Skip to content

fix(export): render math, highlighting and diagrams into the exported HTML - #411

Merged
PathGao merged 1 commit into
fix/local-file-linksfrom
fix/export-rich-content
Aug 3, 2026
Merged

fix(export): render math, highlighting and diagrams into the exported HTML#411
PathGao merged 1 commit into
fix/local-file-linksfrom
fix/export-rich-content

Conversation

@PathGao

@PathGao PathGao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

6 / 6 of the stack. Base: #409.

The defect

exportAsHtml re-renders from raw Markdown and never calls renderRichContent, which only ever ran in the viewer. An exported file therefore carried:

<p data-math="display" data-math-source="E = mc^2">E = mc^2</p>
<pre><code class="language-mermaid">graph TD; …</code></pre>

— LaTeX source, colourless code, and diagrams as source blocks. The person you exported the document for saw none of it rendered.

This is the last instance of the audit's "two paths do the same thing and only one is maintained".

The fix: one function, two callers

renderRichContent moves out of MarkdownViewer.svelte into src/lib/utils/richContent.ts, taking { root, libraries, mermaidTheme, … } instead of closing over component state, and reading root.ownerDocument rather than the global — so a detached element is a first-class caller. The export renders into the wrapper it already built. No offscreen container, no second document, no iframe.

The viewer keeps an 8-line wrapper supplying the live element and the clipboard behaviour. sanitizeDiagramSvg moves alongside — it is the diagram policy, and it belongs next to the only thing that makes diagrams. sanitize.ts is untouched.

Library loading moves in too, as loadRichContentLibraries() behind a module-cached promise, so the export can never get a differently configured KaTeX or a different highlight.js language registry than the preview.

Sanitization: the document filter is not re-run

Pipeline: render_markdown → sanitizeMarkdownHtml → processMarkdownHtml → renderRichContent → href/image passes.

The document policy still sees the untrusted document, and only that. Everything after is produced by our own libraries from already-filtered text: highlight.js re-emits the block as escaped text, KaTeX builds nodes itself and runs with its default trust: false (so \href, \url, \includegraphics are inert), and mermaid's SVG goes through sanitizeDiagramSvg — the same filter the preview uses.

Re-running MARKDOWN_SANITIZE_CONFIG afterwards would also destroy every diagram (FORBID_TAGS: ['style']). Both reasons point the same way. exportSanitize.test.ts's ordering assertions hold unchanged.

Mermaid's <style> in an exported file

