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

PropWhat it does
namethe key the value is stored under, and the one fillForm uses
width, heightthe box it reserves, in points
tooltiphover text (/TU), and what a screen reader announces
readOnlyshown but not editable
requiredmust be filled before the form is submitted
hiddennot shown at all, on screen or in print
printfalse keeps it on screen only
border, background, borderWidthits appearance; a field draws no border by default
color, fontSizehow 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

FieldPropWhat it does
TextFieldvaluethe value it starts with
multilineaccept several lines - give it a matching height
passwordmask the characters
maxLengthrefuse anything longer, on entry and on fill
align"left" | "center" | "right"
Checkboxcheckedstarts ticked
onValuewhat is stored when ticked (default "Yes")
sizebox side length in points
RadioGroupvaluewhich option starts selected, by its value
size, gapbutton diameter, and the space between buttons
Dropdownvalue, editablethe starting choice; editable allows free text
ListBoxvalue, multiSelectthe starting choice; several selectable at once
PushButtonaction"reset" | { open: url } | { submit: url }
labelled fieldslabelthe text drawn beside a checkbox, radio or button
labelSize, labelColor, labelGaphow 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:

PropertyWhat it is
namethe fully qualified name - the /T values from the root down, joined by dots
type"Tx" text · "Btn" button · "Ch" choice · "Sig" signature
value, valueswhat it currently holds; values for a multi-select choice
optionsa choice field's entries, as { value, label }
onValuesevery export name a check box or radio can take, besides Off
maxLen/MaxLen, inherited down the tree like the type
flagsthe /Ff bits, already merged with anything inherited
widgetsthe boxes that display it - a radio group has one per button
needsAppearanceno 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.

jasypdf

Declarative PDFs in pure TypeScript. ZUGFeRD & XRechnung compliant, with no headless browser and no Java.

Resources

© 2026 Florian Heuberger · MIT License

Built with Nuxt · self-hosted fonts · no trackers