diff --git a/packages/streamdown/__tests__/mermaid-utils.test.ts b/packages/streamdown/__tests__/mermaid-utils.test.ts index 3baae087..b2c3b00b 100644 --- a/packages/streamdown/__tests__/mermaid-utils.test.ts +++ b/packages/streamdown/__tests__/mermaid-utils.test.ts @@ -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 = + /]+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 = /]*/; describe("svgToPngBlob", () => { let mockCanvas: any; @@ -132,4 +136,55 @@ describe("svgToPngBlob", () => { svgToPngBlob("Hello"); 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 = + ''; + 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 = + ''; + 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 = + ''; + expect(addXlinkNamespaceIfMissing(input)).toBe(input); + }); + + it("should not modify SVG with no xlink: attributes", () => { + const input = + ''; + expect(addXlinkNamespaceIfMissing(input)).toBe(input); + }); + + it("should inject namespace onto the root tag only", () => { + const input = + ''; + const result = addXlinkNamespaceIfMissing(input); + // Only the first 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 = + ''; + const result = addXlinkNamespaceIfMissing(input); + expect(result).toContain('xmlns:xlink="http://www.w3.org/1999/xlink"'); + }); }); diff --git a/packages/streamdown/lib/mermaid/utils.ts b/packages/streamdown/lib/mermaid/utils.ts index 0242d786..d228fc78 100644 --- a/packages/streamdown/lib/mermaid/utils.ts +++ b/packages/streamdown/lib/mermaid/utils.ts @@ -1,3 +1,29 @@ +const SVG_OPEN_TAG_RE = /(` 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 */ @@ -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";