Catch PDF export regressions before your users download them. Compare every page, assert required text, ignore precise dynamic fields, and open a portable report that shows where an export changed.
Live example report · 中文说明 · Release downloads
An invoice can keep its first page intact while losing the amount on page 2. Printguard checks both pages. A generated date can change without approving a changed amount next to it: text masks follow matched glyph ink, rather than covering an entire text line.
Open Compare PDFs, choose a baseline PDF and a new export, then compare. You can check required phrases, inspect every page and download a standalone HTML report or JSON summary. Files are processed locally in the browser; their contents are not sent to a server.
The browser flow supports visual differences, physical page dimensions, added/deleted pages and required text. It uses native browser canvas; the Node API uses native canvas in its pinned environment, so pixel counts may differ between them. Precise dynamic text/region masks and automated export tests use the CLI or Playwright API below. The browser supports up to 20 MiB and 50 pages per file, 8 million pixels per page/comparison, and 64 million rendered pixels across the pair. Modern browser features are required; the flow is verified on desktop/mobile Chromium.
The live demo uses a four-page operations packet: budget charts, 20 invoice lines, a searchable English/Chinese appendix and a landscape delivery schedule. Five reports demonstrate missing invoice data, a passing date mask, an adjacent amount change, font/spacing reflow and a deleted page. Statuses and pixel counts come from the engine; node scripts/build-demo.mjs rebuilds the site from the shipped PDF fixtures.
Requires Node.js 22.13.0 or newer. Printguard is an ESM package. Version 0.2.0 is distributed through GitHub Releases; it is not published to the npm registry. npm install printguard is therefore not the installation command for this release.
npm install https://github.com/lsj0914/printguard/releases/download/v0.2.0/printguard-0.2.0.tgz
npx printguard demo --out ./printguard-demoOpen printguard-demo/report.html. The synthetic invoice intentionally loses its amount on page 2. The demo exits successfully after writing this intentional failure report.
The core API and CLI do not require Playwright. To use the matcher and reporter, install Playwright separately:
npm install --save-dev @playwright/testnpx printguard compare "exports/actual invoice.pdf" "baselines/invoice.pdf" \
--out ./artifacts/invoice --config ./printguard.config.json--report is an alias for --out. --out is required. Config files must be JSON objects; unknown options and invalid values fail instead of being ignored.
| Exit | Meaning |
|---|---|
0 |
The comparison passed. |
1 |
A visual or required-text check failed; inspect the report. |
2 |
Input, configuration or runtime error; no passing comparison is produced. |
Example configuration:
{
"requiredText": ["Invoice", "$120.00"],
"textMasks": [
{ "pattern": "\\d{4}-\\d{2}-\\d{2}", "label": "Generated date" }
],
"threshold": 0.1,
"maxDiffPixels": 0,
"maxDiffRatio": 0
}import { comparePdf, writeReport, PrintguardError } from "printguard";
try {
const result = await comparePdf(
"exports/invoice.pdf",
"baselines/invoice.pdf",
{
requiredText: ["Invoice", "$120.00"],
textMasks: [{ pattern: "\\d{4}-\\d{2}-\\d{2}", label: "Generated date" }],
},
);
const report = await writeReport(result, "artifacts/invoice");
console.log(result.passed, report.htmlPath);
} catch (error) {
if (error instanceof PrintguardError)
console.error(error.code, error.message);
else throw error;
}Both sources accept filesystem paths or Uint8Array/Buffer. Caller-owned bytes and source files are preserved. comparePdf(actual, baseline, options?) returns a ComparisonResult; writeReport(result, directory) returns { htmlPath, jsonPath }.
The result records renderer versions and settings, input SHA-256/byte/page summaries, every page's status (passed, changed, added, deleted), pixel counts/ratios, masks, text checks and warnings. In-memory page images are PNG byte arrays. result.json omits those arrays; report.html embeds them and works without external assets. Text checks include matching actual/baseline page context. Unmatched masks are reported as warnings.
| Option | Default | Meaning |
|---|---|---|
scale |
1.5 |
Rendered pixels per displayed PDF point. |
threshold |
0.1 |
Pixelmatch color-difference sensitivity, from 0 to 1. |
maxDiffPixels |
0 |
Maximum differing pixel count per page. |
maxDiffRatio |
0 |
Maximum differing pixel fraction per page, from 0 to 1. |
requiredText |
[] |
Literal strings required in the actual document's text layer. |
textMasks |
[] |
Regex rules with pattern, optional flags, page, label. |
regionMasks |
[] |
Explicit { page, x, y, width, height, label? } regions. |
maxBytes |
50 MiB |
Maximum input bytes per document. |
maxPages |
100 |
Maximum pages per document. |
maxPixelsPerPage |
16,000,000 |
Maximum rendered pixel area per page. |
Both tolerance limits must pass. To allow small differences, set both maxDiffPixels and maxDiffRatio, for example 20 and 0.0001. Setting only one leaves the other at zero. Added/deleted pages and changed displayed page dimensions always fail, even with permissive pixel tolerances. Images are never resized to conceal a mismatch.
Regex masks match each document independently and ignore only matched glyph ink, with one rendered pixel of padding. Supported flags are g, i, m, u; global matching is applied automatically. Pages are 1-based. Empty matches are rejected. Explicit regions use displayed PDF points with a top-left origin, after page rotation and CropBox; regions must remain inside the page. Regions intentionally ignore everything within their bounds, so choose them carefully.
import { test } from "@playwright/test";
import { expect } from "printguard/playwright";
test("invoice export", async ({ page }) => {
await page.goto("http://localhost:3000/invoices/1042");
const pending = page.waitForEvent("download");
await page.getByRole("button", { name: "Download invoice" }).click();
const path = await (await pending).path();
if (!path) throw new Error("Expected a local download path");
await expect(path).toMatchPdf("baselines/invoice.pdf", {
requiredText: ["$120.00"],
textMasks: [{ pattern: "\\d{4}-\\d{2}-\\d{2}", label: "Generated date" }],
});
});Each valid comparison writes unique HTML/JSON attachments under the current test's output directory, including repeated calls, workers and retries. Optional matcher reportDir chooses a parent directory; unique subdirectories are still created. .not.toMatchPdf(...) succeeds for a valid failing comparison. Corrupt input, bad config and unsupported masks still throw under .not. Use comparePdf outside an active test, such as in globalSetup.
Add the optional aggregate reporter alongside Playwright's existing reporter:
import { defineConfig } from "@playwright/test";
export default defineConfig({
reporter: [
["html", { open: "never" }],
["printguard/reporter", { outputDir: "artifacts/pdf-summary" }],
],
});Open artifacts/pdf-summary/index.html. The reporter copies attached evidence into that folder and records every attempt, including a failed first retry. Test outcome and PDF match status remain separate, so expected negative comparisons are understandable. Relative reporter output paths resolve against Playwright's configured root directory.
Text-mask matching is bounded to two seconds, two million input characters and 10,000 matches per page. Exceeding a bound throws LIMIT_EXCEEDED; it never produces a passing comparison.
- Generate and review baselines in the same pinned environment used by CI. PDF.js
6.4.299and canvas1.0.10use bundled fonts, CMaps, WASM and ICC resources; rendering needs no network access or system-font substitution. Embedded horizontal CJK fonts are covered by fixtures. Pixel identity across operating systems is not guaranteed. - Approve changed baselines deliberately: inspect the actual PDF and report, replace the reviewed baseline, then commit it. Printguard never creates or approves a baseline automatically.
- Reports can contain full document images and extracted assertion text. They stay local unless you publish or upload them yourself. The public examples use synthetic invoices and an operations packet.
- Required text uses Unicode NFKC and whitespace normalization. It checks text-layer presence, not independent visibility or semantic correctness. Masks never remove text from assertions. Image-only PDFs can be compared visually; there is no OCR.
- Text masks use PDF operator Unicode order, which may differ from human reading order in complex layouts. Partial multi-character glyphs, Type3, vertical writing, stroked/clipped/grouped text, missing/substituted glyph paths or bounds overlapping unmasked text fail with
UNSUPPORTED_MASK. When any non-whitespace glyph on a masked page has unknown bounds, text masking fails conservatively. Review and use an explicit region when appropriate. Ordinary visual comparison remains available for layouts that cannot be text-masked. - Password-protected, corrupt, empty and oversized PDFs produce errors. Password entry and encrypted-document comparison are outside v0.1. Encoded streams are validated before rendering, with a 100 MiB decoded-stream budget per input. The padded comparison canvas also obeys
maxPixelsPerPage. Limits are per input/page, not a total process-memory budget; use smaller limits for untrusted or large inputs.
git clone https://github.com/lsj0914/printguard.git
cd printguard
npm ci
npx playwright install chromium
npm run check
node scripts/package-smoke.mjs
npx playwright test --config examples/playwright.config.tsThe runnable download example serves synthetic invoices through a Playwright route. It proves date changes can pass while a missing page-2 amount is detected. Replace that route with your application's export endpoint. Configuration lives in examples/printguard.config.json.
Package smoke installs the actual tarball outside the checkout with production dependencies only, checks offline CLI/core and embedded CJK rendering, type-checks a consumer, then runs a separately installed Playwright consumer. To verify a downloaded release, use node scripts/package-smoke.mjs --tarball /absolute/path/printguard-0.2.0.tgz. Browser binaries must already be installed for the Playwright portion. Linux/macOS quality checks cover Node 22 and 24.
MIT licensed. Bug reports with a minimal synthetic or redacted PDF pair and exact renderer settings are especially useful.