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-testerpdf-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
How to use
Step 1 - Install Poppler + pdf2image
# Linux
apt-get install -y poppler-utils
# macOS
brew install poppler
# Python wrapper
pip install pdf2image pillowPoppler 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 / totalOr 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/pageStep 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.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Binary diff PDFs directly | CreationDate / ID change per run | Render to image (Step 2) |
dpi=72 (default) | Sub-pixel changes invisible | dpi=150 minimum (Step 2) |
| Threshold = 0 | Anti-aliasing flake | threshold ≈ 0.005 (Step 4) |
| Skip font-pack pinning in CI | OS upgrade swaps fonts; baselines invalidate | Check fonts into repo or pin OS image (see Deterministic rendering) |
| Snapshot every page of 500-page PDF | CI time + storage explodes | Page-range targeting (Step 5) |
Limitations
References
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/testconst 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 weasyprintfrom weasyprint import HTML
HTML(string=html_str, base_url="https://localhost:3000/").write_pdf("out/weasyprint.pdf")
# CLI: weasyprint invoice.html out/weasyprint.pdfwkhtmltopdf (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.pdfPer-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) < 5Font embedding verification
pdfinfo -list-embedded-fonts out/chromium.pdfdef 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 fontsCSS 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):
| Feature | Chromium | WeasyPrint | wkhtmltopdf |
|---|---|---|---|
@page :first / :left / :right | partial | full | none |
running() headers | none | full | none |
target-counter() | none | full | none |
bleeds, marks | none | partial | none |
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 chromiumAnti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Same baseline for all engines | Output differs per engine | Per-engine baseline sets |
| Skip font-embedding check | OS-default fonts substitute silently | pdfinfo -list-embedded-fonts assertion |
| Test only the chosen engine during a migration | Migration sandbagged | Per-engine baselines for both engines |
| Auto-bump engine version in CI | Output silently shifts | Pin versions |
| Compare engines pixel-perfect | They differ naturally; test always fails | Cross-engine = positions + counts with tolerance |
Limitations
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.pdfAlternative: 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.
Related skills
pdf-accessibility-checker
Test PDF accessibility (PDF/UA conformance) - tagged-PDF structure (StructTreeRoot), alternative text on images (Alt), reading-order, language metadata (Lang), document title, heading hierarchy. Use veraPDF / PAC (PDF Accessibility Checker) / pdfix / Adobe Acrobat Pro headless; map each finding back to WCAG 2.1 PDF Techniques (PDF1 - PDF23). Use when a product ships customer-facing PDFs into a context that mandates PDF/UA - US Section 508, EU Directive 2016/2102, or a public-sector tender - and each file must be proven tagged before release.
print-stylesheet-tests
Test CSS print-media output via Playwright `page.emulateMedia({ media: 'print' })` + `page.pdf()` - `@page` rule (size, margin, orphans, widows), `@page :first / :left / :right` pseudo-classes, `break-before/after/inside`, `@media print` selector activation, page-break suppression on headings. Use when an app exposes a Print button or a print stylesheet exists but is untested, and users report the printed or PDF copy breaking in the wrong places while the on-screen page looks fine.