fix(desktop): make mermaid SVG well-formed via the HTML parser, not a void-tag regex
The void-tag pass from the previous commit fixes `<br>`, but mermaid's label HTML carries more than void tags: a non-breaking space (` `, mermaid's `#nbsp;`, or a literal U+00A0 in the source) is serialised as the ` ` entity, which XML does not define. Such a label still decodes to a broken image. mermaid.render() returns the HTML serialisation of the diagram, so parse it with the HTML parser it was written for and re-serialise it with XMLSerializer. That fixes the whole class at once. Against mermaid 11.16 in Chromium, across flowchart, class, state, ER, mindmap, block, kanban, sequence, journey, gantt, pie, timeline, gitGraph, quadrant and xychart output, the round-trip yields the same element tree and image size as the regex wherever the regex output is valid XML, and also loads the non-breaking-space cases the regex leaves broken. Drop a label's plain `xmlns` attribute before serialising: the serializer declares the namespace itself, and a spec-conformant one (jsdom) writes both, a duplicate attribute. The tests' fixtures put `<br>` directly in SVG <text>, which mermaid never emits; replace them with the real 11.16 label shape (XHTML in a <foreignObject>) and assert the label keeps its line break and its non-breaking space. Refs #133089, #134031
This commit is contained in:
@@ -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 <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).
|
||||
it('repairs a label mermaid returned as HTML, so the image stays loadable', async () => {
|
||||
// `A["a<br/>b c"]` comes back from mermaid with an open `<br>` 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: '<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 260.34375 70"><text><tspan><br>x</tspan></text></svg>'
|
||||
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 260.34375 70"><foreignObject width="19.109375" height="48"><div xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"><p>a<br>b c</p></span></div></foreignObject></svg>'
|
||||
})
|
||||
|
||||
const { container } = render(<MermaidRenderer code="graph TD; A[x<br/>y] --> B" />)
|
||||
const { container } = render(<MermaidRenderer code='graph TD; A["a<br/>b c"]' />)
|
||||
|
||||
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('<br/>')
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 `<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).
|
||||
// Mermaid's markup targets inline HTML, so a label's `<br/>` reaches us as
|
||||
// an open `<br>` 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
|
||||
|
||||
@@ -65,40 +65,27 @@ describe('normalizeSvgSize', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// 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).
|
||||
// Real mermaid 11.16 label island for `A["a<br/>b c"]` (verified against
|
||||
// the installed package): the HTML serialisation leaves `<br>` 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 <img> as a data: URI, which is how a diagram
|
||||
// silently stops rendering (broken image, no error anywhere).
|
||||
const MERMAID_LABEL_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 260.34375 70"><g class="label"><foreignObject width="19.109375" height="48"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>a<br>b c</p></span></div></foreignObject></g></svg>`
|
||||
|
||||
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('<text><tspan><br>x</tspan></text>')).toBe('<text><tspan><br/>x</tspan></text>')
|
||||
it('makes a mermaid label with <br> and parse as an <svg> 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('<br class="label">')).toBe('<br class="label"/>')
|
||||
})
|
||||
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 = '<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"')
|
||||
expect(label?.querySelectorAll('br')).toHaveLength(1)
|
||||
expect(label?.textContent).toBe('ab c')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 `<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')
|
||||
|
||||
// `mermaid.render()` returns the HTML serialisation of the diagram, aimed at
|
||||
// inline HTML. Its label islands (XHTML inside <foreignObject>) therefore carry
|
||||
// HTML-only syntax: a `<br/>` in the source comes back as an open `<br>`, 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 <img> 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 {
|
||||
|
||||
Reference in New Issue
Block a user