Read from mermaid 11.16.0's source rather than assumed:

  1. It is namespaced. createUserStylescompileCSS(svgId, …) runs stylis with an addNamespace middleware prefixing every rule with #<svgId>, and deletes any at-rule outside a small allowlist@import and @font-face are removed with a warning. The id is one Markpad generates. The block cannot restyle anything outside its diagram, and cannot pull in a remote stylesheet or font.
  2. It is not a new capability. sanitizeCss only checks brace balance, so a hostile classDef could land a url(https://…) in it — but the export already leaves remote <img src="https://…"> exactly as the author wrote it, which is what that CSP clause is for. A document that wants to beacon on open can do it in one line of Markdown with no diagram involved.
  3. The export is the less privileged place for it. The identical <style> has been entering the app's live document since diagrams were added. There it is worth forbidding at the document level — it can hide the title bar. In a standalone file whose entire content is the author's own document, there is nothing to hide.

The reasoning is written into the doc comment where the next person will hit it.

KaTeX fonts: #382's conclusion reverses, because its premise did

#382 declined to embed them on the measured grounds that there was no KaTeX output in an export. That is exactly what this PR changes, so the question was re-measured rather than inherited.

The failure is not missing glyphs — it is wrong ones that look right:

input KaTeX emits without the font, the reader sees
\mathbb{R} ASCII R + class mathbb → KaTeX_AMS R, not
\mathcal{L} ASCII L + mathcal → KaTeX_Caligraphic plain L
\mathfrak{g} ASCII g + mathfrak → KaTeX_Fraktur plain g
\left(…\right) ( + delimsizing size3 normal-height paren around a two-line fraction
\sum U+2211 + op-symbol large-op undersized serif operator

Measured end to end through the real exportAsHtml:

document before after
no math 72,977 B 68,213 B (−6.5%)
simple algebra 73,010 B 230,471 B
calculus (∑ ∫ big delims) 72,983 B 242,431 B
analysis (ℝ ℋ 𝔤) 72,985 B 315,312 B

Only referenced families are embedded — all 20 faces would be 338 KB on every export. Granularity is the family, not the face, deliberately: face-level would cut simple algebra to ~57 KB but requires resolving the cascade (\mathbf adds weight to a family; **$x$** inherits it), and getting that subtly wrong renders one variable in Times with nothing looking broken. "If a family is referenced, ship its faces" cannot fail that way.

No CSP change needed — font-src data: was already in EXPORT_CSP.

A bug that would have made this a silent no-op

The build emits KaTeX's font URLs relative to the stylesheet (url(./KaTeX_Main-Regular.B22Nviop.woff2) inside _app/immutable/assets/*.css, verified against a real npm run build), not relative to the page. Resolving them against location.href 404s the face, the face is dropped, and the formula quietly falls back to a serif — nothing looks broken. They are absolutised during the document.styleSheets walk, while the owning sheet is still in hand.

Preview-side changes, all narrowing

  • Library loading is now atomic. Previously hljs was assigned after the first Promise.all and mermaid/renderMathInElement after a second await, leaving a window where hljs was truthy and the render guard was not.
  • hljs.highlightElement is wrapped in try/catch — one bad block no longer aborts the rest of the render, or the rest of an export.
  • Mermaid ids gained the loop index. They were mermaid-${Date.now()}-${rand}; two diagrams in the same millisecond could collide, and that id namespaces the diagram's own <style> and its marker URLs.

Otherwise the extracted function is byte-for-byte the old logic.

Tests

scripts/exportRichContent.test.ts drives the real exportAsHtml and asserts on the bytes handed to save_file_content — the full path including the dialog, the renderer round trip, the sanitize/process pipeline, the font pass and buildExportDocument.

Baseline 469 / 0
Final 478 / 0
Counter-proof — modules present, the two calls deleted from export.ts 5 pass / 4 fail

The four that go red are exactly the four artefact assertions: typeset math, highlighted code, drawn diagrams, embedded fonts.

Two things are stood in for, both at a library boundary rather than one of ours: highlight.js / KaTeX / mermaid (measured to throw on the #378 shim — hljs.highlightElement and katex.render both fail, mermaid needs layout), injected through the same libraries field the app fills from the preview's own instances; and DOMPurify.sanitize, a no-op without a real DOM, replaced by identity — what the filter does and where it sits stays pinned by exportSanitize.test.ts.

Two shim additions, documented in place: ShimClassList is now iterable (a real DOMTokenList is, and Array.from(el.classList) was silently yielding []without this the highlighting test would have passed for the wrong reason), and CSSOM is modelled as one rule per cssText with the owning sheet's href.

Existing structural guards were followed to the new location rather than relaxed: singleImplementationConvention now pins richContent.ts as the sole mermaid.render( / dompurify / renderRichContent(options site.

npm run check   435 files, 0 errors
npm test        478 / 478
cargo test      131 / 131
npm run build   clean

Not covered

  • theme: 'system' with diagrams. The page follows the reader's OS, but the SVG is baked with the exporter's preference — a system-dark author's export read on a light machine gets a light page with dark diagrams. A script-free fix needs two SVGs behind prefers-color-scheme.
  • Real library output is not asserted — only that the pipeline hands them the right elements and places the results correctly.
  • Dev-mode font base: Vite injects CSS as <style> with no href, so the base falls back to location.href. It works there because dev paths are absolute; untested.
  • Monaco's codicon @font-face is left untouched and still dead in exports — unchanged, and it never applies inside .markdown-body.
  • Diagram renders are serial awaits, so a document with many diagrams takes proportionally longer with no progress indication.

🤖 Generated with Claude Code

… HTML

`exportAsHtml` re-rendered from raw Markdown and never called
`renderRichContent`, which only ever ran in the viewer. So an exported
file carried `<p data-math-source="E = mc^2">E = mc^2</p>`, unhighlighted
code, and mermaid diagrams still as source blocks - the reader of a
document you exported saw LaTeX source and colourless code.

`renderRichContent` moves out of the component into
`src/lib/utils/richContent.ts`, taking its libraries and target element
as arguments and reading `root.ownerDocument` instead of the global, so
a detached element is a first-class caller. The viewer keeps an 8-line
wrapper that supplies the live element and the clipboard behaviour.
Preview and export are now the same function, which was the point - the
last instance of one behaviour with two implementations, only one
maintained.

The document sanitizer is not re-run afterwards. It still sees the
untrusted document and only that; everything after is produced by our
own libraries from already-filtered text, KaTeX runs with `trust: false`,
and mermaid's SVG goes through the same diagram policy the preview uses.
Re-running it would also delete every diagram, since the document policy
forbids `style`.

KaTeX fonts are now embedded, reversing the call made in #382 - that
conclusion rested on there being no KaTeX output in an export, which is
exactly what changed. The failure without them is not missing glyphs but
wrong ones that look right: `\mathbb{R}` is an ASCII `R` styled by
KaTeX_AMS, so a reader sees `R`, and `\mathcal{L}` an `L`. Only the
families a document actually references are embedded, so a document with
no math gets 4.7 KB smaller and one using blackboard bold pays 237 KB.

The build emits KaTeX's font URLs relative to the stylesheet rather than
the page, so resolving them against the document 404s the face and drops
it silently. They are absolutised during the stylesheet walk, while the
owning sheet is still in hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao merged commit 14bda23 into master Aug 3, 2026
4 checks passed
@PathGao
PathGao deleted the fix/export-rich-content branch August 3, 2026 02:05
PathGao added a commit that referenced this pull request Aug 5, 2026
…cannot delete them (#455)

Every diagram whose labels Mermaid put in a `<foreignObject>` rendered as empty
shapes — in the live preview and in the exported HTML alike, because both call
`renderRichContent` → `sanitizeDiagramSvg`.

## Mechanism

`sanitizeDiagramSvg` allowed the `foreignObject` *element*:

    DOMPurify.sanitize(svg, { ADD_TAGS: ['foreignObject'], ADD_ATTR: [...] })

The label is not the element. It is the HTML inside it — `<div
class="labelBkg">…<span class="nodeLabel">Alpha</span></div>` — and DOMPurify
deletes HTML children of an SVG element unless the parent is an HTML integration
point:

    // dompurify 3.4.12, dist/purify.es.mjs
    const HTML_INTEGRATION_POINTS = freeze(['annotation-xml']);
    _checkHtmlNamespace = function (tagName, parent, parentTagName) {
      if (parent.namespaceURI === SVG_NAMESPACE && !HTML_INTEGRATION_POINTS[parentTagName])
        return false;

`foreignobject` is not on that list, so no `ADD_TAGS` entry could save its
children. `DOMPurify.removed` grew one entry per label and what survived was
literally `<foreignObject width="37.98" height="24"></foreignObject>`.

## Scope, measured

Real mermaid 11.16.0 driven by real dompurify 3.4.12 in a browser, over every
diagram type 11.16.0 ships a renderer for — not only the five sampled in the
report:

  labels entirely deleted (10)  flowchart, flowchart-v2, classDiagram,
                                stateDiagram, stateDiagram-v2, erDiagram,
                                requirementDiagram, mindmap, block, kanban
  labels hidden (1)             journey — see the `<switch>` note below
  unaffected                    sequence, gantt, pie, quadrantChart, gitGraph,
                                C4Context, timeline, sankey, xychart,
                                architecture, info, ishikawa, wardley, treemap,
                                packet, radar, treeView — all SVG `<text>`

## The fix

`mermaid.initialize` now passes `htmlLabels: false`. Mermaid then emits SVG
`<text>`, which no sanitizer objects to; every label above survives. One
root-level key is enough — the per-diagram `flowchart.htmlLabels` /
`class.htmlLabels` / … settings are deprecated in 11.x and the root one takes
precedence over them — so the diagram-specific keys are deliberately not set.

Because nothing then depends on HTML inside the SVG, `foreignObject` also leaves
`sanitizeDiagramSvg`'s `ADD_TAGS`. Nothing needs it:

  - Mermaid still emits `foreignObject` unconditionally in three places — venn
    `text` nodes, eventmodeling boxes, architecture `iconText` — but their HTML
    children are deleted by the rule above whether or not the tag is allowed, so
    the allowance could only ever produce an empty box. Measured: venn's "Bravo"
    label is absent with the tag allowed and absent without it.
  - The `<switch>`-based renderers (journey by default, and anything configured
    `textPlacement: 'fo'`) pair the `foreignObject` with an SVG `<text>`
    fallback. A browser renders the first child it supports, so an
    emptied-but-present `foreignObject` *suppressed* a label that had come
    through the filter intact. Measured: the journey's task labels are 0×0 with
    the tag on the allowlist and 37×20 with it removed. Dropping it fixes a
    diagram `htmlLabels: false` alone cannot.

So the filter gets smaller, not larger.

## The rejected alternative

DOMPurify 3.4.12 accepts `HTML_INTEGRATION_POINTS` as a config option, so
allowing `foreignObject` to be an integration point is a candidate fix that
would have preserved rendering fidelity exactly. Two findings:

  - It only works as an object map. `HTML_INTEGRATION_POINTS: ['annotation-xml',
    'foreignobject']` is a silent no-op — the value is `clone()`d, and cloning an
    array yields index keys, which also drops `annotation-xml`. Passing
    `{ 'annotation-xml': true, foreignobject: true }` does restore every label.
  - It is the wrong trade. It re-permits HTML inside SVG, which DOMPurify keeps
    out on purpose because serialise-then-reparse is the mutation-XSS primitive —
    and `container.innerHTML = sanitizeDiagramSvg(svg)` is exactly that reparse.
    Mermaid source is document content, so the SVG is attacker-influenced; #384
    exists in this same cycle because a document's `<style>` reached the app's own
    DOM. Measured difference: with the override, `<svg><foreignObject><img
    src=x></foreignObject></svg>` survives sanitisation and materialises as a
    live `<img>` on the reparse, where today (and with this fix) it does not.

## Trade-offs of `htmlLabels: false`

  - Markup inside a node label renders literally: `A["<b>bold</b> text"]` comes
    out as the six characters `<b>` followed by `bold`. No sample, test or doc in
    this repo puts HTML in a Mermaid label (checked: `samples/` has one diagram,
    `graph TD` with plain labels).
  - KaTeX inside a label goes the same way — Mermaid's math path runs only under
    `useHtmlLabels` — so `A["$$x^2$$"]` renders as its source. It rendered as
    nothing at all before this change, so this is not a loss.
  - Wrapping is measured by the SVG text engine instead of the browser's layout,
    so long labels break at slightly different points.

## Not a v2.7.0 regression

The allowance predates this cycle: it was introduced in eb9a1c7 ("Fix Mermaid
diagram rendering with SVG foreignObject support", 2026-02-04) and #411 only
moved it into `richContent.ts`. v2.6.13 ships the byte-identical config, and the
dompurify it shipped with (3.3.1) has the same `addToSet({}, ['annotation-xml'])`
and the same SVG-namespace check. So the release does not have to hold for this.

## Tests

`scripts/mermaidDiagramLabels.test.ts` runs the real `renderRichContent` over a
real code block with Mermaid answered out of `scripts/mermaidDiagramCorpus.json`
— bytes real mermaid 11.16.0 emitted, captured once with the old config and once
with the new one, and keyed by the config the pipeline actually sends, so a
config nobody has measured is an error rather than a pass. It parses what the
pipeline produced and asserts there is no HTML in the SVG for the namespace rule
to reach and that every label is in an SVG `<text>`.

What it cannot execute is DOMPurify: without a DOM the library returns a bare
factory with no `sanitize` at all, so it is stood in for by the identity function
as `exportRichContent.test.ts` already does, and a third test asserts that state
so the middle one is not misread as "the filter kept the labels". Making that
half real needs a DOM faithful enough to reproduce HTML5 foreign-content parsing,
which is the rule under test — a shim written here would be marking its own
homework, and no test-only DOM dependency was added.

Falsified: with only `htmlLabels: false` reverted, the corpus stand-in returns
the old bytes and the middle test fails with `flowchart: the rendered diagram
still carries its labels in foreignObject, whose HTML children DOMPurify removes
regardless of ADD_TAGS`, 3 !== 0.

`previewSanitize.test.ts` asserted the source text `ADD_TAGS: ['foreignObject']`.
That assertion confirmed a config string existed while every label was being
stripped, so it is replaced rather than re-anchored: the split between the two
sanitizer configs is now pinned on the reason that survives — the diagram filter
must permit the `<style>` the document policy forbids.

The rationale comment in `scripts/sourceTree.ts` still says the diagram config
"needs `foreignObject`"; it is left for #454's rewrite of that tree rather than
conflicting with it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant