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). Use when a product generates invoices, contracts, or regulatory filings whose layout must not shift, and a PDF template, font pack, or generation library is about to change.
79
99%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Medium
Suggest reviewing before use
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.
page.pdf() output).pdftocairo / pdfinfo) plus pdf2image and Pillow.dpi=150
(300 for regulatory filings).tests/pdf-baselines/.first_page /
last_page.UPDATE_PDF_BASELINES=1 and commit the new baseline images.# 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.
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, ...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.
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}"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/pageAdd 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.
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.
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.
main, run the suite once with UPDATE_PDF_BASELINES=1 to capture
tests/pdf-baselines/inv_001-1.png from the current template.pytest tests/pdf/.convert_from_path("invoice.pdf", dpi=150) renders page 1; pixel_diff
compares it to the baseline and returns 0.0182.diff_ratio < 0.005 fails with Page 1 diff ratio 0.0182,
flagging that the new font reflowed the line-item table.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.UPDATE_PDF_BASELINES=1.| 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) |
pdftocairo, pdftoppm, pdfinfo) - packaged
per-OS; consult system package docs for current versionhtml-to-pdf-regression -
sister skill for the HTML→PDF generation stepprint-stylesheet-tests -
sister skill for pre-PDF CSS verification