Skip to content

feat(pdf): own Typst wasm compiler (@blocknote/xl-typst-compiler), replacing typst.ts - #3020

Draft
YousefED wants to merge 8 commits into
playground/typst-pdf-pocfrom
playground/typst-own-compiler
Draft

feat(pdf): own Typst wasm compiler (@blocknote/xl-typst-compiler), replacing typst.ts#3020
YousefED wants to merge 8 commits into
playground/typst-pdf-pocfrom
playground/typst-own-compiler

Conversation

@YousefED

@YousefED YousefED commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Replaces @myriaddreamin/typst.ts with our own wasm binding over the official typst crates (0.15.1, crates.io — no fork): @blocknote/xl-typst-compiler, ~350 lines of Rust + TypeScript. Motivation: typst.ts is a single-maintainer project compiled against a fork of typst, and its release freeze blocked native PDF-standards support in the browser (the wasm-side feature merged upstream four days after the last published rc).

What the new package is

  • In-memory World (main source + asset map, .typ assets importable), fonts supplied as bytes per instance — no page-level singleton (instance creation incl. parsing the 8 default fonts: ~6ms; typst's caches are module-global, so fresh instances compile warm at ~7-9ms; full document cold compile: ~2min wasm build, 242ms first compile, 7ms warm).
  • No network access, ever: the wasm embeds nothing and downloads nothing; missing glyphs fail loudly instead of rendering substitutes. Wasm is 24.7MB, loaded from the package's own files (bundlers emit it as an asset) — the last CDN default is gone.
  • Native, validated PDF standards: pdfStandard: "ua-1" makes typst/krilla validate conformance during the compile and refuse to emit a nonconforming PDF — a successful compile is the conformance statement.

API changes (xl-pdf-exporter)

  • toPDF(blocks, options?) replaces toBytes/toBlob — named like its siblings (toTypst, toODTDocument, …) and taking the family-consistent single per-export bag: document facts (title, lang, paper, header, …) plus tryDeclarePdfUA, extra assets, and creationTimestamp. The result union is { bytes, blob, pdfUA, compileWarnings } or { error: "compile-failed", compileErrors, compileWarnings } — the blob is a memoized lazy getter (constructing a Blob copies the bytes, so results that only use bytes never pay for it).
  • Fonts and the wasm live in the constructor, like all configuration in the other exporters: PdfExporterOptions = TypstExporterOptions & { wasm?, fonts, emojiFont } (full shape; the constructor takes a Partial and fills defaults, mirroring the base). Family names and font bytes now sit side by side; fonts/emojiFont accept promises so lazy loading fits the sync constructor. The bundled defaults are exported (loadDefaultBodyFonts/loadDefaultEmojiFont) so extending is a spread, e.g. CJK: fonts: loadDefaultBodyFonts().then((f) => [...f, notoSansSC]) with fontFamily: ["Inter 18pt", "Noto Sans SC"].
  • tryDeclarePdfUA (default true — safe because the claim is compile-validated and can never be false): conforming documents get the pdfuaid claim, nonconforming ones re-export tagged-but-unclaimed with Typst's violations in pdfUA. Declaring requires lang and throws without it (a caller-args error, failing fast): Typst always writes /Lang, defaulting to English — verified, it cannot be omitted upstream — and a wrong language declaration is an accessibility defect no validator can catch. TypstExporter no longer fabricates title/lang/author defaults.
  • Compile failures are values (user content and caller markup are expected to fail sometimes); throws are reserved for caller mistakes. Diagnostics carry no per-item severity — which list they arrive in (compileErrors/compileWarnings) is the severity; the wasm returns one uniform never-thrown payload with an explicit output-xor-errors invariant check.
  • A font-sync invariant test pins the name↔file pairing of the defaults: a zero-config export exercising body/bold/italic/code/emoji must produce zero compileWarnings, so renaming a default family or swapping a bundled font file fails CI regardless of which side drifted.

Build & infra

  • pkg/ (wasm + glue) is a gitignored build output. The package's build task self-provisions via scripts/ensure-wasm.mjs: content-hash skip when fresh (~0s), builds via the lockfile-pinned wasm-pack devDep otherwise; toolchain pinned in rust/rust-toolchain.toml. On CI and Vercel it bootstraps rustup automatically (GitHub's ubuntu-24.04 images ship no Rust); on Vercel the cargo caches live under node_modules/.cache (persisted), so only the first build on a fresh cache pays the ~2-15min compile. Local cold compile: 1m52s; warm/unchanged: ~2s. .dockerignore excludes rust/target (1.3GB) from the e2e image context.
  • Unit tests (and shared/util/typstTestUtil.ts) run the same wasm in Node — @myriaddreamin/typst-ts-node-compiler is gone, and test fonts are now the repo's own (no more system-font dependence).

