Forms
A PDF form lives on two planes. Fields are document-scoped records — a field has a name, a family (text, checkbox, radio…), flags, and a value. Widgets are page-scoped annotations — the boxes you actually see and click. One field can have many widgets (a radio group is one field with one widget per button), and a widget is just an annotation until a field adopts it.
The engine mirrors that split: everything field-shaped lives on doc.forms, everything
visual stays on the annotation plane you already know. The examples below assume you
already opened a document (see Quick start).
Reading the form#
list() returns the whole form as one snapshot — the field tree, each field’s value and
options, and where its widgets sit:
const snapshot = await doc.forms.list();
snapshot.formKind; // 'acroform' | 'xfa' | 'none'
for (const field of snapshot.fields) {
// field.name — fully qualified ("billing.name")
// field.family — 'text' | 'checkbox' | 'radio' | 'combobox' | 'listbox' | …
// field.widgets — the page-placed widgets that render it
}Each family has its own DTO, so the value is always correctly typed: a text field has
value: string, a checkbox has checked: boolean, a multi-select list box has
selectedValues: string[]. Narrow on family and TypeScript does the rest:
const field = await doc.forms.get({ kind: 'fqn', name: 'billing.name' });
if (field.family === 'text') {
field.value; // string
field.maxLength; // number | null
}Addressing a field#
Every field carries a ref — reuse it, the same way you reuse annotation refs. When you
need to build one by hand there are two forms:
// Durable: the field dictionary's PDF object number (preferred).
await doc.forms.get({ kind: 'objectNumber', fieldObjectNumber: 12 });
// Portable: the fully qualified name — what FDF/XFDF and analytics use.
await doc.forms.get({ kind: 'fqn', name: 'billing.name' });Some PDFs ship broken forms: widgets that render but whose fields were never linked
into the document’s field tree. The snapshot recovers those and marks them
origin: ‘recovered’ — they read and fill like any other field. Use
repair to make the recovery durable in the file.
Filling fields#
setValue() takes a value shaped by family — text for text fields, a toggle state for
checkboxes and radios, export values for choice fields. Validation happens before
anything is written: a rejected write changes nothing.
// Text (enforces /MaxLen, comb, etc.).
await doc.forms.setValue(field.ref, { type: 'text', value: 'Ada Lovelace' });
// Checkbox / radio: pass the widget on-state to check, null to clear.
await doc.forms.setValue(radio.ref, { type: 'toggle', state: 'yes' });
await doc.forms.setValue(check.ref, { type: 'toggle', state: null });
// Choice (combo/list box): export values; multi-select takes several.
await doc.forms.setValue(listbox.ref, { type: 'choice', values: ['Apple', 'Cherry'] });
// Back to the default value (/DV), or empty when there is none.
await doc.forms.reset(field.ref);The result tells you what changed on screen:
const result = await doc.forms.setValue(radio.ref, { type: 'toggle', state: 'yes' });
result.field; // the field, read back after the write
result.changedWidgets; // every widget whose appearance changed — across ALL pageschangedWidgets is your render-invalidation list: a radio group flips two widgets at
once (the old choice off, the new one on), and they may live on different pages.
Radio edge cases are honored, not approximated: NoToggleToOff groups
reject state: null, radios-in-unison check every widget sharing the
on-state, and a checkbox with an export value (/Opt) reads back that
export value as the field value.
Import and export (FDF/XFDF)#
Form data travels between documents as FDF or XFDF — the interchange formats every PDF tool understands. Export produces bytes; import replays them field by field:
// XFDF is the default (XML, Unicode-safe). FDF is also available.
const { bytes } = await doc.forms.exportData('xfdf');
// …later, into another copy of the same form:
const result = await other.forms.importData(bytes);
result.fieldsApplied; // entries that matched and validated
result.fieldsSkipped; // unknown names / family mismatches — never poison the rest
result.snapshot; // the full form state after import, no second round tripImport is per-field: one bad entry is counted and skipped, everything else still lands.
Repairing a broken form#
repair() is the form doctor. It makes read-time recovery durable: it bootstraps a
missing /AcroForm, links recovered fields into the field tree, re-attaches stray
widgets, and (optionally) bakes missing appearance streams so every viewer renders the
same pixels:
const report = await doc.forms.repair({ bakeAppearances: true });
report.fieldsLinked; // recovered field roots now in /AcroForm /Fields
report.widgetsLinked; // stray widgets re-attached to their field
report.appearancesBaked; // widgets that got a fresh appearance streamRepair is validate-then-apply and idempotent — running it twice reports zeros the second time.
Authoring fields#
Creating a form element is one call. Give createField the family, a name, and where
its widgets go — placement and styling included:
const { field } = await doc.forms.createField({
family: 'radio',
name: 'subscription',
noToggleToOff: true,
widgets: [
{
pageObjectNumber,
rect: { left: 72, bottom: 640, right: 92, top: 660 },
onState: 'monthly',
appearance: { color: { r: 0, g: 0, b: 0 }, strokeWidth: 1 },
},
{
pageObjectNumber,
rect: { left: 72, bottom: 610, right: 92, top: 630 },
onState: 'yearly',
},
],
});
// The newborn group fills through the normal value path.
await doc.forms.setValue(field.ref, { type: 'toggle', state: 'monthly' });updateField patches field-plane properties (rename, flags, options, default value) —
pass the family so the valid fields are typed. deleteField removes the field and
cascades to its widgets:
await doc.forms.updateField(field.ref, { family: 'radio', name: 'billing_cycle' });
const removed = await doc.forms.deleteField(field.ref);
removed.removedWidgets; // widget annotations deleted from their pagesWidgets are annotations#
A widget’s styling vocabulary is the annotation vocabulary — same names, same writer.
You can create an unattached widget through the annotation plane and have a field adopt
it later, which is exactly what createField’s widgets array does for you:
// Born as an annotation: inert until a field adopts it.
const { created } = await page.annotations.create({
subtype: 'widget',
rect: { left: 72, bottom: 560, right: 260, top: 584 },
color: { r: 31, g: 111, b: 235 }, // border
interiorColor: { r: 246, g: 248, b: 250 }, // background
fontSize: 10,
});
// Adoption wires it to the field plane…
await doc.forms.attachWidget(field.ref, {
annotObjectNumber: created.ref.annotObjectNumber,
pageObjectNumber,
});
// …and release returns it to the annotation plane.
await doc.forms.detachWidget(field.ref, {
annotObjectNumber: created.ref.annotObjectNumber,
pageObjectNumber,
});Attached widgets show up in page.annotations.list() like any annotation, carry
fieldObjectNumber as the join key back to their field (0 means unattached), and are
restyled/moved through the ordinary page.annotations.update() path.
Deleting an attached widget through page.annotations.delete() is
refused — the field tree owns it. Detach it first, or use
doc.forms.deleteField() to remove field and widgets together.
Events#
Every form mutation publishes a document event — your own writes and, on the cloud, everyone else’s:
doc.events.subscribe((event) => {
if (event.type === 'form.valueChanged') {
event.field; // the field after the write
event.changedWidgets; // what to re-render
}
});| Event | Fired by |
|---|---|
form.valueChanged | setValue, reset |
form.imported | importData |
form.repaired | repair |
form.fieldCreated / form.fieldUpdated / form.fieldDeleted | field lifecycle |
form.widgetAttached / form.widgetDetached | widget adoption |
Form access is gated by the caller’s scope on the cloud: doc.forms.read
for snapshots and export, doc.forms.fill for value writes and import,
doc.forms.modify for field lifecycle, widget adoption, and repair. Each
scope implies the ones below it; insufficient scope fails with Forbidden.