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 d333bf3fa0..d4ce73065c 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 @@ -46,4 +46,22 @@ 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). + renderMermaid.mockResolvedValueOnce({ + svg: '
x' + }) + + const { container } = render() + + await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(1)) + + const src = decodeURIComponent([...container.querySelectorAll('img')][0]?.src.split(',')[1] ?? '') + + expect(src).toContain('
') + expect(new DOMParser().parseFromString(src, 'image/svg+xml').documentElement.tagName).toBe('svg') + }) }) 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 a7514c5426..2dc7a93491 100644 --- a/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx +++ b/apps/desktop/src/components/assistant-ui/embeds/mermaid-embed.tsx @@ -4,7 +4,7 @@ import type { Mermaid as MermaidApi } from 'mermaid' import { useEffect, useState } from 'react' import { Zoomable } from '@/components/ui/zoomable' -import { copySvgAsPng, normalizeSvgSize } from '@/lib/svg-image' +import { copySvgAsPng, normalizeSvgSize, xmlWellFormedSvg } from '@/lib/svg-image' import { cn } from '@/lib/utils' import { createMermaidRenderCache, createRetryableLoader, nextPaint } from './mermaid-render-cache' @@ -36,7 +36,10 @@ const renderCache = createMermaidRenderCache({ const id = `mmd-${Math.random().toString(36).slice(2)}` const result = await mermaid.render(id, code) - return normalizeSvgSize(result.svg) + // 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). + return normalizeSvgSize(xmlWellFormedSvg(result.svg)) }, // Defer until the source fallback has had a frame to paint, so the mermaid // runtime import and parse/layout cannot contend with first paint of the diff --git a/apps/desktop/src/lib/svg-image.test.ts b/apps/desktop/src/lib/svg-image.test.ts index 2e81a5958c..4197f39290 100644 --- a/apps/desktop/src/lib/svg-image.test.ts +++ b/apps/desktop/src/lib/svg-image.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { normalizeSvgSize, svgSize } from './svg-image' +import { normalizeSvgSize, svgSize, xmlWellFormedSvg } from './svg-image' // Real mermaid 11.16 render output shape (verified against the installed // package): width="100%" + inline style="max-width: Npx" + viewBox. @@ -64,3 +64,41 @@ describe('normalizeSvgSize', () => { expect(normalizeSvgSize(svg)).toBe(svg) }) }) + +// 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). +describe('xmlWellFormedSvg', () => { + const rootOf = (svg: string) => new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement.tagName + + it('closes a void tag mermaid left open inside a label', () => { + expect(xmlWellFormedSvg('
x
')).toBe('
x
') + }) + + it('closes an open void tag that carries attributes', () => { + expect(xmlWellFormedSvg('
')).toBe('
') + }) + + 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"') + }) +}) diff --git a/apps/desktop/src/lib/svg-image.ts b/apps/desktop/src/lib/svg-image.ts index a9397dc969..22cb71c617 100644 --- a/apps/desktop/src/lib/svg-image.ts +++ b/apps/desktop/src/lib/svg-image.ts @@ -16,6 +16,24 @@ 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') + +export function xmlWellFormedSvg(svg: string): string { + return svg.replace(VOID_TAG_RE, (match, tag: string, attrs: string, slash: string) => + slash ? match : `<${tag}${attrs}/>` + ) +} + export function normalizeSvgSize(svg: string): string { const el = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement