Fast, dependency-light PDF forms and text for TypeScript. No WASM.
- Read and fill AcroForm fields, including checkboxes, radios and dropdowns
- Generate field appearances (
/DA, auto-size, alignment, comb, multiline) - Flatten forms into page content
- Extract and search page text
- Merge, split, copy, reorder and rotate pages
- Read and write
/Infoand XMP metadata, outlines, page labels, attachments - Open encrypted documents (R2 through R6: RC4, AESV2, AESV3)
- Incremental saves that append instead of rewriting
- Same build in Node and the browser
Runs on @noble/hashes, @noble/ciphers and fflate. Nothing else.
bun add pdfkernel # or npm / pnpm / yarnimport { PdfDocument } from "pdfkernel";
const doc = PdfDocument.open(bytes);
doc.pageCount; // 2
doc.encrypted; // false
doc.info.title; // "2025 Form 1040"const form = doc.form();
form.field("f1_01")?.setValue("Ada Lovelace");
form.field("c1_checkbox")?.setValue(true);
form.field("dropdown")?.setValue("Option B");
const { bytes } = doc.save();Saving appends. The original bytes are a byte-for-byte prefix of the output, so setting one value on a 780 KB document adds about 500 bytes rather than rewriting the file. Every previous revision stays intact, which matters for signed documents.
setValue reports what happened instead of throwing:
const r = form.field("f1_01")?.setValue("a name longer than the field allows");
r?.ok; // false if the field is read-only or the value is not a valid choice
r?.written; // what was stored, truncated to /MaxLen if necessary
r?.reason; // why, when ok is falsefor (const f of form.allFields()) {
f.name; // "topmostSubform[0].Page1[0].f1_01[0]"
f.kind; // "text" | "checkbox" | "radio" | "choice" | "signature" | "pushbutton"
f.value; // current /V, or null
f.options; // /Opt choices
f.maxLen; // 0 when unset
f.readOnly;
}
form.fieldNames();const page = doc.page(0);
page.size; // { width: 612, height: 792 }, after rotation
page.label; // "i", "ii", "1" ... per /PageLabels
page.widgets(); // widget annotations on this page
page.box("CropBox");
page.setRotation(90);page.text(); // whole page as a string
page.search("gross income"); // [{ page, box, quads, text }, ...]
page.glyphs(); // per-glyph boxes, for layout work
doc.search("income"); // every pageSearch collapses whitespace by default, because PDFs break words across text-showing operators constantly and a literal match finds far less than you would expect.
const { baked, dropped, skipped } = doc.flatten();
const { bytes } = doc.save();Appearances are drawn into the page content and the form is removed. Widgets that cannot be baked
(hidden, no rectangle, degenerate box) are left in /Annots and reported in skipped.
import { createDocument, addPage, copyPages, mergeDocuments, splitDocument } from "pdfkernel";
const merged = mergeDocuments([a, b]);
const [chapter1, chapter2] = splitDocument(doc, [[0, 4], [5, 9]]); // inclusive ranges
const dst = createDocument();
addPage(dst);
copyPages(src, dst, [0, 2, 4]); // omit indices to copy every pageCopied pages keep their form fields: grafted widgets are re-rooted under the destination's
/AcroForm, so a merged or split document is still fillable.
doc.info; // { title, author, subject, keywords, creator, producer, ... }
doc.setInfo({ title: "Filed 2025" });
doc.outline; // [{ title, page, children }, ...]
doc.pageLabels; // ["i", "ii", "1", "2", ...]
doc.attachments; // [{ name, description, data }, ...]open() is synchronous on every platform. load() decodes structural streams up front with the
platform's native DecompressionStream, which is faster in browsers, and every query afterwards
stays synchronous.
const doc = await PdfDocument.load(bytes, { pages: [0] });Documents can restrict filling, modification and copying via /P. pdfkernel enforces those flags, so
the restricted operation throws unless you opt out:
doc.page(0).text(); // PermissionDenied on a copy-restricted file
doc.page(0).text({ allowRestricted: true }); // your call
doc.permissions; // { print, modify, copy, annotate, fillForms, ... }These flags are not a security boundary, since anyone holding the file can strip them. They are enforced so that bypassing one is a decision rather than an accident.
Opening a PDF means parsing a hostile data structure. Both bounds are off by default:
PdfDocument.open(bytes, {
timeoutMs: 5_000, // bounds parsing, page walks and xref recovery
decodeBudget: 64 * 1024 * 1024, // total decompressed bytes for the document
});Streams that exceed the budget decode to empty and set doc.budgetExhausted rather than throwing,
so a bomb degrades instead of taking the process down.
The facade covers the common cases. For anything else, pdfkernel/lowlevel exposes the raw layer:
import { N, isDict, isStream } from "pdfkernel/lowlevel";
const cat = doc.dict(doc.trailer.get(N.Root));
const custom = doc.dict(cat?.get(N.AcroForm));Value types (PdfDict, PdfStream, PdfValue) are re-exported from the package root, so
signatures stay nameable without importing lowlevel.
Every facade method delegates to a free function that is also exported. doc.page(0).text() and
getPageText(doc, 0) are the same code.
Reproduce with bun run bench. Measured on f990.pdf, 778 KB with 1075 fields.
| pdfkernel | pdfium | mupdf | pdf-lib | |
|---|---|---|---|---|
| open + read all fields | 7.3 ms | 34.4 ms | 20.8 ms | 221.2 ms |
| fill 20 fields and save | 7.0 ms | n/a | 43.8 ms | 549.7 ms |
| flatten and save | 11.7 ms | n/a | n/a | 525.4 ms |
| extract text, one page | 2.6 ms | 4.8 ms | n/a | n/a |
| output size | 790 KB | n/a | 1842 KB | 1492 KB |
Two things are not like-for-like: the mupdf and pdf-lib read columns fetch field names only, and pdfium builds a richer text page than pdfkernel does, sorting into reading order and deriving boxes from glyph outlines.
Holding a document open is where the difference compounds. mupdf's fill() costs the same whether
you set one value or two hundred, because each call reopens and re-serialises the file:
| values set | mupdf | pdfkernel | appended |
|---|---|---|---|
| 1 | 6.50 ms | 0.060 ms | 0.5 KB |
| 10 | 6.20 ms | 0.116 ms | 4.3 KB |
| 200 | 6.57 ms | 0.848 ms | 76.0 KB |
Cold start in a fresh process is 36.8 ms against pdfium's 86.9 ms and mupdf's 67.9 ms, which is the number that matters for a CLI that handles one file and exits.
pdfkernel does not render. There is no rasteriser and no plan for one; if you need pixels, use pdfium or mupdf. It also does not:
- Decode image codecs. DCT, JPX, JBIG2, CCITTFax and Brotli streams come back as raw bytes with an
undecodedflag instead of throwing, so structure and text still work on files that use them. - Embed or subset fonts, or embed images. Standard-14 metrics only.
- Draw. There is no
drawText/drawRectangle/drawImage. - Read embedded font programs. Widths and Unicode come from the font dictionary, not the
TrueType/CFF
cmap, which is the main source of remaining text extraction error. - Handle annotations other than widgets with a typed API, though they survive a round-trip.
- Accept a user password. The handler assumes an empty one, which is the permissions-lock case that most published forms use. It reports when that assumption fails.
- Support predefined CJK CMaps or vertical writing. Named non-Identity encodings are treated as 2-byte identity and Identity-V lays out horizontally.
- Create digital signatures, though existing ones survive an incremental save.
bun run test runs in a bare checkout: 122 behaviour checks against PDFs generated in-process, no
fixture files and no oracle. test/fixtures.ts builds them; add a case there rather than committing
a binary.
The deeper gates check pdfkernel against pdfium and mupdf as oracles rather than against its own
expectations, and need a corpus under ../data. Field
reads are compared record by record (9418 widgets across 46 government and corporate forms, no
mismatches); written files are handed back to both engines to read; generated appearances are
rasterised by mupdf and compared as ink coverage. Text extraction agrees with pdfium on 99.80% of
characters.
Broader coverage comes from the pdf.js and pdfium regression corpora, 932 files that each exist because they broke a parser. pdfkernel opens 908 of them; pdfium opens 896. Between them they exercise every security handler revision from R2 to R6, classic and stream xrefs, object streams and the recovery path.
bun install
bun run build # dist/ with .js and .d.ts
bun run check # typecheck, lint, format
bun run test
bun run benchThe differential gates need the pdfium and mupdf baselines plus a corpus under ../data:
bun run gate:diff # field-for-field against pdfium
bun run gate:roundtrip # fill, save, re-read with pdfium and mupdf
bun run gate:text # extraction and window agreement
bun run gate:appearance # generated /AP against mupdf's rendering
bun run gate:wild <dir> # obscure and hostile corporabun run gate:spec needs none of that and is the one to run while working.
test/fetch-corpus.sh fetches the wild corpus. mupdf, pdfium and pdf-lib are devDependencies used
only as measurement baselines.
test/pdfium.ts is the pdfium oracle: raw FPDF_* bindings over the WASM module @hyzyla/pdfium
already ships, since its published wrapper covers only rendering. It deliberately imports nothing
from src/ — an oracle that shares code with the thing it judges proves nothing.
src/ is grouped by subsystem: core (bytes, objects, lexer, parser), codec (filters, crypto),
doc, text, form, write. Compound module names are kebab-case (page-labels.ts).
MIT