Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion packages/streamdown/__tests__/mermaid-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { svgToPngBlob } from "../lib/mermaid/utils";
import { addXlinkNamespaceIfMissing, svgToPngBlob } from "../lib/mermaid/utils";

const BASE64_SVG_DATA_URL_REGEX = /^data:image\/svg\+xml;base64,/;
const XLINK_NS_ATTR_RE =
/<svg[^>]+xmlns:xlink="http:\/\/www\.w3\.org\/1999\/xlink"/;
const XLINK_NS_GLOBAL_RE = /xmlns:xlink="http:\/\/www\.w3\.org\/1999\/xlink"/g;
const SVG_OPEN_ATTR_RE = /<svg[^>]*/;

describe("svgToPngBlob", () => {
let mockCanvas: any;
Expand Down Expand Up @@ -132,4 +136,55 @@ describe("svgToPngBlob", () => {
svgToPngBlob("<svg><text>Hello</text></svg>");
expect(mockImage.src).toMatch(BASE64_SVG_DATA_URL_REGEX);
});

it("should inject xmlns:xlink into SVG data URL when xlink: attributes are present", () => {
const svgWithXlink =
'<svg xmlns="http://www.w3.org/2000/svg"><image xlink:href="data:image/png;base64,abc" /></svg>';
svgToPngBlob(svgWithXlink);
const decoded = atob(
mockImage.src.replace("data:image/svg+xml;base64,", "")
);
expect(decoded).toContain('xmlns:xlink="http://www.w3.org/1999/xlink"');
});
});

describe("addXlinkNamespaceIfMissing", () => {
it("should inject xmlns:xlink when xlink: attributes are present but namespace is missing", () => {
const input =
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><image xlink:href="data:image/png;base64,abc" /></svg>';
const result = addXlinkNamespaceIfMissing(input);
expect(result).toContain('xmlns:xlink="http://www.w3.org/1999/xlink"');
expect(result).toMatch(XLINK_NS_ATTR_RE);
});

it("should not modify SVG that already declares xmlns:xlink", () => {
const input =
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image xlink:href="data:image/png;base64,abc" /></svg>';
expect(addXlinkNamespaceIfMissing(input)).toBe(input);
});

it("should not modify SVG with no xlink: attributes", () => {
const input =
'<svg xmlns="http://www.w3.org/2000/svg"><circle r="5" /></svg>';
expect(addXlinkNamespaceIfMissing(input)).toBe(input);
});

it("should inject namespace onto the root <svg> tag only", () => {
const input =
'<svg id="root"><g><svg id="nested" xlink:href="x" /></g></svg>';
const result = addXlinkNamespaceIfMissing(input);
// Only the first <svg> opening tag should get the attribute
const firstSvgTag = result.match(SVG_OPEN_ATTR_RE)?.[0] ?? "";
expect(firstSvgTag).toContain('xmlns:xlink="http://www.w3.org/1999/xlink"');
// Exactly one namespace declaration injected
const occurrences = (result.match(XLINK_NS_GLOBAL_RE) ?? []).length;
expect(occurrences).toBe(1);
});

it("should handle SVG with xlink:actuate and xlink:title attributes", () => {
const input =
'<svg xmlns="http://www.w3.org/2000/svg"><a xlink:actuate="onRequest" xlink:title="link" /></svg>';
const result = addXlinkNamespaceIfMissing(input);
expect(result).toContain('xmlns:xlink="http://www.w3.org/1999/xlink"');
});
});
33 changes: 32 additions & 1 deletion packages/streamdown/lib/mermaid/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
const SVG_OPEN_TAG_RE = /(<svg\b)/;

/**
* Ensure the SVG root element declares the xlink namespace when the document
* contains xlink-prefixed attributes (e.g. `xlink:href` used by Mermaid in
* C4 and sequence diagrams). Without the declaration the SVG is not valid
* XML: browsers refuse to load it as an image, causing PNG export to fail
* silently.
*
* The fix mirrors the approach used for other Mermaid SVG normalisation
* issues: detect the problem via a lightweight string check and patch the
* root `<svg>` opening tag in-place before any further processing.
*
* Falls back to the original string when the namespace is already present or
* when xlink-prefixed attributes are absent.
*/
export const addXlinkNamespaceIfMissing = (svgString: string): string => {
if (!svgString.includes("xlink:") || svgString.includes("xmlns:xlink")) {
return svgString;
}
return svgString.replace(
SVG_OPEN_TAG_RE,
'$1 xmlns:xlink="http://www.w3.org/1999/xlink"'
);
};

/**
* Convert SVG string to PNG blob for export
*/
Expand All @@ -8,9 +34,14 @@ export const svgToPngBlob = (
const scale = options?.scale ?? 5;

return new Promise((resolve, reject) => {
// Ensure the SVG is valid XML before encoding it as a data URI. Mermaid
// can emit xlink-prefixed attributes without declaring xmlns:xlink on the
// root element, which makes the SVG invalid XML and prevents browsers from
// loading it as an image (causing silent PNG export failures).
const normalizedSvg = addXlinkNamespaceIfMissing(svgString);
const encoded =
"data:image/svg+xml;base64," +
btoa(unescape(encodeURIComponent(svgString)));
btoa(unescape(encodeURIComponent(normalizedSvg)));

const img = new Image();
img.crossOrigin = "anonymous";
Expand Down
Loading