fix(export): render math, highlighting and diagrams into the exported HTML - #411
Merged
Merged
Conversation
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
exportAsHtmlre-renders from raw Markdown and never callsrenderRichContent, which only ever ran in the viewer. An exported file therefore carried:— 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
renderRichContentmoves out ofMarkdownViewer.svelteintosrc/lib/utils/richContent.ts, taking{ root, libraries, mermaidTheme, … }instead of closing over component state, and readingroot.ownerDocumentrather 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.
sanitizeDiagramSvgmoves alongside — it is the diagram policy, and it belongs next to the only thing that makes diagrams.sanitize.tsis 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,\includegraphicsare inert), and mermaid's SVG goes throughsanitizeDiagramSvg— the same filter the preview uses.Re-running
MARKDOWN_SANITIZE_CONFIGafterwards 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 fileRead from mermaid 11.16.0's source rather than assumed:
createUserStyles→compileCSS(svgId, …)runs stylis with anaddNamespacemiddleware prefixing every rule with#<svgId>, and deletes any at-rule outside a small allowlist —@importand@font-faceare 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.sanitizeCssonly checks brace balance, so a hostileclassDefcould land aurl(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.<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:
\mathbb{R}R+ classmathbb→ KaTeX_AMSR, notℝ\mathcal{L}L+mathcal→ KaTeX_CaligraphicL\mathfrak{g}g+mathfrak→ KaTeX_Frakturg\left(…\right)(+delimsizing size3\sumop-symbol large-opMeasured end to end through the real
exportAsHtml: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 (
\mathbfadds 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 inEXPORT_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 realnpm run build), not relative to the page. Resolving them againstlocation.href404s the face, the face is dropped, and the formula quietly falls back to a serif — nothing looks broken. They are absolutised during thedocument.styleSheetswalk, while the owning sheet is still in hand.Preview-side changes, all narrowing
hljswas assigned after the firstPromise.allandmermaid/renderMathInElementafter a secondawait, leaving a window wherehljswas truthy and the render guard was not.hljs.highlightElementis wrapped in try/catch — one bad block no longer aborts the rest of the render, or the rest of an export.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.tsdrives the realexportAsHtmland asserts on the bytes handed tosave_file_content— the full path including the dialog, the renderer round trip, the sanitize/process pipeline, the font pass andbuildExportDocument.export.tsThe 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.highlightElementandkatex.renderboth fail, mermaid needs layout), injected through the samelibrariesfield the app fills from the preview's own instances; andDOMPurify.sanitize, a no-op without a real DOM, replaced by identity — what the filter does and where it sits stays pinned byexportSanitize.test.ts.Two shim additions, documented in place:
ShimClassListis now iterable (a realDOMTokenListis, andArray.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 percssTextwith the owning sheet'shref.Existing structural guards were followed to the new location rather than relaxed:
singleImplementationConventionnow pinsrichContent.tsas the solemermaid.render(/dompurify/renderRichContent(optionssite.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 behindprefers-color-scheme.<style>with nohref, so the base falls back tolocation.href. It works there because dev paths are absolute; untested.codicon@font-faceis left untouched and still dead in exports — unchanged, and it never applies inside.markdown-body.🤖 Generated with Claude Code