Test changes

  • install-pdf-tooling.sh (veraPDF + pinned poppler container) is deleted: conformance is validated by the compile itself (the declared output was verified against veraPDF --flavour ua1: 0 failed checks, and was pixel-identical to the previous engine's visual baselines), and the visual regression moved into the browser e2e suite, which rasterizes the produced PDF with pdf.js and screenshots each page (typst-pdf-page-N, chromium, deterministic via a fixed creationTimestamp).
  • New coverage: compiler package suite (14 tests: ua-1 declaration/violations/fallback, warnings on both branches, .typ imports, byte-reproducibility, font dedup/introspection, invalid-options throw), exporter suite (14: declare flow, lang gating, warnings forwarding, font-sync invariant, lazy blob, spread-extended defaults), slimmed pdfua structural test (pdf-lib as devDep for compressed-object assertions).

Still pending on this branch

  • typst-pdf-page-N e2e baselines (docker image rebuild in progress; until committed, the screenshot matcher seeds them per-run)
  • Final all-browser e2e sweep

Numbers vs typst.ts: 24.7MB wasm (vs 28.8MB, pre-binaryen — a modern wasm-opt pass is a known future size win), ~350 LOC owned (vs ~28k in the replaced stack), zero runtime deps beyond the two workspace packages.

🤖 Generated with Claude Code

…placing typst.ts

Compiles the official typst crates to wasm behind a minimal TypeScript
API and moves the whole PDF pipeline onto it: native, validated PDF/UA-1
(tryDeclarePdfUA with typed violations), compile failures as values
(compileErrors/compileWarnings), no CDN or network access anywhere, no
page-level compiler singleton, and no @myriaddreamin/* or @cantoo/pdf-lib
runtime dependencies. The wasm builds from rust/ via a self-provisioning
build step (scripts/ensure-wasm.mjs) that covers CI and Vercel.
@vercel

vercel Bot commented Aug 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
blocknote Ready Ready Preview Aug 27, 2026 6:20pm
blocknote-website Ready Ready Preview Aug 27, 2026 6:20pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

GitHub's ubuntu-24.04 images no longer ship Rust, so ensure-wasm now
bootstraps rustup on CI like it does on Vercel (dev machines still get
instructions instead of an install). PDFExporter.toPDF replaces
toBytes/toBlob - one method, one result union, with the success branch
carrying the PDF as both bytes and a Blob - mirroring toTypst and the
other exporters' to<Format> naming.
…DF options

Fonts (bytes, value-or-promise) and the compiler wasm move to the
PDFExporter constructor - names and bytes side by side, matching how the
other exporters put all configuration in the constructor - while toPDF
takes a single per-export bag of document facts (plus tryDeclarePdfUA,
assets, creationTimestamp), like every other exporter's export method.
Also: exported spreadable default-font loaders (replacing the short-lived
extraFonts), lazy result Blob, a font-name/file sync invariant test, the
lang requirement failing fast, PdfExporterOptions as the full options
shape with Partial at the constructor, and a leaner e2e docker context
(rust/target excluded).
…tside the e2e container

wasm-pack now runs with the crate directory as cwd so rustup discovers
rust-toolchain.toml (a bootstrapped rustup with no default toolchain found
nothing to run - masked locally by an existing default). The e2e shards
can't build the wasm at all (the Playwright container has no C toolchain
for proc-macros), so a bare-runner job builds it once and shares
pkg/dist/types as an artifact.
Simple-first flow matching the DOCX template: PDF/UA confined to its own
section, CDN/offline said once, internals cut, fonts split into short
sections, custom mappings owned by the Typst page (the base layer never
points up; the PDF page keeps its own option surfaces explicit). Typst
page gains its own Customizing/assets/math-diagram/options sections;
math's export section renamed Typst / PDF. DEFAULT_FONT_FAMILY /
DEFAULT_MONO_FONT_FAMILY are exported so fallback lists need no hardcoded
names.
… and prose-style rules from the PDF page review
…builds

Adds the four typst-pdf-page chromium baselines (visually verified,
pixel-exact via pdf.js rasterization) and gives the visual test cold-start
headroom on shared runners. The e2e image install no longer re-verifies
minimumReleaseAge (2000+ live registry checks - the frozen lockfile was
already policy-verified by the install that produced it and by CI) and
caches the pnpm store/metadata across rebuilds, taking the step from 70+
flaky minutes to ~4 (sub-minute warm). The pkg-pr-new soft release
excludes xl-typst-compiler: its ~25MB wasm exceeds the service's
non-whitelisted upload limit.
@pkg-pr-new

pkg-pr-new Bot commented Aug 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

@blocknote/ariakit

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/ariakit@3020

@blocknote/code-block

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/code-block@3020

@blocknote/core

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/core@3020

@blocknote/diagram-block

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/diagram-block@3020

@blocknote/mantine

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/mantine@3020

@blocknote/math-block

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/math-block@3020

@blocknote/react

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/react@3020

@blocknote/server-util

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/server-util@3020

@blocknote/shadcn

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/shadcn@3020

@blocknote/xl-ai

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-ai@3020

@blocknote/xl-docx-exporter

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-docx-exporter@3020

@blocknote/xl-email-exporter

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-email-exporter@3020

@blocknote/xl-multi-column

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-multi-column@3020

@blocknote/xl-odt-exporter

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-odt-exporter@3020

@blocknote/xl-pdf-exporter

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-pdf-exporter@3020

@blocknote/xl-typst-exporter

npm i https://pkg.pr.new/TypeCellOS/BlockNote/@blocknote/xl-typst-exporter@3020

commit: 063bfc4

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