Skip to content

Repository files navigation

pdfkernel

Fast, dependency-light PDF forms and text for TypeScript. No WASM.

npm bundle size license

  • 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 /Info and 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.

Contents

Installation

bun add pdfkernel     # or npm / pnpm / yarn

Usage

Reading a document

import { PdfDocument } from "pdfkernel";

const doc = PdfDocument.open(bytes);

doc.pageCount;      // 2
doc.encrypted;      // false
doc.info.title;     // "2025 Form 1040"

Filling a form

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 false

Inspecting fields

for (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();

Working with pages

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);

Text and search

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 page

Search collapses whitespace by default, because PDFs break words across text-showing operators constantly and a literal match finds far less than you would expect.

Flattening

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.

Assembling documents

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 page

Copied pages keep their form fields: grafted widgets are re-rooted under the destination's /AcroForm, so a merged or split document is still fillable.

Metadata and navigation

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 }, ...]

Async loading

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] });

Permissions

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.

Untrusted input

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.

Escaping to the object model

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.

Performance

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.

Limitations

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 undecoded flag 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.

How it is tested

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.

Contributing

bun install
bun run build     # dist/ with .js and .d.ts
bun run check     # typecheck, lint, format
bun run test
bun run bench

The 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 corpora

bun 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).

License

MIT

About

Fast, dependency-light PDF forms and text for TypeScript. No WASM.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages