diff --git a/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.test.tsx b/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.test.tsx index d4ce73065c..ade733e3df 100644 --- a/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.test.tsx +++ b/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.test.tsx @@ -47,21 +47,23 @@ describe('MermaidRenderer', () => { expect(decodeURIComponent(images[0]?.src.split(',')[1] ?? '')).toContain('marker-end="url(#arrow)"') }) - it('repairs a label mermaid returned with an open
, so the image stays loadable', async () => { - // `A[x
y]` comes back from mermaid as `
` — malformed XML, so the - // data: URI fails to decode and the message shows a broken image with the - // alt text instead of the diagram (#133089). + it('repairs a label mermaid returned as HTML, so the image stays loadable', async () => { + // `A["a
b c"]` comes back from mermaid with an open `
` and an + // ` ` entity — malformed XML, so the data: URI fails to decode and the + // message shows a broken image with the alt text instead of the diagram + // (#133089). renderMermaid.mockResolvedValueOnce({ - svg: '
x' + svg: '

a
b c

' }) - const { container } = render() + const { container } = render() await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(1)) const src = decodeURIComponent([...container.querySelectorAll('img')][0]?.src.split(',')[1] ?? '') + const doc = new DOMParser().parseFromString(src, 'image/svg+xml') - expect(src).toContain('
') - expect(new DOMParser().parseFromString(src, 'image/svg+xml').documentElement.tagName).toBe('svg') + expect(doc.documentElement.tagName).toBe('svg') + expect(doc.querySelector('p')?.textContent).toBe('ab c') }) }) diff --git a/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx b/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx index 2dc7a93491..8b5857638a 100644 --- a/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx +++ b/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx @@ -36,9 +36,10 @@ const renderCache = createMermaidRenderCache({ const id = `mmd-${Math.random().toString(36).slice(2)}` const result = await mermaid.render(id, code) - // Mermaid's markup targets inline HTML, so a `
` in a label reaches us - // as an open `
`; close void tags before the size pass and before the - // data: URI below, or the diagram decodes to a broken image (#133089). + // Mermaid's markup targets inline HTML, so a label's `
` reaches us as + // an open `
` and a non-breaking space as ` `; make it XML before + // the size pass and the data: URI below, or the diagram decodes to a + // broken image (#133089). return normalizeSvgSize(xmlWellFormedSvg(result.svg)) }, // Defer until the source fallback has had a frame to paint, so the mermaid diff --git a/apps/desktop/src/lib/svg-image.test.ts b/apps/desktop/src/lib/svg-image.test.ts index 4197f39290..87be49ad1d 100644 --- a/apps/desktop/src/lib/svg-image.test.ts +++ b/apps/desktop/src/lib/svg-image.test.ts @@ -65,40 +65,27 @@ describe('normalizeSvgSize', () => { }) }) -// A mermaid label keeps HTML void tags open: `
` in the source comes back -// from `mermaid.render()` as `
`. That makes the string malformed XML, so -// every strict `image/svg+xml` parse below degrades — and Blink fails the same -// way when the string is handed to an as a data: URI, which is how a -// diagram silently stops rendering (broken image, no error anywhere). +// Real mermaid 11.16 label island for `A["a
b c"]` (verified against +// the installed package): the HTML serialisation leaves `
` open and writes +// the non-breaking space as ` `, and neither is well-formed XML. Every +// strict `image/svg+xml` parse degrades on it — and Blink fails the same way +// when the string is handed to an as a data: URI, which is how a diagram +// silently stops rendering (broken image, no error anywhere). +const MERMAID_LABEL_SVG = `

a
b c

` + describe('xmlWellFormedSvg', () => { - const rootOf = (svg: string) => new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement.tagName + const parseXml = (svg: string) => new DOMParser().parseFromString(svg, 'image/svg+xml') - it('closes a void tag mermaid left open inside a label', () => { - expect(xmlWellFormedSvg('
x
')).toBe('
x
') + it('makes a mermaid label with
and   parse as an instead of a parsererror page', () => { + expect(parseXml(MERMAID_LABEL_SVG).documentElement.tagName).not.toBe('svg') + expect(parseXml(xmlWellFormedSvg(MERMAID_LABEL_SVG)).documentElement.tagName).toBe('svg') + expect(normalizeSvgSize(xmlWellFormedSvg(MERMAID_LABEL_SVG))).toContain('width="260.34375"') }) - it('closes an open void tag that carries attributes', () => { - expect(xmlWellFormedSvg('
')).toBe('
') - }) + it('keeps the label content: the line break and the non-breaking space', () => { + const label = parseXml(xmlWellFormedSvg(MERMAID_LABEL_SVG)).querySelector('p') - it('leaves already-closed void tags untouched', () => { - const svg = '

' - - expect(xmlWellFormedSvg(svg)).toBe(svg) - }) - - it('leaves non-void tags and void-tag lookalikes untouched', () => { - const svg = '
xy
' - - expect(xmlWellFormedSvg(svg)).toBe(svg) - }) - - it('makes a label with
parse as an instead of a parsererror page', () => { - const svg = - '
x' - - expect(rootOf(svg)).not.toBe('svg') - expect(rootOf(xmlWellFormedSvg(svg))).toBe('svg') - expect(normalizeSvgSize(xmlWellFormedSvg(svg))).toContain('width="260.34375"') + expect(label?.querySelectorAll('br')).toHaveLength(1) + expect(label?.textContent).toBe('ab c') }) }) diff --git a/apps/desktop/src/lib/svg-image.ts b/apps/desktop/src/lib/svg-image.ts index 22cb71c617..d5e8d759b1 100644 --- a/apps/desktop/src/lib/svg-image.ts +++ b/apps/desktop/src/lib/svg-image.ts @@ -16,22 +16,31 @@ function viewBoxSize(el: Element): { height: number; width: number } | null { return vbW > 0 && vbH > 0 ? { height: vbH, width: vbW } : null } -// Mermaid emits SVG aimed at inline HTML: its labels keep HTML void tags -// unclosed, so a `
` in the source comes back from `mermaid.render()` as -// `
`. Everything below parses that string as strict XML -// (`image/svg+xml`) — and so does Blink when the same string reaches an -// as a data: URI — where an open void tag is a malformed document: the parse -// yields a parsererror page rather than an and the diagram silently stops -// rendering. Close void tags before anything XML-parses the markup. Already -// closed tags, and names that merely start with a void tag (``), pass -// through untouched. -const VOID_TAGS = 'area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr' -const VOID_TAG_RE = new RegExp(`<(${VOID_TAGS})((?:\\s[^<>]*?)?)(/?)>`, 'gi') - +// `mermaid.render()` returns the HTML serialisation of the diagram, aimed at +// inline HTML. Its label islands (XHTML inside ) therefore carry +// HTML-only syntax: a `
` in the source comes back as an open `
`, and a +// non-breaking space (` `, mermaid's `#nbsp;`, or a literal U+00A0) comes +// back as the ` ` entity, which XML does not define. Everything below +// parses that string as strict XML (`image/svg+xml`) — and so does Blink when +// the same string reaches an as a data: URI — so either one turns the +// document into a parsererror page and the diagram silently stops rendering. +// Parse it with the HTML parser it was serialised for and re-serialise as XML, +// which fixes every such construct at once rather than one tag at a time. export function xmlWellFormedSvg(svg: string): string { - return svg.replace(VOID_TAG_RE, (match, tag: string, attrs: string, slash: string) => - slash ? match : `<${tag}${attrs}/>` - ) + const el = new DOMParser().parseFromString(svg, 'text/html').querySelector('svg') + + if (!el) { + return svg + } + + // The HTML parser keeps a label's `xmlns="…/xhtml"` as a plain attribute. The + // serializer declares that namespace itself, and a spec-conformant one (jsdom) + // also writes the plain attribute — a duplicate `xmlns`, malformed again. + for (const node of el.querySelectorAll('[xmlns]')) { + node.removeAttribute('xmlns') + } + + return new XMLSerializer().serializeToString(el) } export function normalizeSvgSize(svg: string): string {