Documentation
Forms
jasy does both halves of the PDF form story: it creates fillable fields as ordinary layout elements, and it reads and fills a form that came from somewhere else - a government PDF, a supplier's order sheet - without touching the rest of the file.
Creating fields
A field is a layout element like any other. It reserves a box and emits a widget annotation, so it
sits in a Column, paginates, and takes the same sizing props as a Box.
import {
Document,
Page,
Column,
Row,
Text,
TextField,
Checkbox,
RadioGroup,
Dropdown,
ListBox,
PushButton,
SignatureField,
renderToBytes,
} from "@jasy/pdf";
Document([
Page({ size: "A4", margin: 48, gap: 12 }, [
Text("Registration", { size: 20, bold: true }),
TextField({ name: "fullName", width: 260, height: 22 }),
TextField({ name: "notes", width: 260, height: 60, multiline: true, maxLength: 500 }),
Checkbox({ name: "agree", label: "I agree to the terms" }),
RadioGroup({ name: "plan", gap: 8 }, [
{ value: "basic", label: "Basic" },
{ value: "pro", label: "Pro" },
]),
Dropdown({ name: "country", width: 120, height: 22 }, ["DE", "AT", "CH"]),
ListBox({ name: "topics", width: 160, height: 48 }, ["invoices", "reports"]),
PushButton({ name: "reset", label: "Reset", action: "reset", width: 80, height: 24 }),
SignatureField({ name: "signature", width: 200, height: 48 }),
]),
]);
Seven kinds: TextField, Checkbox, Radio / RadioGroup, Dropdown (alias Select), ListBox,
PushButton and SignatureField. The ones that offer a choice take it as the second argument, the
way Column takes its children - a radio choice as { value, label }, a dropdown or list entry as a
plain string.
A PushButton does one of three things: action: "reset" clears the form, { open: url } follows a
link, { submit: url } posts the values - the last one Acrobat only, since neither poppler nor pdf.js
implements /SubmitForm.
The props every field takes
| Prop | What it does |
|---|---|
name | the key the value is stored under, and the one fillForm uses |
width, height | the box it reserves, in points |
tooltip | hover text (/TU), and what a screen reader announces |
readOnly | shown but not editable |
required | must be filled before the form is submitted |
hidden | not shown at all, on screen or in print |
print | false keeps it on screen only |
border, background, borderWidth | its appearance; a field draws no border by default |
color, fontSize | how the value is drawn inside the box |
A field with no border is invisible until it is clicked, exactly like a browser input with
border: none. That is a reasonable default when you draw your own boxes underneath, and a surprise
otherwise - so give it one:
TextField({ name: "fullName", width: 260, height: 22, border: "#aab3c2", background: "#fbfcfe" });
And the ones only some of them take
| Field | Prop | What it does |
|---|---|---|
TextField | value | the value it starts with |
multiline | accept several lines - give it a matching height | |
password | mask the characters | |
maxLength | refuse anything longer, on entry and on fill | |
align | "left" | "center" | "right" | |
Checkbox | checked | starts ticked |
onValue | what is stored when ticked (default "Yes") | |
size | box side length in points | |
RadioGroup | value | which option starts selected, by its value |
size, gap | button diameter, and the space between buttons | |
Dropdown | value, editable | the starting choice; editable allows free text |
ListBox | value, multiSelect | the starting choice; several selectable at once |
PushButton | action | "reset" | { open: url } | { submit: url } |
| labelled fields | label | the text drawn beside a checkbox, radio or button |
labelSize, labelColor, labelGap | how that label is set and how far it sits away |
A Radio option also takes selected when you build the buttons yourself instead of handing
RadioGroup a list.
jasy bakes an appearance for every field, so the value is visible before a reader redraws it. Opt
out with renderToBytes(doc, { fieldAppearances: false }) if you would rather the viewer draw
everything. Field text is deliberately unkerned: viewers do not kern field text, and a baked
appearance has to match what the viewer will redraw over it.
Filling a form somebody else made
The reader lives behind its own entry point, so a project that only ever generates PDFs never bundles it:
import { readAcroForm, fillForm, flattenForm, PdfDocument } from "@jasy/pdf/edit";
// What is in there? Each field comes back with its name, type, current value, options and flags.
const form = readAcroForm(await PdfDocument.open(bytes));
form.fields.map((f) => `${f.name} (${f.type})`); // ["fullName (Tx)", "agree (Btn)", ...]
Each field it hands back:
| Property | What it is |
|---|---|
name | the fully qualified name - the /T values from the root down, joined by dots |
type | "Tx" text · "Btn" button · "Ch" choice · "Sig" signature |
value, values | what it currently holds; values for a multi-select choice |
options | a choice field's entries, as { value, label } |
onValues | every export name a check box or radio can take, besides Off |
maxLen | /MaxLen, inherited down the tree like the type |
flags | the /Ff bits, already merged with anything inherited |
widgets | the boxes that display it - a radio group has one per button |
needsAppearance | no widget carries a drawn appearance, so filling it has to generate one |
Note the name: the creation option is maxLength, the field read back reports maxLen, because
that side mirrors the PDF key /MaxLen rather than the factory.
const { bytes: filled, warnings } = await fillForm(bytes, {
fullName: "Ada Lovelace",
agree: true,
country: "DE",
});
Both fillForm and flattenForm return a result object - bytes plus what was touched
(filled, flattened) and any warnings - rather than raw bytes, so nothing about the operation is
lost on the way back.
You pass values, not object mutations. The form is a contract, and every way of breaking it is a
named error rather than a silent no-op: an unknown field name, a value of the wrong type for the kind,
a string past the field's MaxLen, a choice that is not in the list, a read-only field, a signature
field, a push button.
Flattening
flattenForm stamps each field's appearance onto the page and removes the widget, so the values can
no longer be edited. fillForm(bytes, values, { flatten: true }) does both in one pass:
const { bytes: frozen } = await fillForm(bytes, { fullName: "Ada" }, { flatten: true });
// or on its own, optionally for named fields only
const { bytes: partly } = await flattenForm(filled, { fields: ["fullName"] });
The original file stays a prefix
Saving is an incremental update: the original bytes are kept verbatim, the changed objects are
appended, and the new cross-reference table chains back with /Prev. That gives a property you can
check with a diff - the file you passed in is a literal prefix of the file you get back. Nothing
was rewritten behind your back, and any signature over the original bytes still covers them.
An encrypted PDF works too: fillForm(bytes, values, { password }) decrypts, fills and re-encrypts,
including the strings it carries over.
Errors you can catch, not strings you have to match
Every refusal has a type, so a caller can tell one apart from another without reading messages:
import {
fillForm,
FillError,
FlattenError,
PdfEncryptedError,
PdfStreamTooLargeError,
} from "@jasy/pdf/edit";
try {
await fillForm(bytes, { nope: "x" });
} catch (e) {
if (e instanceof FillError) {
// 'no such field: "nope". This form has: "fullName", "agree"'
}
}
PdfEncryptedError says which of the three things went wrong - no password, wrong password, or a
revision jasy does not implement. PdfStreamTooLargeError is the zip-bomb guard: a compressed stream
that would inflate past 64 MB is refused rather than decompressed into memory. Raise or lower it
per call with maxStreamSize, and read the default from DEFAULT_MAX_STREAM_SIZE:
await fillForm(bytes, values, { maxStreamSize: 8 * 1024 * 1024 });
The guard is not free advice: the obvious implementation is wrong, because fflate does not throw on
an over-long inflate - it truncates silently, which would trade a crash for quiet data corruption.
jasy streams the input and checks the total as it grows.
Reading is broad, writing is narrow
jasy opens AES-256 (R6, R5), AES-128 (R4) and RC4 (R2/R3), because those files exist and you may have to fill one. It writes only AES-256 R6 - the newest standard. Handing someone protection that is known to be broken is not a feature, so the legacy handler refuses to encrypt.
Worth knowing when you compare: PDFKit and react-pdf both default to 40-bit RC4 when you ask them for a password, and neither can emit R6 at all.
The bug this design came from
A filled field showed nothing until you clicked it. The obvious story - "the viewer redraws lazily" - was wrong, and a second implementation proved it: react-pdf's filled file behaved fine. The difference was that theirs carried no appearance, so the viewer had to draw, while ours carried a stale one from creation. We had written a new value and left the old picture behind: a self-contradicting document.
So on fill, a text or choice field's appearance is dropped; a button's is kept, because its appearance holds its on and off states and only the selector moves.