From 1a69486e82092a5e0ad1591547931dd4ea7fa5dd Mon Sep 17 00:00:00 2001 From: Dmitrii Troitskii Date: Thu, 25 Jun 2026 10:43:00 +0000 Subject: [PATCH] fix(mermaid): inject xmlns:xlink namespace if missing before PNG export Mermaid can emit SVG containing xlink-prefixed attributes (e.g. xlink:href in C4Context and sequence diagrams) without declaring the xmlns:xlink namespace on the root element. The resulting SVG is not valid XML, so browsers refuse to load it as an image, causing PNG export to fail silently. Add addXlinkNamespaceIfMissing() which detects the missing declaration via lightweight string checks and injects it onto the root tag. svgToPngBlob() now normalises the input before base64-encoding it as a data URI, matching the existing pattern for other Mermaid SVG normalisation fixes (see #516). Fixes #541 --- .../__tests__/mermaid-utils.test.ts | 57 ++++++++++++++++++- packages/streamdown/lib/mermaid/utils.ts | 33 ++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) 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";