Testland
Browse all skills & agents

pdf-snapshot-tester

Test PDF outputs by converting per-page to images (`pdftocairo` / pdf2image / Poppler) and running pixel-diff (pixelmatch / Resemble.js / Pillow `ImageChops`) against approved baselines. Per-page-range targeting, threshold tuning, font-substitution warnings, byte-stable PDF metadata stripping (CreationDate, /ID); references/ carry cross-engine HTML→PDF regression (Chromium `page.pdf()` / WeasyPrint / wkhtmltopdf per-engine baselines, engine-agreement tests, font-embedding checks, engine-version pinning). Use when a product generates invoices, contracts, or regulatory filings whose layout must not shift, when a PDF template, font pack, or generation library is about to change, or when swapping / upgrading the PDF engine.

Install with skills.sh (any agent)

npx skills add testland/qa --skill pdf-snapshot-tester
View source

pdf-snapshot-tester

PDFs are binary documents with embedded fonts, embedded images, and CreationDate/ID metadata. Direct binary diff is useless. The canonical approach: render per-page to image, then pixel-diff against approved baselines.

When to use

  • Invoice / contract / regulatory-filing PDFs where layout regression is unacceptable.
  • Pre-deploy gate before changing PDF generation library, font pack, or template.
  • Cross-engine verification (WeasyPrint output vs Chromium page.pdf() output) - the per-engine generation setup, agreement tests, and version pinning live in references/cross-engine-regression.md.

How to use

  1. Install Poppler (pdftocairo / pdfinfo) plus pdf2image and Pillow.
  2. Generate the PDF under test and render each page to PNG at dpi=150 (300 for regulatory filings).
  3. On the first run, save the rendered pages as approved baselines under tests/pdf-baselines/.
  4. On later runs, pixel-diff each page against its baseline and assert the diff ratio stays under threshold (~0.005).
  5. For long PDFs, target only the changed pages with first_page / last_page.
  6. Pin the production font pack in CI and normalize PDF metadata so baselines stay reproducible - see references/deterministic-rendering.md.
  7. After an intentional layout change, re-run with UPDATE_PDF_BASELINES=1 and commit the new baseline images.

Step 1 - Install Poppler + pdf2image

# Linux
apt-get install -y poppler-utils

# macOS
brew install poppler

# Python wrapper
pip install pdf2image pillow

Poppler ships pdftocairo + pdftoppm - the workhorses for PDF → image.

Step 2 - Render PDF pages to images

from pdf2image import convert_from_path
from pathlib import Path

pages = convert_from_path(
    "out.pdf",
    dpi=150,
    fmt="png",
    output_folder=str(Path("rendered")),
    paths_only=True,
)

dpi=150 balances diff sensitivity vs file size. Increase to 300 for high-stakes documents (regulatory filings).

CLI alternative:

pdftocairo -png -r 150 out.pdf rendered/page
# produces rendered/page-1.png, rendered/page-2.png, ...

Step 3 - Pixel-diff against baseline

from PIL import Image, ImageChops

def pixel_diff(actual_path, baseline_path, threshold=0.001):
    a = Image.open(actual_path).convert("RGB")
    b = Image.open(baseline_path).convert("RGB")
    if a.size != b.size:
        return 1.0  # full mismatch on dimension change

    diff = ImageChops.difference(a, b)
    bbox = diff.getbbox()
    if not bbox:
        return 0.0

    diff_pixels = sum(1 for px in diff.getdata() if any(c > 5 for c in px))
    total = a.size[0] * a.size[1]
    return diff_pixels / total

Or use pixelmatch (Node) for a maintained reference impl.

Step 4 - Per-page assertion

def test_invoice_pdf_matches_baseline(tmp_path):
    actual_pdf = tmp_path / "invoice.pdf"
    generate_invoice(invoice_id="inv_001", out=actual_pdf)

    pages = convert_from_path(actual_pdf, dpi=150)
    for i, page_img in enumerate(pages, start=1):
        actual = tmp_path / f"actual-{i}.png"
        page_img.save(actual, "PNG")
        baseline = Path(f"tests/pdf-baselines/inv_001-{i}.png")
        diff_ratio = pixel_diff(actual, baseline)
        assert diff_ratio < 0.005, f"Page {i} diff ratio {diff_ratio:.4f}"

Step 5 - Page-range targeting

For long PDFs (statements, prospectuses), test only changed pages:

pages = convert_from_path(
    "out.pdf",
    dpi=150,
    first_page=2,
    last_page=5,
)

CLI:

pdftocairo -png -r 150 -f 2 -l 5 out.pdf rendered/page

Step 6 - Update-baseline workflow

