Skip to content

Typst Backend

The Typst backend generates PDF, SVG, and PNG documents using the Typst typesetting system. It converts card-yaml payload fields to Typst markup, injects them into the plate via a generated helper package, and compiles to the requested format.

Data Access

Plates are plain Typst code. Document metadata reaches the plate as a Typst dictionary literal exposed by the virtual @local/quillmark-helper package:

#import "@local/quillmark-helper:0.1.0": data

#data.title                                  // a declared field: always present
#data.at("logo", default: none)              // an undeclared key: may be absent

Every field arrives at its native Typst type — a date as a datetime, a number as an int or float, an object as a dict — with one exception: richtext and plaintext arrive as Typst content, their text already lowered to markup, because the authored text is their rendering. This holds at every depth: a date declared inside an object or an array row is the same datetime a top-level one is.

Dates

A present type: date / type: datetime field is a native datetime; a blank date is none (so #if data.field != none guards are unchanged):

#data.issued.display("[day padding:none] [month repr:long] [year]")  // native string
#data.issued.year()                                                   // components: int
#data.issued < data.due                                               // comparison, arithmetic
#some-package(date: data.issued)                                      // any datetime-consuming package
#display("issued", "[day padding:none] [month repr:long] [year]")     // rendered, click-to-edit
#if data.issued != none { .. }                                        // presence

Everything except the last two is ordinary Typst, because the value is an ordinary datetime.

  • display(field, ..args) renders and is clickable. It takes the field's schema address, not its value, and returns Typst content whose glyphs carry a region keyed on that address: the atomic, picker-editable click-to-edit target. It accepts the same patterns datetime.display does, and a date-only field inherits Typst's native error on a [hour] pattern. The address is checked against the schema at compile time, the same check form-field(field:) and field-region apply, so a typo fails the render instead of dropping the date from it. none for a blank date, so a == none fallback still fires.
  • data.<field> is the value. Reach for it whenever you want a datetime — math, comparison, components, handing it to a package. A direct plate reference (#data.issued.display("…") written in the plate itself) still regions, the same way any scalar reference site does.

One rule: want a value → data.<field>; want clickable ink → display("<field>", ..). The difference matters exactly when a package does the inking: a datetime handed to a package draws its glyphs wherever the package places them, so nothing ties them to your schema field, while display's ink is born in generated code and keeps its address however deep it travels. It is the only helper that takes an address rather than a value, and that is exactly why: an address survives the value being copied.

Which accessor to reach for

A key's declaration decides whether it can be absent, and that decides the accessor. Three cases, no judgement calls:

Key Accessor Why
A field declared in Quill.yaml data.subtitle Always present: compilation blank-fills every declared field with its authored value, else the schema default:, else the field's blank ("", (), 0, the empty content).
A $-sigiled key ($kind, $body, $cards, $path) data.at("$body", default: "") Typst identifiers exclude $, and $-metadata is present only where it is defined: $kind only on a card that authors one, $body only where the kind enables a body.
An undeclared key, or any field of a card whose $kind is unknown data.at("logo", default: none) No schema fills it, so absence is real.

So a default: on a declared field is dead code, and an #if "field" in data guard on one is always true. When a declared field is optional, guard its value, not its presence:

#if data.subtitle != "" {
  [Subtitle: #data.subtitle]
}

If a default belongs anywhere, it belongs in Quill.yaml — a default: restated in the plate is never read, and silently diverges when the schema's own default changes.

An enum needs this most: its blank is "", which is never one of its values:, so branch over values ∪ blank and never let an else swallow the blank into a variant nobody picked.

#if data.seal != "" { .. }        // the blank means "no seal", not the first value

Body, arrays, and cards

The document body is exposed under the $body key, accessed via data.at("$body") because Typst identifiers exclude $. Arrays come through as Typst arrays. Cards live under the $cards key, each carrying its own $kind discriminator, fields, and $body:

#data.at("$body", default: "")

#for author in data.authors [- #author]

#for card in data.at("$cards", default: ()) {
  if card.at("$kind", default: none) == "product" {
    [Product: #card.name — #card.at("$body", default: "")]
  }
}

A card block with no $kind: line is a kindless card: it reaches the plate carrying its authored fields verbatim and no $kind, so a bare card.at("$kind") panics on it. Read the discriminator with a default and let unrecognized kinds fall through.

Typst Packages

Declare packages in Quill.yaml, then #import them from the plate:

typst:
  packages:
    - "@preview/appreciated-letter:0.1.0"
#import "@local/quillmark-helper:0.1.0": data
#import "@preview/appreciated-letter:0.1.0": letter

#show: letter.with(sender: data.sender, recipient: data.recipient)

Browse the full catalog at Typst Universe.

A package vendored into the quill under packages/<dir>/ carries its own typst.toml, which names the spec the plate imports:

[package]
name = "my-helper"
version = "0.1.0"
entrypoint = "lib.typ"

A packages/<dir>/ without one is skipped at load with a typst::package_manifest warning, and the plate's #import for it then fails as an unresolved file.

Fonts

A Quill carries its own fonts. The backend loads every .ttf and .otf under assets/fonts/ and inside vendored packages/, an asset font winning a family a package also ships. A Quill bundling none renders in the embedded Figtree faces. The host's installed fonts are not among them, so #set text(font: "Arial") names a family the compile cannot find.

To bundle fonts with the Quill, drop them in assets/fonts/:

my-quill/
└── assets/
    └── fonts/
        ├── CustomFont-Regular.ttf
        └── CustomFont-Bold.ttf

Then reference them by family name (#set text(font: "CustomFont")).

Images

A plate draws a file under assets/ by its path from the Quill root:

#image("assets/logo.svg", width: 2cm)

A markdown image in a richtext field draws nothing. ![logo](assets/logo.svg) in document content reaches no page, and the render warns under backend::declined_construct, naming the field and how many images it holds.

What such a url names — a file in this Quill, a path beside the document, a remote address — is undecided. A document is portable across every version a $quill selector admits, so a path into one Quill's file tree is not a binding it can take. The construct still stores and round-trips; only the page declines it.

Typesetting

Plate authors style output with Typst's standard #set directives:

#set page(paper: "us-letter", margin: 1in, numbering: "1")
#set text(font: "Linux Libertine", size: 11pt, lang: "en")
#set par(justify: true, leading: 0.65em)

See the Typst tutorial for the full styling vocabulary. For worked plates that combine data access with real layout, read the plate.typ of the usaf_memo and taro quills in crates/fixtures/resources/quills/.

Form Fields

form-field drops an AcroForm widget at its call site: a clickable field in PDF, reserved invisible layout space in SVG and PNG. It backs two widget kinds, text inputs and signature boxes. A plate wanting an interactive checkbox or dropdown is an acroform quill.

#import "@local/quillmark-helper:0.1.0": form-field, signature-field

Value binding is the plate author's job: pass value: straight from your data; there is no resolver on the Typst side.

signature-field is the ten-line wrapper for the common case — it is exactly form-field(name, type: "signature", width: width, height: height, field: field), with height defaulting to 50pt instead of 20pt:

Approving authority:
#signature-field("approver")

Witness:
#signature-field("witness", width: 220pt, height: 60pt)

Positioning

A widget is ordinary Typst inline content sized width × height. It participates in layout the same way #rect(width: 200pt, height: 50pt) would: content after it gets pushed by the box's dimensions. Two modes:

In-flow (reserves layout space). Drop the call where you want to claim that block of space and let the rest of the document flow around it:

Sign here:
#signature-field("approver")  // reserves 200×50pt below the label
The above signature acknowledges receipt.

Overlay (no displacement). Wrap in #place(...) to anchor the widget without consuming flow. This is what you want when the surrounding template already reserves space, for example, the four blank lines above a typed-name signature block in a USAF memo:

// At the cursor position where the typed-name signature block begins:
#place(dx: 0pt, dy: -3.5in,
       signature-field("approver", width: 3in, height: 0.5in))

#place without an alignment argument anchors the widget at the current cursor (then offsets by dx/dy); #place(top + left, ...) anchors to the containing block's top-left. Either way, the call consumes no flow space and the surrounding template stays put.

Inside #box, #table, #figure, #footnote, #move, #pad: a widget tracks the layout system normally. Multi-page documents work; each field's page is the page it lays out on, not where it was written in source.

Parameters

Name Type Default Notes
name str required (positional) Widget /T name: unique within the document, matching [A-Za-z0-9_.]+ (. allowed for fully-qualified names). One uniqueness domain, signature-field calls included.
type str "text" "text" or "signature".
value per type none The delivered field value; interpretation depends on type (see below).
multiline bool false Toggles the multi-line flag for type: "text"; ignored otherwise.
width length 200pt Absolute length (pt/mm/cm/in); relative lengths (2em, 50%) are rejected.
height length 20pt Same constraint as width.
field str or none none Schema-field address this widget's region is keyed on (see "Binding to a schema field").
font str "helvetica" "helvetica", "times", or "courier"; "text" only (see "Styling the value text").
size length or auto auto Absolute length, or auto for the viewer's fit-to-box. "text" only.
align str "left" "left", "center", or "right". "text" only.

signature-field takes name, width, height and field, and forwards them; the rest are form-field's alone.

The two field types

value: is forwarded verbatim; the Rust adapter maps it to the AcroForm value per type:

Text: value is a string (numbers stringify). A blank value emits no /V. Set multiline: true for a multi-line box.

#form-field("full_name", type: "text", value: data.name)
#form-field("bio", type: "text", value: data.bio, multiline: true, height: 80pt)

Signature: value is ignored. PDF output gains a clickable SigField widget at the call site, which Acrobat — or any reader that supports form signing — presents as a "Sign Here" affordance.

#form-field("approver", type: "signature", height: 50pt)

The widget is unsigned. Quillmark performs no cryptography: to produce a signed PDF, run the output through pyHanko, Acrobat, endesive, or another signing tool.

Binding to a schema field

By default a widget's only identity is its /T name. Pass field: to additionally key the widget's region on a schema-field address, so it surfaces in the geometry sidecar (session.regions()) and resolves under session.fieldAt(...):

#form-field("Signature", type: "signature", field: "signature_block")

field: is region-only: the /T widget name stays name; only the sidecar entry keys on field:. The address must be a real schema field: a bare field name, an array element like "refs.2", a container property like "classification.poc", or a card path built from the card's $path prefix (a bad address raises a Typst assert). Omit field: and the widget exposes no region: a click has no schema field to route to.

A one-step suffix is checked against what the field actually offers, so "refs.2" needs an array and "classification.poc" a container — an object field, or an enum declaring variants:, whose cells and value discriminant address alike. "subject.0" and "subject.poc" are both rejected on a scalar subject.

Styling the value text

A widget carrying a value: bakes it into an appearance stream, and a viewer re-synthesizes that appearance when someone fills the field. font, size, and align are what both read — except that the baked stream always draws from the box's left edge, so align moves the value only once a viewer re-synthesizes. All three apply to "text" only, a signature field having no variable text, and passing a non-default there raises an assert rather than silently doing nothing.

#form-field("memo_date", type: "text", field: "date",
            font: "times", size: 12pt, align: "right", width: 1.2in, height: 16pt)

size is worth setting whenever the value has to match surrounding text. The default auto is the AcroForm auto-size, which fits text to the box and refits as the user types, so a long value renders smaller than a short one in the same field. An explicit size is the only way to make the rendered size predictable.

align is the only way to pin a value to an edge. A fillable box has to be sized for the longest plausible value, not the value actually typed, so its width says nothing about where the text lands: under the default "left" the value starts at the box's left edge and the leftover space trails off to the right. Right-aligning the box in Typst does not help, because that moves the box, not the text inside it. Reach for align: "right" wherever a template calls for a right-aligned fill-in, as a USAF memo does for its date.

font is limited to the three base-14 families ("helvetica", "times", "courier"), which every PDF viewer is required to have. A widget cannot carry a font program, so a quill's own bundled fonts are not reachable here; pick the base-14 family closest to the surrounding type. "times" is a close match for the Times-alike faces most formal templates use.

These affect the PDF only. SVG and PNG reserve the same invisible layout space regardless.

Errors

  • Duplicate name across any form-field/signature-field calls → typst::duplicate_form_field.
  • A non-absolute width/height/size, a type outside the two values, a font/align outside its set, a name violating [A-Za-z0-9_.]+, or a field: that is not a known schema address → a Typst assert pointing at form-field.
  • font/size/align set to a non-default on a "signature" field → a Typst assert.

The label <__qm_field__> and metadata kind: "__qm_field__" are reserved for this hand-off: don't use them for unrelated metadata in your plate.

A widget emits a document-global metadata element (standard Typst introspection). If your plate or its packages read config via query(metadata), filter to your own elements rather than assuming a single or last metadata element.

Which Reads Get Regions

A scalar is tracked at the expression that draws it, so where you write the read decides whether it surfaces in session.regions(). Naming the value first is fine: a let bound once to one whole data chain is followed, and stepping into a container through that name keeps the cell's address.

#let c = data.classification
#c.poc                       // regions as `classification.poc`, same as #data.classification.poc

A read into a typed table works the same way, one step further: the index and then the row property, the addresses form-field(field:) takes.

#data.refs.at(0).org         // regions as `refs.0.org`
#data.refs.at(0)             // regions as `refs.0` — each step is its own address
#let row = data.refs.at(0)
#row.org                     // regions as `refs.0.org` too

Rebind that name anywhere in the plate — a second let, a closure parameter, a loop pattern, an assignment — and it stops being followed, because a read can no longer be tied to one value. Three shapes are past what the tracker follows at all:

Shape Why
a value handed to a function (#let f(c) = [#c.poc]) the parameter is a fresh name bound per call
a destructured binding (#let (poc, ..) = data.classification) the pattern names no chain
a per-card loop variable (#for card in data.at("$cards")) one shared expression site carries no per-instance identity

Each of those still renders correctly and loses only the click target, which is why nothing announces it. Wrap the read in a field-region claim to get the region back.

Backend-generated ink needs none of this. A richtext value's ink is born in generated code, so it keeps its address through a function, a loop, or a package that rebuilds it — and so does a date placed through display("<field>", ..), which is why the projection takes an address rather than a value. A value laundered through any of the shapes above is on the list like every other value, dates included.

Tying Composed Content to a Field

A live preview routes a click back to the schema field that produced the ink under it, and it finds that field automatically for content it generated: a richtext field's markup, a #data.subject reference in your plate. Content your plate composes — a banner keyed off data.classification, an address block a vendored package lays out, a computed table — draws ink Quillmark cannot attribute to anything. field-region claims it:

#import "@local/quillmark-helper:0.1.0": data, field-region

#let banner(level) = box(stroke: 1pt, inset: 6pt)[#upper(level)]

#field-region("classification")[#banner(data.classification)]

The banner now appears in session.regions() under classification and a click on it resolves through session.fieldAt(...), exactly as if the field had drawn it.

body is returned untouched, bracketed by two invisible metadata markers, so the wrapper changes nothing about layout or output bytes. Unlike a form-field widget it reserves no space and draws no click target of its own: it claims the ink that is already there.

What it claims

A claim is a fallback, not an override. Ink already tracked to a field keeps that field, and the wrapper takes only what is left:

#field-region("recipient")[
  #line(length: 2in)          // no field of its own → claimed for `recipient`
  #data.body                  // a richtext field → stays `body`
  Prepared by #data.author    // a scalar reference → stays `author`
]

Nesting therefore reads as ordinary scoping, and wrapping never moves a region off the field that generated it. The flip side: you cannot use field-region to retarget ink that is already attributed. Ink Typst attributes to no source position at all — list bullets, underline rules — stays unclaimed here as it is everywhere else.

Each call claims independently, so field need not be a literal and a wrapper used once per card yields one region per card:

#for card in data.at("$cards", default: ()) {
  field-region(card.at("$path") + "$body", render-card(card))
}

That is the way to give a card's scalar fields regions: read from the loop variable, they carry no per-instance identity of their own.

Parameters and errors

Name Type Default Meaning
field str required (positional) Schema address: a field name, an array element like "refs.2", or a card path built from the card's $path prefix.
body any content required (positional) Returned unchanged; its ink is what gets claimed.
  • A field that is not a known schema address, or is not a string, raises a Typst assert pointing at field-region.
  • A claim whose content Typst lays out somewhere else entirely (#place, a float) claims whatever ink lands between its markers instead; wrap the placed content rather than the place call.
  • Emit the call's return value whole. Splitting it — passing .children through separately, say — can land the opening marker in a frame without its closing one. Such a claim is bounded by nothing, so rather than let it take every unattributed piece of ink to the end of the document it is dropped entirely and reported as a typst::unclosed_field_region warning naming the field.

The label <__qm_region__> and metadata kind: "__qm_region__" are reserved for this hand-off: the same query(metadata) caveat applies.

Output Formats

PDF and SVG render as a single artifact. PNG renders one artifact per page.

Python binding (rendering lives on the engine, not the quill):

from quillmark import OutputFormat
result = engine.render(quill, doc, OutputFormat.PDF)   # or .SVG, .PNG

WASM/JS binding (rendering lives on the engine, not the quill):

engine.render(quill, doc, { format: 'png' });           // 144 PPI
engine.render(quill, doc, { format: 'png', ppi: 300 });  // print quality

PNG resolution is set via the ppi option (default 144, 2× at 72pt/inch, suitable for retina previews):

PPI Use case
72 Low-res web thumbnails
144 Retina screen preview (2×)
192 High-DPI screen display
300 Standard print quality
600 High-quality print / archival

Resources

Next Steps