fix(desktop): render Mermaid labels that contain <br/>
`mermaid.render()` returns SVG aimed at inline HTML, and its sanitizer gives back a `<br/>` written in a label as an open `<br>`. That string is not well-formed XML, so the strict `image/svg+xml` parse in `normalizeSvgSize` yields the parsererror document instead of an `<svg>`, takes the `el.tagName !== 'svg'` early return, and hands the *unnormalized* string back. The diagram `<img>` then gets it as a `data:` URI, which Blink decodes as strict XML and rejects for the same reason — so the message shows a broken image whose alt text reads "Mermaid diagram", with nothing thrown and nothing written to any log. Close HTML void tags before the markup reaches either XML parse. The pass is generic rather than a `<br>` special case, because the same class of input reaches `<hr>`, `<img>`, `<input>`, …, and it is idempotent: already-closed tags and non-void tags (including names that merely start with a void tag, such as `<break>`) pass through untouched. Fixes #133089 Verified by replaying 32 real ```mermaid fences from a session database through the app's own renderer chunks in Electron 40.10.2 / Chromium 144: 24/32 fail before this change — every failure has `<br/>` in a label, with a perfect 1:1 correlation — and 31/31 renderable diagrams succeed after it. The 32nd is an invalid Mermaid source that correctly keeps falling back to its source block.
This commit is contained in:
@@ -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 <br>, so the image stays loadable', async () => {
|
||||
// `A[x<br/>y]` comes back from mermaid as `<br>` — 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: '<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 260.34375 70"><text><tspan><br>x</tspan></text></svg>'
|
||||
})
|
||||
|
||||
const { container } = render(<MermaidRenderer code="graph TD; A[x<br/>y] --> B" />)
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(1))
|
||||
|
||||
const src = decodeURIComponent([...container.querySelectorAll('img')][0]?.src.split(',')[1] ?? '')
|
||||
|
||||
expect(src).toContain('<br/>')
|
||||
expect(new DOMParser().parseFromString(src, 'image/svg+xml').documentElement.tagName).toBe('svg')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 `<br/>` in a label reaches us
|
||||
// as an open `<br>`; 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
|
||||
|
||||
@@ -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: `<br/>` in the source comes back
|
||||
// from `mermaid.render()` as `<br>`. 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 <img> 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('<text><tspan><br>x</tspan></text>')).toBe('<text><tspan><br/>x</tspan></text>')
|
||||
})
|
||||
|
||||
it('closes an open void tag that carries attributes', () => {
|
||||
expect(xmlWellFormedSvg('<br class="label">')).toBe('<br class="label"/>')
|
||||
})
|
||||
|
||||
it('leaves already-closed void tags untouched', () => {
|
||||
const svg = '<text><br/><hr /></text>'
|
||||
|
||||
expect(xmlWellFormedSvg(svg)).toBe(svg)
|
||||
})
|
||||
|
||||
it('leaves non-void tags and void-tag lookalikes untouched', () => {
|
||||
const svg = '<blockquote><break>x</break><bring>y</bring><image href="a"/></blockquote>'
|
||||
|
||||
expect(xmlWellFormedSvg(svg)).toBe(svg)
|
||||
})
|
||||
|
||||
it('makes a label with <br> parse as an <svg> instead of a parsererror page', () => {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 260.34375 70"><text><tspan><br>x</tspan></text></svg>'
|
||||
|
||||
expect(rootOf(svg)).not.toBe('svg')
|
||||
expect(rootOf(xmlWellFormedSvg(svg))).toBe('svg')
|
||||
expect(normalizeSvgSize(xmlWellFormedSvg(svg))).toContain('width="260.34375"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 `<br/>` in the source comes back from `mermaid.render()` as
|
||||
// `<br>`. Everything below parses that string as strict XML
|
||||
// (`image/svg+xml`) — and so does Blink when the same string reaches an <img>
|
||||
// as a data: URI — where an open void tag is a malformed document: the parse
|
||||
// yields a parsererror page rather than an <svg> 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 (`<break>`), 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user