Add an opt-in update mode (analogous to Jest snapshots):

import os

def assert_pdf_matches(actual_pdf, baseline_dir, threshold=0.005):
    update = os.environ.get("UPDATE_PDF_BASELINES") == "1"
    pages = convert_from_path(actual_pdf, dpi=150)
    for i, page_img in enumerate(pages, start=1):
        baseline = baseline_dir / f"page-{i}.png"
        if update or not baseline.exists():
            page_img.save(baseline, "PNG")
            continue
        diff = pixel_diff_img(page_img, Image.open(baseline))
        assert diff < threshold, f"Page {i} diff {diff}"

Run UPDATE_PDF_BASELINES=1 pytest tests/pdf/ after intentional changes; commit the new baseline images.

Deterministic rendering

Non-deterministic PDF metadata (/CreationDate, /ID, /ModDate) and host font substitution both invalidate baselines. Normalize metadata with qpdf and detect missing fonts via Poppler stderr before diffing: references/deterministic-rendering.md.

Worked example

A billing service renders invoice.pdf from an HTML template; the team is about to swap the body font and needs proof no invoice layout shifts.

  1. On main, run the suite once with UPDATE_PDF_BASELINES=1 to capture tests/pdf-baselines/inv_001-1.png from the current template.
  2. Apply the font swap on a branch and re-run pytest tests/pdf/.
  3. convert_from_path("invoice.pdf", dpi=150) renders page 1; pixel_diff compares it to the baseline and returns 0.0182.
  4. The assertion diff_ratio < 0.005 fails with Page 1 diff ratio 0.0182, flagging that the new font reflowed the line-item table.
  5. pdfinfo -list-embedded-fonts invoice.pdf confirms the new font is embedded (no substitution), so the shift is a real layout change, not a host-font artifact.
  6. The team narrows column widths, re-runs until the diff ratio drops below 0.005, then refreshes the baseline with UPDATE_PDF_BASELINES=1.

Anti-patterns

Anti-patternWhy it failsFix
Binary diff PDFs directlyCreationDate / ID change per runRender to image (Step 2)
dpi=72 (default)Sub-pixel changes invisibledpi=150 minimum (Step 2)
Threshold = 0Anti-aliasing flakethreshold ≈ 0.005 (Step 4)
Skip font-pack pinning in CIOS upgrade swaps fonts; baselines invalidateCheck fonts into repo or pin OS image (see Deterministic rendering)
Snapshot every page of 500-page PDFCI time + storage explodesPage-range targeting (Step 5)

Limitations

  • Pixel-diff catches visual regressions but not semantic changes (text content swap with same layout). Pair with text-extraction tests if needed.
  • Baselines are large binary files; use Git LFS for repos with many PDF baselines.
  • Headless rendering may differ from production printer output; for print-critical work, sample real-printer output too.

References

  • Poppler utilities (pdftocairo, pdftoppm, pdfinfo) - packaged per-OS; consult system package docs for current version
  • pdf2image Python wrapper - github.com/Belval/pdf2image
  • pixelmatch (Node reference impl) - github.com/mapbox/pixelmatch
  • references/deterministic-rendering.md - metadata stripping + font-substitution detection
  • references/cross-engine-regression.md - cross-engine HTML→PDF generation, per-engine baselines, engine-agreement tests
  • print-stylesheet-tests - sister skill for pre-PDF CSS verification

Cross-engine HTML-to-PDF regression

View source (opens in new window)

Cross-engine HTML-to-PDF regression

Companion reference for pdf-snapshot-tester. Consult when migrating from one HTML→PDF engine to another (wkhtmltopdf → Chromium, wkhtmltopdf → WeasyPrint), when shared templates render through more than one engine, or after an engine version upgrade (Chromium revs change PDF output; WeasyPrint major versions break layout subtly).

Different engines produce different output for the same input - fonts embed differently, @page support varies, page-break algorithms differ. Tests verify the chosen engine produces the expected output AND (optionally) that two engines agree on the critical pages.

Set up the three engines

Chromium via Playwright:

npm install -D @playwright/test
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(loadInvoiceHTML('inv_001'));
const pdf = await page.pdf({
  format: 'A4',
  printBackground: true,
  preferCSSPageSize: true,
});
await writeFile('out/chromium.pdf', pdf);

WeasyPrint (per the WeasyPrint docs (opens in new window); requires Python 3.10+):

pip install weasyprint
from weasyprint import HTML
HTML(string=html_str, base_url="https://localhost:3000/").write_pdf("out/weasyprint.pdf")
# CLI: weasyprint invoice.html out/weasyprint.pdf

wkhtmltopdf (no longer actively maintained; verify suitability):

