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:
| Markdown | Typst |
|---|---|
# Heading | = Heading |
**bold** | *bold* |
*italic* | _italic_ |
> quote | #block(inset: ..., stroke: ...)[...] |
| a pipe table | #table(columns: 3, align: (...), ...) |
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:
| LaTeX | Typst | Note |
|---|---|---|
\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, \omega | alpha, pi, omega | Greek mostly passes straight through |
It’s a heuristic translator, not a LaTeX parser. Unknown commands pass through untouched, on the bet that Typst recognises them. That’s a deliberate 80/20 decision: it handles the math people actually type into a Markdown editor, in a few hundred lines, instead of being a complete LaTeX frontend that would dwarf the rest of the feature.
VI. Mermaid diagrams as virtual files
Inkwell renders Mermaid diagrams (flowcharts, sequence diagrams) to SVG using mermaid.js inside the webview. For the PDF, Typst needs those SVGs — but I didn’t want to spray temp files across the user’s disk on every export.
So the SVGs never touch the filesystem. The webview hands each rendered SVG to the Rust side, keyed by a hash of the diagram’s source text. The converter emits #image("mermaid-<hash>.svg"), and when Typst asks the World for that file, it gets the bytes straight out of an in-memory map. Typst thinks it read a file. It read a HashMap.
The one sharp edge: that hash is computed in two languages — JavaScript when the SVG is produced, Rust when the converter emits the reference. They have to agree exactly, byte for byte, or the key misses and the diagram comes out blank. Getting the two djb2 implementations to produce identical output (down to normalising \r\n to \n first) was a small but real debugging session.
VII. Fonts: embedded at compile time
include_bytes! bakes the font files — Inter, Crimson Pro, IBM Plex Mono, and a math font — directly into the executable. The World’s font() callback hands them to Typst on demand.
No system-font dependency. Identical output on every machine. It works even in a locked-down sandbox. The fonts are part of the binary, the same way the code is.
VIII. The payoff
- PDF export works on Windows, macOS, and Linux from a single code path. The platform
#[cfg]is gone. - It’s real typesetting — justified text, deliberate page breaks (every top-level heading starts a new page), embedded fonts, a proper math renderer instead of a browser approximating one.
- There’s no browser in the export path at all. It’s pure Rust from Markdown string to PDF bytes.
- It’s testable. PDF generation is now a pure function: markdown in, PDF bytes out. The test suite feeds it documents — code blocks, tables, math, Mermaid — and asserts the output starts with the
%PDF-magic number.
IX. If you’re considering this yourself
Typst-as-a-library is a genuinely good experience for embedding document generation in a Rust app. The World trait is the entire API you need to learn, and it’s a clean one.
The catch is everything upstream of Typst. You own the conversion from your format into Typst markup. And if your format carries its own math syntax, you own that translation too. For a Markdown editor that came out to a few hundred lines and one memorable bug about the letters m and c. For an app with deeper LaTeX expectations, it would be a real project in its own right.
Worth it, though. PDF export went from a Windows-only feature held together with COM calls to a cross-platform one that produces documents we’re actually happy to put our names on.
Inkwell is a buy-to-own Markdown editor — offline, no telemetry, no subscription. The Typst PDF engine shipped in v1.4; v1.5 added a SQLite persistence layer. See what’s included →