Open source · MIT · v0.5.0

Semantic diffs for generated PDFs. Any producer.

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.pdf

Document regressions are silent

Every 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.”

What it reports

Named events, not a similarity score. Each example below is verbatim output over the corpus fixtures.

  • page-count-changedpage count changed 2 → 3
  • heading-hierarchy-changedheading "3. Payment Terms" hierarchy changed H2 → H3
  • table-resizedtable grew 5×4 → 6×4 on page 1
  • table-movedtable moved from page 1 to page 2
  • element-moved-to-different-pagecell "Subtotal" moved from page 1 to page 2
  • element-movedtext "Delivery is expected within five busine…" moved 62pt on page 1
  • element-addedtext "Group 3" added on page 1
  • element-removedtext "Wile E. Coyote, 1 Desert Road, Tucson, …" removed from page 1

One change, one line

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:

✗ 1 error, 5 warnings · 6 causes, 146 events:
page-count-changed page count changed 2 → 3 (+4 repeated header/footer elements on the new page) [5 events]
table-resized table grew +15 rows, +81 cells, 23 repositioned (6×4 → 21×5), now spans pages 1–2 [121 events]
element-moved-to-different-page 14 elements shifted +1 page (1→2, 2→3) following the table's growth [14 events]
element-added text "$14350.00" → "$41158.00" [2 events]
element-added text "$1148.00" → "$3292.64" [2 events]
element-added text "$15498.00" → "$44450.64" [2 events]

A changed value

opt-in

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.

element-content-changed cell content changed "$250.00" → "$450.00" on page 1
element-content-changed cell content changed "$1250.00" → "$1450.00" on page 1

The producer corpus

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.

ProducerTestedWhat's reliableDocumented boundary
Puppeteer
v25.9.0
chrome page.pdf
4 docs × 2 variantsHeadings 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 variantsHeadings 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 variantsHeadings 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 variantsAuthoritative 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 →

Where Forme fits

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.

Same company: Forme is the PDF engine, and pdf-testkit is the open-source testing tool that works with any producer.

How to use it

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.

Vitest / Jest matcher

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

CLI

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)

GitHub Action (comment-only)

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 | any

pdf-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.

MIT licensed·v0.5.0·GitHub·npm·Findings