apt-get install -y wkhtmltopdf
wkhtmltopdf --page-size A4 \
  --margin-top 20mm --margin-right 20mm \
  --margin-bottom 20mm --margin-left 20mm \
  --enable-local-file-access \
  invoice.html out/wkhtmltopdf.pdf

Per-engine baseline assertion

Each engine gets its own baseline set - don't expect engines to be identical to each other. The pixel-diff mechanics are SKILL.md's job:

import pytest
from pathlib import Path

ENGINES = ["chromium", "weasyprint", "wkhtmltopdf"]

@pytest.mark.parametrize("engine", ENGINES)
def test_invoice_per_engine(engine, tmp_path):
    actual = generate_invoice(engine, "inv_001", tmp_path)
    baseline_dir = Path(f"tests/pdf-baselines/{engine}/inv_001")
    assert_pdf_matches(actual, baseline_dir, threshold=0.005)

Cross-engine agreement test (advisory)

For pages where layout MUST be identical across engines (regulatory filings, forms with strict positioning), compare extracted positions with tolerance - never pixel-perfect across engines:

def test_form_field_positions_agree_across_engines():
    chromium_fields = extract_form_fields(generate("chromium"))
    weasyprint_fields = extract_form_fields(generate("weasyprint"))

    for field_name, chrome_pos in chromium_fields.items():
        weasy_pos = weasyprint_fields[field_name]
        # Allow ~2mm tolerance
        assert abs(chrome_pos.x - weasy_pos.x) < 5
        assert abs(chrome_pos.y - weasy_pos.y) < 5

Font embedding verification

pdfinfo -list-embedded-fonts out/chromium.pdf
def test_required_fonts_embedded(engine):
    fonts = list_embedded_fonts(generate("invoice", engine))
    assert "InterVariable" in fonts or any("Inter" in f for f in fonts)
    # System fallbacks indicate a font miss
    assert "Times" not in fonts
    assert "Helvetica" not in fonts

CSS feature support matrix

Capture which @page features each engine handles for your templates (verify per current engine version - features evolve; per MDN Paged Media (opens in new window), "marks" / "bleeds" support is browser-limited):

FeatureChromiumWeasyPrintwkhtmltopdf
@page :first / :left / :rightpartialfullnone
running() headersnonefullnone
target-counter()nonefullnone
bleeds, marksnonepartialnone

Engine-version pinning in CI

Engine upgrades change output - pin in CI; bump intentionally with baseline updates in the same PR:

- name: Install WeasyPrint
  run: pip install weasyprint==68.1

- name: Install Playwright (with pinned Chromium)
  run: |
    npm install -D @playwright/test@1.50.0
    npx playwright install --with-deps chromium

Anti-patterns

Anti-patternWhy it failsFix
Same baseline for all enginesOutput differs per enginePer-engine baseline sets
Skip font-embedding checkOS-default fonts substitute silentlypdfinfo -list-embedded-fonts assertion
Test only the chosen engine during a migrationMigration sandbaggedPer-engine baselines for both engines
Auto-bump engine version in CIOutput silently shiftsPin versions
Compare engines pixel-perfectThey differ naturally; test always failsCross-engine = positions + counts with tolerance

Limitations

  • WeasyPrint is the most CSS-Paged-Media-complete engine; Chromium is the most modern-CSS-complete. They have non-overlapping strengths.
  • wkhtmltopdf uses an old WebKit fork (~2014); modern CSS features often unsupported.
  • Headless rendering may not match printer output for proofing; for print-critical work, sample a real printer pass.

References

Deterministic PDF rendering

View source (opens in new window)

Deterministic PDF rendering

Non-deterministic PDF metadata and host font substitution are the two environment factors that invalidate baselines. Normalize both before diffing, or rely on image diff (which is metadata-free by construction).

Strip non-deterministic PDF metadata

PDFs include /CreationDate, /ID, sometimes /ModDate. These change per run and break byte diffs. Use qpdf to normalize:

qpdf --linearize \
     --object-streams=disable \
     --replace-stream-data=uncompress \
     --remove-attachments \
     out.pdf normalized.pdf

Alternative: rely on image diff (render + pixel-diff) which is metadata-free by construction.

Font-substitution detection

Missing fonts on the rendering host produce visually-different output. Detect via Poppler stderr:

import subprocess

result = subprocess.run(
    ["pdfinfo", "-list-embedded-fonts", "out.pdf"],
    capture_output=True, text=True,
)
if "Font Substitution" in result.stderr:
    raise RuntimeError("Font substitution detected; baseline invalid")

For CI, install the production font pack via the package manager or check fonts into the repo for deterministic builds.