ffpdf is a small, fast command-line tool for reading and filling PDF form fields, with a JSON in / JSON out contract that scripts and AI agents can drive. It is written in plain C with no dependency beyond zlib: no Java, no pdftk, no headless browser. Ask it what fields a form has, hand back the values as JSON, and it writes a filled PDF.
It’s built to handle real-world PDFs: government forms, bank documents, LiveCycle/XFA forms, and encrypted “secured” PDFs, not just clean textbook files.
$ ./ffpdf fill form.pdf values.json > filled.pdfMost "fill a PDF form" tools drag in a huge runtime or require commercial licensing. This one is a single ~100 KB binary that:
- Starts instantly and stays small. Filling streams the original file straight through and only buffers the bytes it appends, so memory stays proportional to your changes, not the file size. A 22 MB PDF parses in a fraction of a second.
- Preserves the original. Fills are written as a PDF incremental update: the original bytes are copied verbatim and the changes are appended. Nothing is silently rewritten.
- Scriptable and agent-friendly.
fieldslists a form's fields as JSON (names, types, options, constraints);filltakes the values back as JSON and reports the outcome as JSON with a meaningful exit code. No FDF knowledge required. (FDF is still supported and auto-detected, handy if you already work with FDF or pdftk.) - Handles the hard cases (encryption, XFA, compressed object streams, weird cross-reference layouts).
- Made for the thousand-PDF loop. With no runtime to start, a fill is just a process exec: ~600 real forms per second on one core. See Filling PDFs by the thousands.
# 1. Build it (needs gcc and zlib)
make
# 2. See what fields a form has (as JSON)
./ffpdf fields form.pdf
# 3. Fill it from a JSON object of values
./ffpdf fill form.pdf values.json > filled.pdfThat’s the whole loop: discover → fill → verify, all in JSON.
Say form.pdf has a text field named applicant_name and a checkbox named agree.
1. Discover the fields with fields. The JSON gives each field's name and type, and — for checkboxes and choice fields — the values it accepts:
$ ./ffpdf fields form.pdf
{
"fields": [
{ "name": "applicant_name", "type": "text", "value": "", "required": false, "readonly": false },
{ "name": "agree", "type": "button", "value": "Off", "required": false, "readonly": false, "on_state": "Yes" }
],
"count": 2,
"xfa": false,
"dynamic_xfa": false
}The on_state says agree is checked by the value Yes (or simply true).
2. Write your answers as a JSON object keyed by field name, values.json:
{
"applicant_name": "Ada Lovelace",
"agree": true
}3. Fill and verify:
$ ./ffpdf fill --strict -o filled.pdf form.pdf values.json
$ echo $?
0Open filled.pdf: the name is typed in and the box is checked. The tool picks the right on‑state for the checkbox, generates the visual appearance so the values show in any viewer, and (for dynamic XFA forms) also updates the XFA data packet so Adobe renders it too. --strict makes it exit non‑zero if any value failed to match a field, so a script can trust the outcome without parsing output.
Tip: field names can be long and nested (e.g.
topmostSubform[0].Page1[0].f1_1[0]). Use the full name fromfields, or just the last.-separated piece if it’s unique.Prefer PDF‑native FDF?
filltakes that too; it's auto‑detected, and handy if you already work with FDF or pdftk. See batch fills.
Start with a blank form:
One ffpdf fill command produces the filled version on the left; adding --flatten produces the right one, with the same values baked permanently into the page and no interactive form left to edit:
| Filled by ffpdf | Filled with --flatten |
|---|---|
![]() |
![]() |
The docs/ folder carries the actual files, which you can open right in your browser:
| File | What it is |
|---|---|
example-form.pdf |
The unfilled form: a realistic one-page application exercising every field type (text fields, a checkbox, a combo box, a multi-select list box, and a signature field) |
example-answers.fdf |
The plain-text FDF holding the values, including a multi-select array |
example-filled.pdf |
The result: text filled, box checked, dropdown set, both list selections highlighted. The signature field stays empty (a signature is not a fillable value), and the form is still editable |
example-flattened.pdf |
The --flatten result: the same values baked into the page content, with the form removed (nothing left to edit) |
$ ffpdf fill -o example-filled.pdf example-form.pdf example-answers.fdf
$ ffpdf fill --flatten -o example-flattened.pdf example-form.pdf example-answers.fdfThe values here are held in an FDF (example-answers.fdf); the same fill works from an equivalent JSON object — fill auto-detects the format. The PDFs in the repo were produced by ffpdf itself (make examples regenerates all of them), and the values are visible in any viewer because fill generates the visual appearance streams rather than relying on the viewer to draw them.
Filling one form is easy; the payoff is filling thousands. Because ffpdf is a ~100 KB binary with no runtime to warm up, invoking it once per record costs almost nothing. The pattern is a shell loop over your data, one fill per row, each writing its own output file, with the values for each record supplied as JSON.
The quick path turns a JSON object into a template with placeholders where the values go:
{
"Address": "${ADDRESS}",
"City": "${CITY}",
"State": "${STATE}",
"Zip": "${ZIP}"
}then loop over your data; envsubst (from gettext) does the substitution:
while IFS=, read -r ADDRESS CITY STATE ZIP; do
export ADDRESS CITY STATE ZIP
envsubst < template.json | ffpdf fill -o "out/$ZIP-$CITY.pdf" form.pdf -
done < customers.csv(- reads the values straight from the pipe, with no intermediate file to manage, and -o writes each PDF atomically, so a killed job never leaves partial files in out/.)
Measured on a single ordinary core: 1,000 filled PDFs in about a second for a small form, and ~600 fills/second on a real 74 KB IRS form, roughly 36,000 a minute before you even reach for xargs -P. The per-invocation startup cost that makes JVM-based tools painful in this loop simply isn’t there.
A few practical notes:
- For messy data, generate the JSON instead of templating it.
envsubstis plain text substitution, so a value containing"or a backslash will break the JSON. When your data is untrusted or may contain quotes, build each object with a real serializer (jq -n --arg city "$CITY" '{City: $city}', or your language's JSON library), which escapes correctly. The template above is the quick path for clean data. - Multi-select fields take an array. A list box that accepts several values is filled with
["opt1", "opt2"](thefieldsoutput flags these with"multi_select": trueand lists the validoptions). - Parallelize trivially. Every fill is an independent process with no shared state: split the input and run N loops, or hand the whole thing to
xargs -P/GNUparallel.
Already have FDF? fill auto-detects it, so the very same loop works with an FDF template (envsubst < template.fdf | ...). That's the natural choice when you interoperate with pdftk or a system that already emits FDF; just remember FDF strings need (, ) and \ backslash-escaped.
| Command | What it does |
|---|---|
fdf-extract <pdf> |
Extract all form fields as an FDF (to stdout) |
xfdf-extract <pdf> |
Extract all form fields as an XFDF (XML flavour, to stdout) |
fields <pdf> |
List all form fields as JSON: names, human-readable labels, types, current values, page numbers, required/readonly/maxlen constraints, a choice field's options and flags, a checkbox's on-state, plus document xfa/dynamic_xfa flags. The machine-readable companion to fdf-extract, built for scripts and AI agents |
fill [-f|--flatten] [-o FILE] <pdf> <values> |
Fill <pdf> from a <values> file — an FDF or a JSON object, auto-detected; the PDF and values may be in either order, and <values> may be - for stdin. Write the result to stdout or, with -o, atomically to FILE. With --flatten (-f), bake the values into the page and remove the interactive form, producing a non‑editable PDF |
xref <pdf> |
Dump the parsed cross-reference table as JSON (a debugging aid) |
help (-h, --help) |
Print full help to stdout and exit |
version (-v, --version) |
Print version and license info and exit |
All commands are subcommands of ffpdf (e.g. ffpdf fill form.pdf data.fdf).
Everything goes to stdout, so redirect it (... > out.pdf), or use -o out.pdf, which writes atomically (the file is assembled as out.pdf.tmp and renamed into place only on success, so a failed fill never leaves a partial output). As a safety net, fill refuses to write PDF bytes to a terminal. Progress and warnings go to stderr, so they won’t corrupt the output. -- ends option parsing for file names that begin with -. Run ffpdf --help for a built-in summary, or see the ffpdf(1) man page.
Checking the result programmatically. By default fill already fails loudly when nothing lands: if no field in the input matched the form, it writes no output and exits 2 (a no-op fill is treated as a failure, not a silent success — --flatten is exempt). For more detail, fill --json writes a JSON summary to stdout (which fields were updated, not_found, or truncated to fit a /MaxLen, plus counts; pass -o for the PDF since stdout then carries the JSON), and fill --strict exits 3 if any field didn't match — so a caller can trust the exit code instead of parsing text:
$ ffpdf fill --json --strict -o out.pdf form.pdf answers.fdf
{ "updated": ["applicant_name"], "not_found": ["agree_typo"],
"updated_count": 1, "not_found_count": 1 }
$ echo $?
3Exit codes: 0 success · 1 bad usage or unreadable PDF · 2 nothing matched (no output) · 3 --strict and some field didn't match.
JSON values reference. The <values> file is a flat JSON object keyed by field name (the shape fields emits — see the worked example). The value's type selects the fill behaviour:
| JSON value | Fills |
|---|---|
| string | a text or choice field ("State": "NH") |
| array of strings | a multi-select list box (["Flood", "Identity theft"]) |
| number | used verbatim as text — handy for ZIPs and IDs (3801 → "3801") |
true / false |
checks / clears a checkbox (mapped to its real on-state) |
null |
skipped — the field is left as-is |
values.json may be - to read from stdin. String values are UTF-8 (or \uXXXX escapes), so "Zoë 😀" round-trips correctly. A value longer than a text field's /MaxLen is truncated to fit, with a warning (and is listed under truncated in the --json result).
Form types
- AcroForm fields (the standard kind)
- Dynamic XFA forms (Adobe LiveCycle): the AcroForm layer and the XFA data packet are kept in sync
Field types
| Type | Filling behaviour |
|---|---|
| Text | Value written; visual appearance generated (UTF‑16 for non‑ASCII) |
| Checkbox / radio | Maps Yes/On/1 (or an explicit state) to the widget’s real on‑state; a radio group's valid option names are listed by fields, and fill sets both the group value and the selected button |
| Choice (combo / list box) | Matches your value against the options, sets the selection index, and draws it, single or multi‑select |
| Signature | Left untouched (a signature isn’t a fillable value) |
Under the hood. The parsing machinery that makes the above work on real files:
- Flate and LZW stream decompression, with PNG and TIFF‑2 predictors
- Compressed object streams, classic + cross‑reference‑stream + hybrid xref layouts
- Encryption: the standard security handler with an empty user password (RC4, AES‑128, and AES‑256), for both reading and filling
- Correct handling of the fiddly bits: non‑ASCII values, on‑state discovery, appearance streams that render even in viewers that ignore
NeedAppearances
This tool deliberately does one job, reading and filling form fields, and does it as a tiny, dependency-free binary. pdftk and Aspose.PDF are much broader tools; here’s the honest trade-off.
| ffpdf | pdftk (pdftk-java) | Aspose.PDF | |
|---|---|---|---|
| Cost | Free | Free | 💲 Paid commercial license |
| License | Apache-2.0 (permissive) | GPLv2 (copyleft) | Proprietary |
| What it is | A CLI + small C codebase | A CLI (Java app) | An SDK/library (.NET, Java, C++, Python…) |
| Runtime required | none (just zlib) | a Java runtime (JRE) | a .NET / JVM / native runtime |
| Footprint | ~100 KB binary | JRE + jar (tens of MB) | large SDK |
| Cold-start speed | instant | pays JVM startup each run | in-process (it’s a library) |
| Scope | read & fill form fields | full toolkit: merge, split, stamp, rotate… | practically everything: edit, convert, OCR, sign… |
| AcroForm filling | ✅ | ✅ | ✅ |
| Dynamic XFA filling | ✅ syncs the XFA data packet | ✅ | |
| Encrypted read + fill | ✅ empty-password RC4/AES | ✅ (supply the password) | ✅ |
| Memory Usage | ✅ incremental + streamed | loads the document | loads the document |
| Drop into a shell pipeline | ✅ trivial | ✅ (heavy per call) | ❌ you write code |
| Read the whole source | ✅ ~5k lines of C | large Java project | ❌ closed source |
Why pick this one? If your job is “take these values, fill this form, hand me a PDF”, especially in a script, a build step, or a container where you’d rather not ship a JVM or pay for an SDK, then a 100 KB binary with no runtime and no install is hard to beat. It also handles the two things that quietly break other free tools: dynamic XFA forms (it updates the data packet Adobe actually renders, instead of leaving a stale one) and encrypted forms (read and refill, no password needed for the common empty-user-password case).
When to reach for the others instead: pdftk if you need general PDF surgery (merging, splitting, stamping, attachments) and a JVM is fine; Aspose.PDF if you need deep editing, format conversion, OCR, or signing inside a larger application and have the budget for a commercial library. This tool won’t do any of that, and that’s the point.
Why the license matters. Apache-2.0 is a permissive license: you can vendor ffpdf’s source into a closed-source or commercial product, modify it, statically link it, and ship it. The obligations are essentially attribution (keep the LICENSE/NOTICE files), and it includes an explicit patent grant from contributors, which corporate legal teams increasingly require. GPLv2 (pdftk) is copyleft: merely running the pdftk binary from your scripts is fine and imposes nothing, but the moment you vendor, modify, or link its code into something you distribute, your derivative must be GPLv2 too (a non-starter for most proprietary products), and GPLv2 predates explicit patent-grant language. In practice: if you’ll only ever shell out to a CLI, either license works; if there’s any chance you’ll embed the code in a product, an appliance, or a static binary you ship to customers, Apache-2.0 removes the legal friction entirely.
pdftk facts reflect the maintained pdftk-java fork; Aspose.PDF is a commercial product with a watermarked free trial. “Focused” is a feature here, not an oversight: fewer moving parts, nothing to install, nothing to audit but a small C tree.
Two environment variables cap decompression so a malicious “bomb” PDF can’t exhaust memory. The defaults are generous; you only need these to tighten things in an untrusted‑input setting.
| Variable | Default | Meaning |
|---|---|---|
PDF_MAX_DECOMPRESSED |
256 MB | Max size of any single decompressed stream |
PDF_MAX_TOTAL_DECOMPRESSED |
1 GB | Max total decompressed across one run |
Streams that would blow the budget are treated as corrupt and skipped, rather than allocating unbounded memory.
Requirements: gcc (C99) and zlib. That’s it. (The test suite additionally uses python3, and a couple of optional checks use the pikepdf and Pillow Python packages if present.)
make # build ./ffpdf
make test # fast unit tests (no PDFs needed)
make check # unit tests + end-to-end tests against real fixtures
make clean # remove build artifacts
make install # install binary + man page under PREFIX (default /usr/local)make install respects PREFIX and DESTDIR (e.g. make install PREFIX=~/.local) and installs the ffpdf(1) man page alongside the binary; make uninstall removes both.
The code is portable C99 + zlib with no OS-specific headers, so it builds on Linux, macOS, and Windows. CI builds and tests all three on every push.
- Linux:
gcc/clang+zlib1g-dev. Builds out of the box. - macOS:
clang(Xcode CLT) + the system zlib:makejust works. - Windows: build in an MSYS2 MinGW-w64 shell (
pacman -S mingw-w64-x86_64-gcc mingw-w64-x86_64-zlib make, thenmake STATIC=1), or cross-compile from Linux withmake CC=x86_64-w64-mingw32-gcc STATIC=1.STATIC=1produces a self-containedffpdf.exewith no DLL dependencies: drop it anywhere and run. Windows builds also force stdout to binary mode (sofilloutput isn't mangled) and accept Unicode file paths (accented names, OneDrive/CJK folders) via_wfopen. Prebuilt archives for all three platforms are attached to each release; each bundles the binary withLICENSE,NOTICE, this README, and the man page.
A note on zlib on Windows: there's no usable "native" zlib to link against; the Win32 Compression API speaks MS-specific formats (MSZIP/XPRESS/LZMS), not the zlib/DEFLATE stream PDF needs. Supply zlib via MSYS2/MinGW, vcpkg, or by vendoring zlib's source for a fully self-contained .exe.
The end‑to‑end tests in test_e2e.sh drive the real binary through the extract → fill round‑trip and exercise the tricky paths (encryption, LZW, predictors, oversized dicts, decompression‑bomb rejection).
The code is organised into focused modules:
| Module | Responsibility |
|---|---|
pdf_lex.{c,h} |
Low‑level PDF syntax: find keys, read strings/references, walk dictionary text |
pdf_parser.{c,h} |
Cross‑reference tables & streams, object parsing, predictors |
field_map.{c,h} |
The AcroForm field tree, object access, the lazy object‑stream cache |
crypto.{c,h} |
MD5/SHA‑2/RC4/AES primitives and the PDF standard security handler |
xfa.{c,h} |
XFA datasets sync (navigating the XML by field path) |
form_extractor.{c,h} |
The fdf-extract / xfdf-extract extractors |
pdf_filler.{c,h} |
The fill command: builds the incremental update |
utils.{c,h} |
Decompression (Flate/LZW), the dynamic xref table, shared helpers |
Two design choices shape everything:
- Incremental updates. Filling never edits existing bytes; it appends new object versions plus a fresh cross‑reference stream. Original offsets stay valid, and a failure part‑way through leaves no half‑written file.
- Lazy, streamed I/O. Object streams are decompressed only when an object inside them is actually needed (and cached), and the original file is streamed to output in chunks rather than held in memory.
The formats ffpdf implements are all publicly specified:
- PDF: ISO 32000‑1:2008 (Adobe’s free copy). Cross‑reference tables and streams, object streams, encryption, and the AcroForm field model all come from here.
- FDF / XFDF: the field‑data formats produced by
fdf-extract/xfdf-extract, defined in the PDF spec above (FDF) and Adobe’s XML Forms Data Format Specification (XFDF). - XFA: Adobe’s XFA Specification, for the datasets packet that dynamic (LiveCycle) forms render from.
- Empty‑password encryption only. Encrypted files that need an actual password aren’t supported (by design; this isn’t a cracking tool).
- Checkbox/radio filling targets the common “field == widget” case. Radio groups with separate kid widgets fall back to writing your value verbatim.
- The
xrefcommand is a debugging/inspection aid, not part of the fill workflow. - Output goes to stdout by default: remember the
> file.pdf, or use-o file.pdffor an atomic write.
The tool parses untrusted PDF/FDF input and often handles sensitive data, so it
is built with that in mind: no network access, no telemetry, no temp files in
its default operation (only the opt-in fill - and -o modes stage private,
self-cleaning temp files; see the datasheet), and
filled values are never logged by default (set FFPDF_VERBOSE=1 to
opt in). It is continuously fuzzed (ASan/UBSan, over the read and fill paths,
including encrypted inputs), adversarially audited, and compiled with hardening
(stack canaries, FORTIFY, PIE, RELRO).
- Report a vulnerability: see
SECURITY.md(privately to pb@0x00f.foo). - Deploying for PHI/PII (HIPAA-conscious workflows): see the Security & Privacy Datasheet.
If this tool is useful to you, consider sponsoring its development.
Copyright 2026 Page Brooks
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this software except in compliance with the License. You may obtain a copy in the LICENSE file or at https://www.apache.org/licenses/LICENSE-2.0.
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, without warranties or conditions of any kind, either express or implied. See the LICENSE for the specific language governing permissions and limitations.
ffpdf is a trademark of Page Brooks: the license covers the code, not the name or logo (see NOTICE). Contributions are accepted under the same license via the Developer Certificate of Origin; see CONTRIBUTING.md.
Built in C, with zlib as the only runtime dependency.




