Why I Replaced WebView2 Print-to-PDF with a Typst Engine — Lore — 4worlds.dev<br>Inkwell is a desktop Markdown editor built with Tauri and Rust. Until version 1.4, exporting a document to PDF worked on exactly one platform: Windows. The macOS and Linux builds returned an error if you even tried.
This is the story of why that was, and how a Rust typesetting engine called Typst fixed it.
I. The browser was doing the typesetting
A Tauri app doesn’t bundle a browser. It uses whatever webview the OS already ships, WebView2 (Chromium) on Windows, WKWebView on macOS, WebKitGTK on Linux. That’s what keeps the binary around 40 MB instead of 150.
The original PDF export leaned on that webview. On Windows it called WebView2’s COM PrintToPdf interface — the same machinery behind Ctrl+P → Save as PDF in Edge. It worked. But it had three problems:
It was Windows-only. Each platform’s webview exposes a different print API, or none at all. The macOS and Linux builds had the export path compiled out behind a #[cfg] — they returned an error.
The output was browser-print quality. Page breaks landed wherever the browser decided. Typography was whatever the CSS plus the rendering engine produced. It looked like a printed web page, because that’s exactly what it was.
The COM interface was brittle. Driving a Windows COM API from Rust to coax a PDF out of a webview is not a code path you enjoy maintaining.
PDF export is a paid feature in Inkwell. Shipping a paid feature that only works on one of three platforms is not a great look.
II. Enter Typst
Typst is a typesetting system — think LaTeX, redesigned this decade. The part that mattered to me: it’s written in Rust and published as a crate. You can add it to a Cargo.toml and call typst::compile() directly.
That reframes the whole problem. Instead of asking a browser to print a web page, I could compile the document the way a typesetting system does — and the typesetter is just a library call. No browser. No COM. No per-platform #[cfg].
III. The integration point: the World trait
Typst’s compiler doesn’t assume a filesystem. It can’t — it runs in browsers, in CI, embedded in other apps. Instead, everything it needs from the outside world goes through one trait. You implement World, and the compiler calls back into it whenever it wants a source file, a font, an asset, or the current date.
Inkwell’s implementation is deliberately minimal — stateless, no caching, no incremental compilation, because a one-shot export doesn’t need any of that. It answers four questions:
Where’s the main source? An in-memory string of generated Typst markup.
Where’s this asset file? Checked against an in-memory map first (more on that below), then resolved relative to the document’s folder — with a guard that rejects ../../../etc/passwd-style path traversal.
Where are the fonts? Embedded in the binary (more on that below too).
What’s today’s date? The system clock.
That’s the entire surface you need to learn to embed Typst in an application. It’s a remarkably clean boundary.
IV. Markdown is not HTML — and that helped
The key shift: I wasn’t converting HTML to PDF. I was converting Markdown to Typst markup — a source-to-source translation between two plain-text formats.
Inkwell parses Markdown with pulldown-cmark and walks the resulting event stream, emitting Typst as it goes:
MarkdownTypst# Heading= Heading**bold***bold**italic*_italic_> quote#block(inset: ..., stroke: ...)[...]a pipe table#table(columns: 3, align: (...), ...)<br>A nice side effect: as each heading is emitted, the converter also writes a Typst label derived from the heading text — a slug. That means an in-document link like [see below](#architecture) becomes a clickable cross-reference in the exported PDF, not dead text. The converter does a pre-pass to collect every heading slug first, so it knows which fragment links are real and which should quietly degrade to plain text.
V. The hard part: LaTeX math
Inkwell’s live preview renders math with KaTeX, which speaks LaTeX. Typst has its own math syntax. The two rhyme, but they are not the same language — so there’s a hand-written translator between them.
The trap that took longest to find: implicit multiplication.
In LaTeX, mc^2 means m × c². In Typst, mc is a single identifier — a variable literally named “mc”. So E = mc^2 rendered as the word “mc” with a superscript. The fix is to insert spaces between adjacent single letters: mc becomes m c. Obvious in hindsight; mystifying when you’re staring at a PDF that says E = mc² with the mc in the wrong font.
The rest of the translation is a pile of smaller mappings:
LaTeXTypstNote\frac{a}{b}frac(a, b)functions take parenthesised args\mathbf{E}bold(E)font commands become function calls{ a + b }( a + b )Typst groups math with parens, not braces\left( ... \right)( ... )delimiters dropped — Typst auto-sizes them\begin{pmatrix}...\end{pmatrix}mat(delim: "(", ...)& → , and \\ → ;\alpha, \pi, \omegaalpha, pi, omegaGreek mostly...