RFC: Live typst preview #51633
Replies: 2 comments 1 reply
-
output.mov |
Beta Was this translation helpful? Give feedback.
-
|
Huge +1 for Typst rendering and custom viewer extensions in Zed. To add to this, I've been working on a hobby project using GPUI to render Typst directly to a preview window. The core logic is in this crate: typst-gpui. It's still an early proof of concept with some known translation limitations (e.g., circles are limited to 2-color gradients, and ovals show up as pill shapes, though PDF export works perfectly). I don't have the time or deep expertise to integrate it into Zed myself, but someone familiar with Zed's preview architecture might find it useful as a reference or building block. If Zed opens up custom viewers for extensions down the road, I'd love to try porting this over! |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Commit: 172fe93 / https://github.com/dsturnbull/zed/tree/typst-preview
Companion tinymist fork: https://github.com/dsturnbull/tinymist (1 commit on top of upstream)
Problem
There's no live preview for typst documents in Zed. The typst extension launches tinymist as a language server, which already does incremental compilation with comemo memoisation on every keystroke, but there's no way to see the rendered output without switching to an external PDF viewer or browser.
Typst's existing preview ecosystem is built around a browser-based frontend that consumes tinymist's reflexo vector IR format, a binary protocol with sub-page deltas, designed for the WASM renderer. Pulling that rendering stack into Zed would mean ~250 reflexo crates or shipping a WebView. Neither is appealing.
Background
tinymist's preview architecture has two halves: a compile pipeline (the LSP, comemo, incremental compilation, already running) and a render pipeline (reflexo vector IR to WASM frontend to browser canvas). The compile pipeline is the expensive part and it's already paid for. The render pipeline is what we need to replace.
Zed already has usvg + resvg in its dependency tree (from pdf_viewer). typst already has
typst_svg::svg(page)which renders a compiled page to a complete SVG string. The gap is small: tinymist needs to calltypst_svg::svg()instead of packing vector IR, and send the SVG string over the existing WebSocket data plane.How other editors do it
VS Code uses tinymist's browser-based preview: a WebView panel loads the reflexo WASM renderer, which connects to tinymist's WebSocket and consumes vector IR. This works well but depends on WebView, which Zed doesn't have (and adding one for a single feature is heavy).
Neovim plugins typically shell out to
typst compile --format pdfand display the result in an external viewer. This loses the live-update-on-keystroke experience.Design
tinymist fork (1 commit)
Two new flags on the preview server, both opt-in:
--server-svg: theRenderActorcallstypst_svg::svg(page)for each page and sends the result asWsMessage::Textinstead of binary vector IR. Multi-page documents send one message per page with apage:{idx}:{total}\nheader.--strip-svg-glyph-defs: hash-based stripping of unchanged<defs id="glyph">sections after the first frame. Typst SVGs embed every glyph outline as a<symbol>, typically 80-90% of the SVG size. When the glyph set hasn't changed (which is most keystrokes), this reduces per-frame transfer from ~2MB to ~200KB. Per-page hashing so multi-page documents don't cross-contaminate. Off by default because the client must cache and re-inject the stripped defs.The existing vector IR path, browser-based frontend, and all default preview behaviour are untouched.
Zed side:
typst_viewercrateLSP integration. Finds the running tinymist server, sends
tinymist.doKillPreview(per-file task ID for concurrent preview support) thentinymist.doStartPreviewwith--server-svg --strip-svg-glyph-defs --task-id=<path> --data-plane-host=127.0.0.1:0. Retry logic handles tinymist's config-reload dispose race (whereworkspace/didChangeConfigurationcauses a project reload that kills the preview immediately after it starts).Receive loop. Connects to the WebSocket data plane, receives SVG text messages, rasterises on background threads. Frame dropping via
now_or_neverdrain of queued messages: during the ~80ms rasterisation window, stale intermediate frames are discarded. Per-page glyph defs caching: first frame's defs are cached per page, injected into subsequent stripped frames.Multi-page display. Pages laid out vertically with
ScrollHandleand scrollbar (same pattern as pdf_viewer). Visible-page-first rasterisation ordering: reads the scroll offset to prioritise the page the user is looking at, then expands outward to neighbours.Workspace integration.
OpenPreview/OpenPreviewToTheSideactions with keybindings.key_context+track_focuson the root div so workspace-level keyboard shortcuts route through the preview pane.Performance
Benchmarked in release against a complex A4 2-column legal reference sheet (536 glyphs, 7704 glyph uses, 2566 text groups, ~2MB SVG per page, 5 pages):
At 80 wpm (~150ms between keystrokes), the pipeline keeps up. Frame dropping handles bursts. For comparison, cold compile (no memoisation, i.e.
tinymist compileCLI) is 538ms + 85ms = 624ms.The benchmarks (
bench_preview_lsp,bench_preview_loop) drive a real tinymist session: start the LSP over stdio, open the document, start the preview, sendtextDocument/didChangeedits in a loop, receive SVGs over WebSocket, rasterise, measure.Scope and known limitations
jump/srclocationWebSocket messages, but we don't send or handle them yet.Open questions
Should viewers be extensions instead of compiled-in crates?
Every viewer in Zed today (pdf_viewer, image_viewer, typst_viewer, markdown preview) is a Rust crate compiled into the binary. Each brings its own dependencies, its own GPUI view boilerplate, and its own protocol plumbing. The typst case makes the redundancy especially clear: the typst extension already launches tinymist, already manages the LSP lifecycle, already handles
textDocument/didChange. The only reason the rendering lives inside Zed's binary is that extensions have no way to put pixels on the screen.The WASM extension API can spawn processes, talk HTTP, run LSP, register slash commands. But it can't display an image. If the extension host exposed a frame buffer mechanism (extension creates a buffer, writes BGRA pixel data, presents it; host wraps it as a GPUI image and displays it in a pane), the rendering pipeline moves entirely into extensions:
typst_viewercrate gets deleted from Zed.The key design question is data transfer cost. At 4K (3840x2160x4 = 33MB per frame), memcpy across the WASM boundary matters. wasmtime's shared memory support may allow the host to map the WASM linear memory directly, avoiding the copy entirely. Alternatively, the host provides a buffer as a WIT resource handle, and the extension writes into it; the host reads it back without copying through WASM return values.
We're exploring this direction in a separate branch. This PR is the working-today approach that doesn't require extension API changes, but the goal is to make it obsolete.
Upstream tinymist
The fork is minimal. Is there interest in proposing
--server-svgupstream? The multi-page framing protocol and glyph defs stripping are useful for any native editor integration, not just Zed.Scale factor handling
Currently hardcoded to 2.0x. Should read the actual display scale factor from GPUI and pass it to the rasteriser.
Image sizing
Display dimensions are computed as
pixel_size / 2.0. Should adapt to pane width like pdf_viewer does (fit-to-width with zoom).Feedback welcome, especially from people who use typst and would benefit from live preview, or who have opinions on the extension-vs-crate question.
Beta Was this translation helpful? Give feedback.
All reactions