Written 2026-08-26, against Typst 0.15. The point of this page is that the
boundary should be stated rather than discovered — the same rule the
<cfdocument> plan applies to its HTML subset.
Short version: the builder covers business documents — invoices, statements, reports, letters. It covers very little of Typst's typesetting and almost none of its programmability, and the single largest gap is that a document cannot be a template.
Typst is a typesetting system — the modern answer to LaTeX. You write markup, it lays the text out and exports a PDF. Two things make it a better fit for generating documents from a program than an HTML-to-PDF pipeline:
- it is a library, not a subprocess. There is no headless browser, no
wkhtmltopdf, no temporary directory.
typst::compile()is a function call: ~6 ms for a two-page invoice with a table, ~2 ms to export the PDF; - it is paged by construction. Page size, margins, running headers and footers, page counters and page breaks are first-class, not properties you coax out of a layout engine designed for a scrolling viewport.
What this extension does with it:
CFML Rust Typst
──── ──── ─────
Document() → a document model (nothing yet)
.heading(…) (src/model.rs)
.table(…) accumulated, not emitted
.image(…) image bytes held aside
│
└─ .toTypst() ─→ the emitter runs once markup text
.toBinary() (src/emit.rs) │
.write(…) ▼
a World with one source typst::compile()
file, the host's fonts, │
and only the images you ▼
added (src/world.rs) typst_pdf::pdf()
│
▼
PDF bytes
Three deliberate properties fall out of that shape:
The markup is generated once, at the terminal. The model accumulates; nothing
is emitted until .toTypst()/.toBinary()/.write(). Emitting incrementally
would make Typst's #set scoping depend on call order in ways a caller cannot
see.
Your text is data, never code. Every value you pass is emitted as a Typst
string literal, and every verb is emitted in Typst's code mode —
#heading(level: 1)[#"…"], #table(…). A paragraph containing #set page(fill: black) renders as those characters. Measurements and colours are the only place
a bare expression is emitted, which is why they are whitelists rather than
passthroughs.
The document cannot reach the filesystem or the network. World::source()
serves exactly one file — the generated main.typ — and returns NotFound for
everything else, so #import has nowhere to go. World::file() serves only the
images the builder registered by generated name. Neither is an omission; both are
the security boundary, and §4 below is where it gets in the way.
| Typst capability | How you reach it |
|---|---|
#set page(paper:, width:, height:, margin:, flipped:, numbering:, columns:, fill:) |
pageSize / paper, margin, orientation, numbering, columns, pageFill |
#set text(font:, size:, fill:) |
font, plus format on any verb |
#set heading(numbering:) |
headingNumbering |
#heading(level:) |
heading, with label |
| Paragraph text | paragraph / text / p |
Inline runs — #strong, #emph, #underline, #strike, #smallcaps, #linebreak |
An array of runs anywhere text is taken |
#link |
A { link: … } run, or link() |
#footnote |
A { footnote: … } run |
#ref / <label> |
A { ref: … } run, and label= on headings, tables and images |
#list / #enum |
list( ordered= ) |
#terms |
terms |
#quote(attribution:) |
quote |
#raw(lang:, block: true) |
code |
#block(fill:, stroke:, radius:, inset:, width:) |
callout / box / panel |
#table(columns:, align:, fill:, stroke:, gutter:, inset:), table.header, table.cell |
table — see the API reference; grid is the stroke-less variant |
#figure(caption:) |
caption= on table and image |
#outline(title:, depth:) |
outline / toc |
#image() |
image, from a path or Binary — crosses as bytes |
#pagebreak(), #v(), #line(), #align() |
pageBreak, spacer, rule, align= |
Running header/footer, #counter(page) |
header / footer, with {page} and {total} |
| Document metadata | title, author, keywords |
#let, show rules, json(), #import |
Templates — template() + data(), or typstRender() |
| PDF export options — standards, tagging, page ranges, creator, timestamp | pdf() |
| PDF export | toBinary / toPdf / write |
| Layout without export | pageCount |
| Anything at all | .typst( markup ), and typstCompile( markup ) |
Grouped by how much it would take, because "missing" and "missing and hard" are different answers.
Nothing structural stands in the way of these; they are model variants and emitter arms.
- Grid beyond a table —
#gridwith cell spans (colspan/rowspan) and per-cell strokes.grid()gets you a stroke-less table; it does not get you a spanning layout. - Shapes and transforms —
#rect,#circle,#ellipse,#polygon,#curve,#rotate,#scale,#pad,#move,#repeat(dot leaders). #stackand#place— including floats, i.e. a figure that moves to the top of the page.- Gradient and tiling fills — a colour is a solid colour here.
- Typography controls —
hyphenate,lang/region,tracking,spacing,baseline,#smartquote,#sub/#super,#highlight,#overline. - Nested blocks. A
calloutholds inline runs, not a list or a table. The model is flat: blocks are a sequence, not a tree. This is the one entry here that is a design limit rather than a missing arm, and the one most likely to need revisiting. - Caption position. A captioned table's caption sits below it, Typst's
default for the
tablekind here. Moving it above needs a show rule (#show figure.caption: set …), so today it needs.typst(). - Figure supplement. Captions are emitted with
supplement: none, so a cross-reference to a captioned table renders as a bare number rather than "Table 1". Exposing the supplement is a one-argument change.
- Math mode —
$ x^2 $, and the whole of Typst's equation layout. Reachable through.typst()and, in a template, through the template. Whether a document-generation extension should expose maths at all is a genuine question; it is the thing Typst is best at. - Bibliographies and citations —
#bibliography("refs.bib"),#cite, with Hayagriva/BibLaTeX. These now work inside a template, because a template root can hold the.bibfile. There is no builder verb for them. - Other export formats.
typst-rendergives PNG,typst-svggives SVG, and 0.15 has experimental HTML export. We build onlytypst_pdf. Rasterising works today by handing the PDF back to the engine —pdfToImage( pdfRead( … ) )— which is fine, and is one more dependency and one more decode thantypst-renderwould be. - Dates.
World::today()deliberately returnsNone, sodatetime.today()inside markup yields nothing. That is on purpose — the same inputs should produce the same PDF — and.pdf( timestamp = )is how a caller stamps one. A template that wants today's date should be given it indata. <cfdocument>and its HTML subset — designed in PLAN.md §4–5, not built. It needs an HTML→Typst translator, and the inline-run model that landed for the builder is the half of it that was missing.
- Typst packages —
#import "@preview/cetz:…". Resolving one reaches the network, and nothing in this extension does. Vendor a package under a template root and#importit by path. - Arbitrary filesystem access from a generated document. No root, by construction. Templates relax this to exactly one directory.
PLAN.md §6's third open question, answered: yes, and it came first.
pdf = Document().template( expandPath( "/templates/statement.typ" ) )
.data( { reference: "STMT-7", customer: customer, lines: lines } )
.toBinary();#let data = json("data.json")
#import "_shared.typ": money, panel
#show heading: it => block(below: 1em)[#text(weight: "bold", size: 15pt)[#it.body]]
= Statement #data.reference
#panel[#data.customer.name]
#table(
columns: (1fr, auto, auto),
..data.lines.map(l => (l.description, str(l.qty), money(l.amount))).flatten(),
)Three decisions inside it are worth stating, because each of them was a choice:
- The root is one canonical directory, defaulting to the template's own,
and every lookup goes through
VirtualPath::realize— Typst's own root-relative resolver. Not a hand-rolledstarts_withcheck: the escape cases (.., symlinks, absolute paths) are exactly what a hand-rolled version gets wrong.read("../../../../etc/hosts")is refused with Typst's own message, and there is a test asserting the reason, not just the failure. - Data crosses as a virtual
data.json, not as generated#letsource. It keeps the template idiomatic —json()is how a Typst author already reads data — and it preserves the property the builder has: your values are data and cannot become code. A test proves it by page count, since an executed#pagebreak()in the data would produce a second page. serializeJSONis the engine's, called back over the ABI, sodata.jsonholds exactly the JSON the rest of the application would produce. A second JSON writer here would have its own opinions about dates, numeric strings and key case.
What it unlocked immediately: #let, show rules, #import, json()/csv()/
read() within the root, and .bib bibliographies — none of which could resolve
while the World served a single file.
What it does not remove: the builder still earns its place. It needs no designer,
no template file and no # escaping, and it is right when the document is just
the data. Templates and the builder are two entry points on the same World.
<cfdocument>and HTML→Typst (PLAN §4–5) — the compatibility story, and the inline-run model it needed now exists.- Nested blocks (§3.1) — a callout containing a table is the shape people will ask for first, and it is a model change rather than a verb.
typst-renderfor PNG/SVG (§3.2) — removes a PDF round-trip.- Spans, shapes, floats (§3.1) — as asked for.