Open source · MIT · v0.5.0
pdf-testkit extracts the structure of a PDF — pages, headings, tables, layout — and reports what changed between two renders: a table gained a column, a heading moved to page 3, a total changed.
It reads the PDF, not the renderer. The same structural diff runs on output from Puppeteer, react-pdf, PDFKit, and Forme — proven on a four-producer corpus that re-renders every one and asserts the result on every commit. Forme is the reason the tool exists and one producer in the tested set, not a prerequisite.
# Vitest matcher (or @pdf-testkit/jest); pdfjs-dist reads non-Forme PDFs
npm i -D @pdf-testkit/vitest pdfjs-dist
# or a one-off structural diff of any two PDFs, from any producer
npx pdf-testkit diff old.pdf new.pdfEvery functional test passes. The data is right. Then a customer opens an invoice whose line-item table split across a page break, or a contract whose section heading quietly dropped a level. Nothing asserted on layout, so nothing was caught.
The usual answer is to rasterize each page and compare pixels. It catches everything and explains nothing: anti-aliasing and font hinting differ across platforms and CI images, so the diffs flake — and a single aggregate pixel score hides structural loss, because a dropped table header is a rounding error in pixels.
pdf-testkit compares structure instead. It pairs elements across two renders and emits named events, so a shifted table reads as table moved from page 1 to page 2 — not “0.4% of pixels changed.”
Named events, not a similarity score. Each example below is verbatim output over the corpus fixtures.
A single edit fans out. Adding 15 rows to the invoice table produces 146 individual events — every one accurate, the list useless. pdf-testkit collapses causally-related events for humans while the machine-readable output keeps the full resolution. Those 146 become 6 lines:
By default pdf-testkit diffs structure, not values — a cell that keeps its slot but changes its number fires nothing. That crosses the structure-not-content line the tool is built on, so catching it is opt-in: contentChanges (matcher) or --content (CLI), at warning severity.
Four producers, four documents each — invoice, contract, statement, compact — rendered and committed as fixtures. Extraction and diffing are asserted on every commit; re-rendered weekly at each producer's latest version, so an upstream bump that changes what extraction sees is a reviewable diff, not silent drift.
| Producer | Tested | What's reliable | Documented boundary |
|---|---|---|---|
| Puppeteer v25.9.0 chrome page.pdf | 4 docs × 2 variants | Headings and tables detected across all four documents. | Full-width section rows can split one table into several nodes (F3); the long invoice over-fires one heading (F5). |
| react-pdf v4.8.1 renderToBuffer | 4 docs × 2 variants | Headings exact (3 / 6 / 1 on invoice / contract / compact); tables detected. | Section rows fragment one table into two (F3). |
| PDFKit v0.20.1 imperative text / lines | 4 docs × 2 variants | Headings reliable. | Tables under-detected: imperatively-positioned text has none of the structure the detector keys on, so an ~11-row invoice reads as ~3 rows (F4). The honest worst case for the pdfjs path. |
| Forme v0.22.0 renderDocumentWithLayout | 4 docs × 2 variants | Authoritative structure via a layout sidecar, at confidence 1.0. | Via pdfjs (sidecar off) it shows the same table under-detection, and splits the title into two H1 runs (F7) — which is exactly why it ships the sidecar. |
Heading and table inference on the pdfjs path is heuristic (confidence < 1); on structureless output like PDFKit's it under-detects, by design. The corpus pins those numbers so a regression — or an improvement — shows up as a reviewed change, never a silent one. Every boundary above has a minimal repro in the fixtures. Read the findings →
On output from any producer, pdf-testkit extracts structure with pdfjs — heuristic, confidence below 1. On Forme output it can instead read a layout sidecar: roles, headings, and tables are what the renderer knew, at confidence 1.0 — no inferred tables, no false heading levels.
Concretely: on the invoice, the pdfjs path reads 4 headings — it splits the title into two runs — while the sidecar reads the correct 3. That is a genuine difference, and it is here as what changes if you happen to use Forme, not as a reason to.
A matcher in the test suite you already run, a CLI, or a PR comment. Baselines are JSON snapshot files you commit — no hosted storage required.
First run writes the baseline into __pdf_snapshots__/; later runs diff against it. Accept intended changes with -u.
import '@pdf-testkit/vitest'; // or '@pdf-testkit/jest'
test('invoice layout is unchanged', async () => {
// Puppeteer, react-pdf, PDFKit, Forme — pdf-testkit reads the PDF, not the renderer
const pdf = await renderInvoice();
// first run writes __pdf_snapshots__/…json; later runs diff against it
await expect(pdf).toMatchPDFSnapshot();
});Exit code is the contract: 0 clean, 1 regression. Diff two PDFs or two committed snapshots.
pdf-testkit diff baseline.pdf current.pdf --fail-on warn # exit 0 = clean, 1 = regression
pdf-testkit diff baseline.pdf current.pdf --content # also flag text edits (wrong totals)Posts and updates one PR comment with the semantic diff and gates the check on fail-on. It stores nothing — the baseline is a file in your repo.
# .github/workflows/pdf-diff.yml — one PR comment with the semantic diff
permissions:
contents: read
pull-requests: write # the Action posts a PR comment
jobs:
pdf-diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx pdf-testkit snapshot dist/invoice.pdf --out current.json
- uses: danmolitor/pdf-testkit/packages/action@v0.3.0
with:
baseline: baselines/invoice.json # committed to your repo
current: current.json
fail-on: error # error | warn | anypdf-testkit checks structure, not content correctness. A passing diff means the structure is unchanged — not that the document is correct. It tells you a table moved, a heading dropped a level, or text overflowed; assert values themselves with ordinary assertions.