Typst
Unreviewed · 2026-04-20 Node + Browser Document TBDTypst document compiler — source in, PDF out. Bundled Libertinus fonts; offline-only packages.
- Targets
- Node + Browser
Install
pnpm add @amigo-labs/typstBenchmarks
Trend (7 pts)Benchmark
trivial source
- @amigo-labs/typst compile 1.32K hz
Benchmark
multi-section report (10 sections)
- @amigo-labs/typst compile 930 hz
README
@amigo-labs/typst
Typst document compilation in-process. Source in → PDF out, one FFI crossing per document. Bundled Libertinus Serif, Mono, and New Computer Modern (math) fonts — no system-font fallback, deterministic output.
Replaces CLI invocations (
typst compile), Puppeteer-based PDF generators, andpdfmakefor structured multi-page output where typography matters.
Install
pnpm add @amigo-labs/typst
Note: this package is larger than the rest of the portfolio (~15 MB per platform binary) because Libertinus + NCM + Fira fonts are bundled for deterministic output. See
docs/perf-review/typst.md.
Usage
import { compile, compileMany } from '@amigo-labs/typst'
const { pdf, warnings } = compile(`
= Invoice
#table(
columns: (1fr, auto, auto),
[*Item*], [*Qty*], [*Amount*],
[Consulting], [20 h], [\$2000],
)
`)
fs.writeFileSync('invoice.pdf', pdf)
// Inject data via sys.inputs:
const { pdf: templated } = compile(
`Dear #sys.inputs.at("name", default: "Friend"), …`,
{ data: { name: 'Alice' } },
)
// Batch:
const outputs = compileMany([source1, source2, source3], { data })
API
interface CompileOptions {
data?: Record<string, string> // exposed in sys.inputs
fonts?: Buffer[] // additional TTF / OTF font buffers
}
interface Diagnostic {
severity: 'error' | 'warning' | 'hint'
message: string
}
interface CompileResult {
pdf: Buffer
warnings: Diagnostic[]
}
function compile(source: string, options?: CompileOptions): CompileResult
function compileMany(sources: string[], options?: CompileOptions): CompileResult[]
Scope (v0.1)
- Single-source-file documents.
- Bundled font set (Libertinus + NCM + DejaVu).
- Caller-provided extra fonts (CJK, brand fonts).
sys.inputsvia thedataoption.- All Typst scripting:
#let,#if,#for,#show,#set, math typesetting, tables, lists, user functions.
Install for the browser
The same import works in Angular, React, Vite, esbuild, and webpack ≥ 5 — the bundler picks the WASM build via the browser conditional export:
const { compile } = await import('@amigo-labs/typst')
⚠️ Bundle is ~5 MB gzipped (typst-pdf + bundled Libertinus fonts) — well over the 500 KB soft budget. Always lazy-import in a code-split route; never include in the initial chunk. The chrono wasmbind feature wires today() through to JavaScript’s Date.now().
Scope cuts
- No
@preview/*package resolution. Supply-chain risk; stay offline. Inline the module source instead. - No multi-file imports. Single-string source only. Use
#let/#showfor modularity. - No TypstCompiler class in v0.1. Every
compile()call rebuilds the world (~50–200 ms cold). For hot-path server workloads where you compile many documents with the same font set, batch viacompileMany(). A stateful class is fast-follow. - No disk font resolution. Deterministic bundle only.
See __conformance__/divergences.md.
License
MIT (the Typst compiler itself is Apache-2.0; bundled fonts have their own licenses — Libertinus SIL OFL, NCM GFL, Fira OFL).
Perf review
Candidate review: typst
Status: GO (as a new package, not a drop-in) · Predicted: 🟢 Green · Reviewed: 2026-04-20 Shipped: v0.1 on branch
claude/crate-performance-audit-6KLOJ(2026-04-23). Benchmarks measured.
Verdict
typst as a library is a textbook Green shape, structurally analogous to commonmark and inflate: markup string + optional JSON data in → PDF bytes out, one FFI crossing per document. The expensive work (parsing, layout, font resolution, PDF emission via krilla) runs entirely Rust-side. No callback boundary, no object traversal across the boundary, no chain-API trap like with pdfkit.
This is a new package, not a drop-in. The JS alternatives for multi-page business reports with tables (invoices, statements, dashboards) are Puppeteer (a Chromium process) or pdfmake / html-pdf-node — the former has hundreds of MB of overhead and starts a browser per request, the latter is pure JS without a serious typesetting engine. Against both, ≥2× is trivially reachable; against Puppeteer more like 10–50×.
Parity is not a goal: Typst is its own markup-language ecosystem — that’s the product, not the compromise.
JS package
- npm: no direct drop-in candidate — this package is a new product. Comparison alternatives in JS for business-report generation:
puppeteer(~5M/week) — HTML→PDF via Chromium, highest fidelity but massive process overheadpdfmake(~400k/week) — pure-JS document-as-data API, with tables + page breakshtml-pdf-node/html-pdf-chrome(~150k/week) — wrappers around Chromium/Puppeteerjsreport/carbone— higher-level abstraction, often use LibreOffice or Puppeteer internally
- Downloads: n/a (newcomer; the
typst-jsnpm package at ~5k/week is a WASM build of the Typst CLI, not consumable as a library) - Exports / API surface: kept small —
compile(source, data?) → Buffer, statefulTypstCompilerclass for repeated calls with shared font and package cache - Typical input: Typst source 2–50 KB + optional JSON data object 100 B – 500 KB (invoice line items, report KPIs)
- Typical output: PDF bytes 20 KB – 5 MB, depending on page count and embedded assets
- Realistic median use-case: server-side invoice/statement generation, 10–500 documents per request, each 2–20 pages, templates written once and rendered many times with variable data
Rust replacement
- Candidate crate(s):
typst(primary — the core library of the Typst ecosystem, contains parser, compiler, layout engine) together withtypst-pdf(PDF export viakrilla) andtypst-kit(font and package-resolution helpers for library embedding). - Maintenance / license: very active (typst GmbH, broad OSS surroundings), Apache-2.0, clean library separation from 0.11+. No known ABI-break issues per release in the
typstcrate itself; the API between major versions is more stable thankrillaorpdf-writeralone. - Known gotchas / divergences:
- Font strategy must be explicitly decided — typst doesn’t resolve fonts out of the box: either we bundle a default set (Libertinus + Fira + New Computer Modern, ~15–20 MB), or we accept caller-provided TTF buffers, or we resolve from disk. The choice shapes binary size and portability.
- Package resolution (
#import "@preview/…") goes online against the Typst package index. Default must be offline-only (supply-chain risk, sandboxing) — opt-in later, if at all. - Cold start: first
compile()call loads fonts, parses the core library, costs 50–200 ms. Only amortizable via aTypstCompilerclass. - Binary size: Typst brings substantial deps (Rust regex engine, ICU parts,
krilla, font parser). Release build withlto+stripestimated at ~15–25 MB per platform target — roughly doubles the largest current package in the repo. Must be weighed explicitly against the policy. - No pixel-parity goal vs. Puppeteer/LibreOffice — same as
commonmarkvs.marked: own positioning as a spec-conformant Typst renderer.
BACKLOG check
No existing typst entry in BACKLOG.md. The only PDF-related reference in BACKLOG.md:12 is pdf-parse (text extraction via pdf-extract / lopdf) — that’s the read path, not the write path. No overlap.
Delineation to existing reviews:
docs/perf-review/pdfkit.md(2026-04-20) recommendsprintpdffor the label/ticket use-case (~2–20 KB, high-volume batch, trivial layout). That analysis explicitly excludes text wrapping and tables from v1 scope. This typst review addresses the complementary use-case — multi-page documents with tables, computed totals, proper typography. The two packages don’t collide; they cover different shapes.docs/post-mortems/xml.mdis the warning against object traversal across the FFI boundary — typst avoids that by design (bytes-in, bytes-out).
No entry in docs/packages.json.
FFI-overhead prediction
| Factor | Assessment |
|---|---|
| Per-call algorithmic work | Substantial. 10-page invoice with a table ~8–25 ms Rust compute (parse + layout + PDF emit). 50-page report with math/charts ~80–300 ms. Relative to the FFI fixed-cost baseline (~110 ns), that’s orders of magnitude of headroom. |
| Input size distribution | Typst source 2–50 KB + JSON data 0.1–500 KB. Via Buffer input (like renderBytes in commonmark), FFI input cost is ~flat 180 ns regardless of size (see docs/BASELINE.md:28–30). For string input up to 50 KB it would be ~20 µs UTF-16→UTF-8 — negligible next to compute. |
| Output size distribution | PDF bytes 20 KB – 5 MB. Buffer return is flat ~180 ns up to 10 MB (BASELINE.md:30) — output FFI cost is noise in the budget. |
| Reusable setup (stateful potential) | High. Font parsing + package cache costs 50–200 ms cold per font set. A TypstCompiler NAPI class caches fonts, parsed core-library modules, and (if enabled) loaded packages. On 500 invoices with the same template + same font set, that’s the difference between 25 s and 4 s total wall-clock. |
| Batch-usage realism | High. Invoice runs, monthly statement batches, report generation for dashboards — the default is batch, not single request. compileMany(jobs) via rayon::par_iter collapses the FFI crossings and uses multiple cores — see crates/commonmark/src/lib.rs:183–194 as a reference pattern. |
| FFI-share estimate vs. Rust work | <1% for 10+-page reports (one FFI-in, 10+ ms Rust compute, one FFI-out). Even for 2-page invoices <5%. The compute side dominates structurally. |
Classification reasoning
The shape matches the existing Green packages commonmark, inflate, zip, sanitize-html exactly: bytes-in, substantial compute, bytes-out, no callback boundary, no object traversal. The Rust side does enough real work per byte that the FFI fixed costs go invisible. Per docs/BASELINE.md:25–33, the relevant floor (109 ns noop, 180 ns buffer return, ~35 µs per 100 KB string input) is three orders of magnitude smaller than the expected compute time (several ms per document) — so structural headroom is there.
The actual Green condition is the smallest realistic input: a 1-page invoice, no batch, cold start included. Cold that’s ~100–200 ms (font load dominates) — typst loses against pdfmake there (~30–50 ms for a simple document in pure JS). Hot, with the TypstCompiler class, the same invoice runs in ~5–10 ms — then 3–6× faster than pdfmake with better typography. The 2×-at-smallest-input gate holds only on the hot path. That has to be transparent in the docs — it’s the same nuance as with commonmark’s Renderer class.
Against Puppeteer/html-pdf-node, typst wins structurally on every input, because those start a browser process or hold one persistent (memory overhead ~100–300 MB per worker). For server-side invoice generation, that’s a hard cost advantage, not just wall-clock.
A parser-/handlebars-shape trap does not exist: typst has no callback extension points across the FFI. Data comes in as JSON (one blob, one marshal), template modules come in as strings (one blob, one marshal). No --include-helper=function escape hatches.
Benchmark gap flag: the prediction is qualitative. Before the Green gate, the four scenarios below must be measured — without numbers the package stays at 🟡 Yellow, as with pdfkit.md.
If GO — proposed port
-
Recommended crate name:
@amigo-labs/typst -
Primary API sketch:
type FontSpec = { name?: string; data: Buffer }; type CompileOptions = { /** Typst source as UTF-8 string or Buffer. */ source: string | Buffer; /** JSON-serializable data injected into the template as sys.inputs. */ data?: Record<string, unknown>; /** Additional in-memory source files addressable by #import "path". */ virtualFiles?: Record<string, string | Buffer>; }; /** One-shot convenience — allocates fonts per call, fine for low volume. */ export function compile(opts: CompileOptions): Buffer; /** Reusable compiler — the Green path for batch / server workloads. */ export class TypstCompiler { constructor(opts: { /** User-provided fonts. If omitted, ships a bundled default set. */ fonts?: FontSpec[]; /** Filesystem root for #include resolution. Default: no disk access. */ root?: string; /** Allow @preview/ package resolution. Default: false (offline only). */ allowPackages?: boolean; }); compile(opts: CompileOptions): Buffer; compileMany(jobs: CompileOptions[]): Buffer[]; }Explicitly not compatible with Puppeteer or
pdfmake. The input language is Typst markup — that’s a deliberate product offering. -
Must-have benchmark scenarios:
- small-cold: 1-page invoice, single
compile()call, againstpdfmake+puppeteer. Report cold-start cost transparently. - small-hot: 1-page invoice via
TypstCompiler.compile()after warm-up, against the same baseline. This is the actual Green gate. - batch-500:
compileManywith 500 invoices, identical template, variable data. Against Puppeteer (worker pool with 4 workers) andpdfmake(single-thread). The main win case. - long-report: 50-page business report with tables, charts (as SVG/PNG), title page, table of contents. Against Puppeteer + ChartJS HTML. Tests layout scaling.
- realistic median: 5-page monthly statement with 50-row table, an embedded chart, and computed totals.
- small-cold: 1-page invoice, single
-
Acceptance thresholds (Green gate):
- small-hot ≥ 2×
pdfmake - batch-500 ≥ 5×
pdfmakeand ≥ 10×puppeteer(wall-clock incl. process startup for Puppeteer) - long-report ≥ 2×
puppeteer(Puppeteer + HTML chart libs is actually fast here — the target is conservative) - small-cold is allowed to be worse than
pdfmake— but must be documented, and thecompile()standalone path should be explicitly positioned in the README as “for one-shot usage, warm path usesTypstCompiler” - Cold-start cost (first
TypstCompiler.compile()incl. font load) has to be reported — transparency requirement analogous topdfkit.md.
- small-hot ≥ 2×
-
Risks:
- Binary-size explosion. Typst + fonts + krilla + pdf-writer estimate to 15–25 MB per NAPI target. Six targets = 90–150 MB total npm artifact size. Has to be weighed against the repo policy. Mitigation: optionally a
@amigo-labs/typst-fontspeer package for the default fonts,@amigo-labs/typstitself stays font-free. - Font-resolution complexity. Three plausible strategies (bundled / user TTFs / disk-resolve) and all three are wanted by different user classes. v1 has to pick one and push the others into documented follow-ups, otherwise the API design diverges in three directions at once.
- Typst API churn. The library API of
typst+typst-pdf+typst-kitis comparatively stable from 0.11, but not 1.0. Major upgrades every ~6 months, some with API shifts. We’re committing to a version pin and active maintenance of upgrades. - User expectation “LaTeX-in-JS”. Typst is not LaTeX, doesn’t know every LaTeX convention. Will produce support issues that can only be answered with “That’s Typst, not LaTeX — see typst.app”. The README has to make this upfront.
- Baseline nuance:
docs/BASELINE.mddoesn’t measure Typst compute. The FFI-share estimate above is derived from Typst community’s own benchmarks, not measured in our harness. After the port, extend the_ffi-benchharness with acompilePdfJobcase.
- Binary-size explosion. Typst + fonts + krilla + pdf-writer estimate to 15–25 MB per NAPI target. Six targets = 90–150 MB total npm artifact size. Has to be weighed against the repo policy. Mitigation: optionally a
If NO-GO — BACKLOG entry
If the binary-size budget (90–150 MB total for six targets) is judged a showstopper, or if the use-case is deemed too narrow for its own package:
- **typst (as library)** (not on npm, ~5k/week as WASM build). Evaluated in `docs/perf-review/typst.md`. FFI shape is textbook Green (bytes-in, bytes-out, no callbacks), compute win against Puppeteer/pdfmake substantial (predicted 5–50× depending on use-case). Deferred due to binary size (~15–25 MB per platform × 6 targets = 90–150 MB npm artifact) and scope question: business-report generation is a narrow vertical that claims a significant share of the repo's download size. Re-evaluate once Typst offers a slimmer embedding profile or the repo's binary-size budget expands.
Section in BACKLOG.md: Parity too expensive (doesn’t fit — it’s not a parity problem) → rather a new section or Scope too large.