PDF Form Backend (acroform)¶
The acroform backend fills existing PDF forms: something the Typst backend fundamentally cannot do (Typst cannot embed a PDF page, so a Typst path would rasterize the form and lose fidelity). Instead of generating a page from a plate, acroform stamps a fresh AcroForm onto a pre-existing background and binds your document's values into the widgets.
It is Typst-free: an acroform quill never compiles Typst code and a form-only build never links the Typst compiler.
The two-asset model¶
An acroform quill ships two assets at its root instead of a plate:
my-form/
├── Quill.yaml
├── form.pdf # the stripped background: pages + chrome, no form fields
└── Form.yaml # the value-free field reconstruction spec
form.pdf, the stripped background: the normalized form with its/AcroForm, widget annotations, and page/Annotsremoved (pure pages, rules, boxes, and labels).Form.yaml, the value-free placement + binding layer: where each widget sits (page,rect) and which schema field it binds (schema_field). Everything intrinsic (widget kind, choice options, multiline, tooltip) is derived from the quill schema, not restated here.
At load the backend binds each field's value from your document data and writes the AcroForm fresh from Form.yaml onto the background. It never reconciles a form already in form.pdf: a background that still carries one is refused (pdf::existing_acroform), since a second /AcroForm on the catalog is a dict the spec does not define and the old widgets would stay live in the page /Annots.
A catalog /AcroForm null is no form: ISO 32000-1 §7.3.9 reads an entry whose value is null as absent, so the background stamps and the fresh /AcroForm replaces the null entry. A reference to a null object, or to an object the file does not hold, is null too (§7.3.10), so /AcroForm 7 0 R over 7 0 obj null endobj is no form either. Every optional entry the backend reads off form.pdf reads a null the same way, written directly or referenced.
The background's cross-reference data is a classic xref table. One held in a cross-reference stream, or a hybrid file whose trailer names /XRefStm or whose /Prev chain reaches such a stream, is refused as pdf::xref_stream: objects inside an object stream are invisible to the stamp. qpdf --object-streams=disable in.pdf form.pdf writes a classic table.
Nulling the key (PyMuPDF's xref_set_key sets a key to null rather than deleting it) strips the catalog entry alone, and the old widget annotations stay live in the page /Annots. A background whose page /Annots hold a /Subtype /Widget, written directly or referenced, is refused as pdf::existing_acroform too, whatever its catalog holds, and the message names the page.
Where the assets come from
Producing a clean form.pdf + Form.yaml from a raw source PDF (decrypt, strip, extract, verify) is the job of a separate quillification layer and is out of scope for Quillmark, which checks the result of the stripping, not how it was reached. V1 quills hand-author both assets; the sample_form fixture in crates/fixtures/resources/quills/sample_form/ is a worked example.
Quill.yaml¶
An acroform quill declares backend: acroform and has no plate file. The document body is typically disabled: a form is filled from fields, not prose. Fields under main.fields define the document schema exactly as for any other backend:
quill:
name: sample_form
version: 0.1.0
backend: acroform
description: "Demo PDF form filled by the Typst-free acroform backend, over every field type it binds."
main:
body:
enabled: false
fields:
full_name:
type: text
default: ""
description: Full legal name of the applicant. Binds the FullName text field.
comments:
type: array
items:
type: text
default: []
description: "Free-form comments; each element becomes one line of the Comments field."
agree:
type: boolean
default: false
description: Whether the applicant agrees to the terms. Binds the Agree checkbox.
favorite_color:
type: enum
values:
- red
- green
- blue
default: red
description: Favorite color. Binds the FavoriteColor dropdown.
headline:
type: richtext
multiline: false
default: ""
description: "One-paragraph richtext bound to the Headline text field; lowered to plain text."
See the Quill.yaml Reference for the full field-type vocabulary.
Form.yaml¶
Form.yaml is a durable, version-controlled artifact: readable, diffable, and commentable. It carries two populations: bound fields that reference a schema_field and inherit their widget kind from the schema, and unbound widgets with no schema field (a signer fills them), which declare their own type:
schema: quillmark/form@0.2.0
fields:
- name: FullName
schema_field: full_name
page: 0
rect: { x: 180, y: 100, w: 340, h: 20 }
- name: Comments
schema_field: comments
page: 0
rect: { x: 180, y: 140, w: 340, h: 80 }
# Box 3 on the printed form: the terms checkbox.
- name: Agree
schema_field: agree
page: 0
rect: { x: 180, y: 240, w: 14, h: 14 }
- name: FavoriteColor
schema_field: favorite_color
page: 0
rect: { x: 180, y: 280, w: 340, h: 20 }
- name: Headline
schema_field: headline
page: 0
rect: { x: 180, y: 520, w: 340, h: 20 }
widgets:
- name: Signature
type: signature
page: 0
rect: { x: 180, y: 330, w: 340, h: 40 }
- name: SignerInitials
type: text
page: 0
rect: { x: 180, y: 390, w: 120, h: 20 }
- name: SignerConfirms
type: checkbox
page: 0
rect: { x: 180, y: 430, w: 14, h: 14 }
- name: SignerRole
type: choice
options: [witness, notary, guardian]
page: 0
rect: { x: 180, y: 470, w: 340, h: 20 }
The file is read as Quill.yaml is: YAML 1.2, where only true and false are booleans, so options: [yes, no] holds two strings. JSON is YAML 1.2, so a generator may write JSON into Form.yaml unchanged, with three exceptions:
- A string holding DEL, a C1 control other than U+0085, U+FFFE or U+FFFF written raw is refused by the parser. Escape such a character (
\u007f). - A key written twice in one object is refused, whatever the key.
- A
rectvalue pastf32range, such as1e39, is refused.
A YAML error, one of those three, or a key missing from an entry fails the load as acroform::invalid_form_spec, located at its line and column in Form.yaml.
Quote text holding # or :
An unquoted # starts a comment, so tooltip: Box 14b #2 reads as Box 14b with no error, and an unquoted : fails the load. Quote such a value: tooltip: "Box 14b #2".
A quill holding form.json or a lowercase form.yaml, beside Form.yaml or in its place, fails to load as acroform::legacy_form_json or acroform::legacy_form_yaml; rename the file to Form.yaml, contents unchanged.
Note what is absent from the bound fields: no type, options, or multiline. FullName is a text box because full_name is a text; Agree is a checkbox because agree is a boolean; FavoriteColor is a dropdown whose options are the schema field's enum values; Comments is a multi-line text box because comments is a scalar array, one element per line. The two files cannot drift, because there is only one source of truth.
Bound-field keys (fields)¶
| Key | Required | Notes |
|---|---|---|
name |
yes | The widget's /T entry: unique across both fields and widgets. |
schema_field |
yes | The document field this widget binds to. Resolved against the quill schema at load; a dangling path is a load error (acroform::dangling_binding), not a silent blank. |
page |
yes | 0-based page index into form.pdf. |
rect |
yes | {x, y, w, h} in PDF points (1/72"), top-left origin, page-relative (see below). A box with no width or height, or wholly off its page, is a load error (acroform::invalid_field_rect). |
tooltip |
no | Overrides the widget's /TU. When omitted, the field inherits the schema field's description. |
option |
no | One value of an enum field. The widget is a checkbox of its own, checked when the field holds that value, and its /TU defaults to the field's description followed by the value. Bind one per value to lay out a printed group of boxes that answers one question. A variant container binds its discriminant, classification.value. A field that is not an enum, or a value outside it, is a load error (acroform::invalid_option). The boxes are separate fields, so a viewer does not keep them exclusive when a person ticks them by hand. option is quillmark/form@0.3.0 vocabulary: Quillmark before 0.127 ignores it and draws a dropdown in the box, so a quill using it declares requires: ">=0.127". |
Unbound-widget keys (widgets)¶
An unbound widget has no schema_field, so it cannot inherit a kind: it declares one.
| Key | Required | Notes |
|---|---|---|
name |
yes | The widget's /T entry: unique across both populations. |
type |
yes | One of text, checkbox, choice, signature. |
page |
yes | 0-based page index into form.pdf. |
rect |
yes | Same top-left geometry as a bound field. |
tooltip |
no | The widget's /TU. |
multiline |
text only | true for a multi-line text box. |
options |
choice only | The dropdown options, as bare strings. |
Widget-kind projection¶
A bound field's kind is derived from the capability of the resolved schema field, not its type token. The projection is total, or the quill fails to load:
| Resolved schema field | Widget kind |
|---|---|
has enum values |
choice, options = the enum values |
has enum values, with the field's option set |
checkbox, checked for that value |
boolean, or a matrix member (its tick) |
checkbox |
text, richtext, number, integer, date, datetime |
text |
| array of the above (scalar or prose) | text: elements joined with newlines |
object, matrix, or array of objects |
load error acroform::unbindable_field |
A container has no widget shape of its own; the value positions inside it do, and each binds at its own address. A matrix member's own address is its tick, a checkbox at qualifications.flight_cc checked when the document holds the member, and its columns are text widgets beside it (qualifications.flight_cc.detail). An open matrix fails to load (quill::open_matrix_unsupported): a form has no widget for an item a document adds.
A text widget bound to a text or richtext field declaring max: carries it as /MaxLen, so a viewer stops input at the cap. A value already past the cap stamps without /MaxLen, since a viewer would otherwise cut the value the document holds. The widget counts as a viewer does, in UTF-16 units of the drawn text, so a value under the cap that holds a character outside the Basic Multilingual Plane, such as an emoji, can also stamp without it. validate warns validation::over_length on a value past the cap either way. A scalar array's max: counts elements, so the joined widget carries none.
multiline on a text widget comes from the schema field's own multiline: true (a scalar array is always multiline), and a value holding a newline (a richtext of two paragraphs, a block scalar) widens it to multiline whatever the schema said, so the file shows every line the preview does.
A text widget takes a richtext value's plain text: the backend draws paragraphs and nothing else. A heading, rule, code block, list, quote, definition, table, image, footnote or equation in a content field warns backend::declined_construct at validate and at render, once per field and construct. The text of a heading, code block, list, quote or definition still lands as lines; a rule, table, image, footnote or equation lands as nothing.
Top-left coordinates¶
rect is authored top-left origin: x/y measured from the top-left corner of the page, the way a human reads a form. The corner is the one a viewer shows you — the page's canvas box, /CropBox intersected with /MediaBox — so a background cropped or shifted away from PDF user-space (0,0) (anything through pdfcrop, say) needs no adjustment on your side. The backend flips to PDF's native bottom-left origin when it builds the widget. You never reason about page height or coordinate flipping yourself.
A page whose canvas box is under a point per side carries no canvas to place anything on, and loading refuses it with pdf::degenerate_page_box.
Schema versioning and unknown keys¶
schema follows the convention quillmark/form@<version>, hand-set at the last format change (never auto-derived from a crate version). The current format is quillmark/form@0.3.0, which adds a bound field's option; a 0.2 file reads unchanged. Unknown keys are ignored, not rejected, so the format grows additively under a later minor: every 0.x from 0.2 on is read, and a new major is where a break goes. A version below 0.2 or under another major is rejected: a form@0.1.0 file (which restated type/options/multiline on each field) fails to load with acroform::invalid_form_spec.
Opinionated styling¶
The background owns all visual chrome; each widget is a transparent input over it. The backend therefore picks one house style (a standard font, auto-sized black text, a fixed checkbox on-state) and Form.yaml carries no styling: no fonts, colors, borders, flag bitfields, or per-field appearance. Keep Form.yaml to geometry and type.
Binding values¶
Each field's value comes from the resolver: for every bound field, the backend dereferences its schema_field against your document data and coerces it to the field's derived widget kind.
- Bound against the same validated data the Typst plate sees. Schema validation, defaults, blank-fill, and scalar coercion are all inherited: there is no second data pipeline.
- Addressing is a shallow path rooted at a schema field name, optionally with an array index or nested key:
full_name,comments.0,address.street. The path is validated against the schema at load: a.Nsegment requires an array, a.keysegment requires an object, and any miss is aacroform::dangling_bindingload error naming the failing segment. - Coercion is type-directed:
| Type | Binding |
|---|---|
text |
String value; numbers/bools stringify; an array joins with newlines (one element per line: the multiline fill). Empty → blank. |
checkbox |
true → checked; otherwise unchecked. Binds a boolean field, which reaches here already coerced, or a matrix member, checked when the document holds it. With option, binds an enum and is checked when its value equals option. |
choice |
The value must match one of options exactly, else the field is left blank. |
signature |
Never bound: always an empty, signer-fillable widget. |
- Unbound = blank. A field with no
schema_field, or whose bound value is absent ornull, renders empty.
Card-instance addressing¶
A schema_field rooted at the reserved $cards key binds one card instance from the document's $cards array (the same array the Typst plate iterates), by kind + index:
$cards.indorsement.1.from: thefromfield of the second card whose$kindisindorsement, skipping intervening cards of other kinds.
Absolute-index addressing ($cards.0.from) is not accepted in form@0.2.0: a widget's kind must be statically derivable at load, and only the kind names which schema field the slot binds (an absolute index does not, since the card at that position varies per document).
This lets a static, fixed-capacity form lay out a bounded number of card slots across its existing pages: each slot a bound field with its own page, bound to a distinct instance.
Static forms only
acroform stamps over a fixed, pre-existing page set. It never composes content, appends continuation pages, or merges PDFs. A document carrying more card instances than the form has slots is the author's concern, not Quillmark's.
Signature fields¶
A type: signature entry in the widgets section produces a clickable, unsigned AcroForm signature widget. Open the result in Acrobat (or any reader that supports form signing) and the widget presents a "Sign Here" affordance.
The widget is unsigned: Quillmark performs no cryptography. To produce a signed PDF, run the output through pyHanko, Acrobat, endesive, or another signing tool.
Output formats¶
The backend's output formats are [Pdf]: the deliverable, always an interactive
AcroForm. SVG and PNG error with backend::format_not_supported, the code both
built-in backends share.
PDF is emitted whole, so a RenderOptions::pages selection errors with
backend::page_selection_not_supported.
Canvas is a separate surface from render(): it is the WASM paint()
raster path (render_rgba), not an OutputFormat, and this backend paints. See
PREVIEW.md.
The PDF is the real deliverable, and the canvas rasterizes the same stamp but for each checkbox's /AP /N, which there names only the state the box shows (the canvas rasterizer reads no state dictionary).
Each widget is a real field carrying /NeedAppearances and a baked /AP appearance stream drawing its current value. The two split the work: a viewer that synthesizes appearances (Acrobat, Chrome/pdfium, Preview.app, pdf.js's forms layer) rebuilds each value from /V and /DA, refitting it as the user types; a consumer that synthesizes nothing (a raster pipeline, Ghostscript, the canvas) draws the baked stream and shows the value rather than an empty box.
The baked stream fits the value as a viewer's auto-size does: a one-line value shrinks until it fits the box's width, and a multiline value breaks at spaces into lines of the box's width, shrinking until they fit its height, down to 4 pt. A value that does not fit even at 4 pt is cut off at the box's edge, and the session warns acroform::text_overflow at its field.
A checkbox bakes both of its states, checked or not: /AP << /N << /Yes … /Off … >> >>, with /AS and /V naming the current one. Yes draws the check mark and Off draws nothing, the background owning the box itself. A later stage that fills the delivered PDF toggles a box by renaming /AS and /V, as pypdf does, with no appearance to regenerate.
Baked-appearance fidelity limits¶
The /V a stamped field carries is always faithful, and a synthesizing viewer renders from it. The baked stream is an approximation in three ways, because it commits to one encoding and one measurement where a form renderer would defer. In all three the delivered /V is correct, only the drawn pixels differ, and no diagnostic is raised.
- Non-WinAnsi characters draw as
?. The stream encodes text as WinAnsi (CP1252). Any code point outside that range (CJK, emoji, and many symbols) is substituted with?, while/Vkeeps full Unicode (UTF-16BE). A field whose value is日本語shows correctly in Acrobat but as???on the canvas. The session warnsacroform::unencodable_textat each such field, with how many characters it draws as?. - Justification is not applied. The stream draws from the box's left edge;
/Q(thealigndial) moves the text only in a viewer that re-synthesizes. - Overflow is clipped. A value wider or taller than its field box is clipped to the box by the appearance's
/BBox, whereas a synthesizing viewer wraps or scrolls it. A canvas can therefore look truncated where the same field in Acrobat is complete.
Treat /V, not the drawn pixels, as the source of truth for what a field actually contains.
Regions sidecar¶
Field geometry comes from a session's regions(), or from a one-shot render that asks for it (RenderOptions::regions in Rust, regions=True in Python, { regions: true } in JavaScript), which fills RenderResult.regions. Either way it is one entry per schema-bound field, keyed on its schema field path:
pub struct RenderedRegion {
pub field: String, // schema field path
pub page: usize, // 0-based
pub rect: [f32; 4], // [x0, y0, x1, y1], PDF pt, bottom-left origin
pub span: Option<[usize; 2]>, // scalar [start, end) of the covered content; None for a scalar/widget
}
regions() reads off the compiled session without producing another byte artifact, so a GUI can fetch geometry once and overlay it on whatever surface it shows (a paint-ed canvas or a rendered page), independent of which format it goes on to render. A field with no schema_field never surfaces a region. The acroform_preview example (crates/quillmark/examples/) opens a session for the sample_form fixture and prints its regions for cross-checking against a viewer.
Resources¶
- PDF AcroForm reference: §12.7, Interactive Forms
- pyHanko: signing a stamped PDF
Next steps¶
- Creating Quills: quill bundle layout and workflow
- Quill.yaml Reference: full field types and constraints
- Typst Backend: the other backend, for generated documents