Gap 12 of docs/gaps.md: until rendering is measured, every claim about its quality is a judgement. Text extraction had an oracle and written output had qpdf; rendering had nothing. `cargo xtask render-oracle` renders page 1 of every corpus file with our CLI and with Poppler's pdftoppm and reports how far apart they are. The metric is the point. A raw pixel comparison is useless here -- two correct renderers disagree on every glyph edge, so it calls a perfect page badly wrong and says nothing about whether a shading is missing. `pixels::compare` counts a pixel as differing only when it differs from every pixel in the other image's 3x3 neighbourhood by more than a tolerance, in both directions, taking the worse. A one-pixel shift is forgiven; absent or miscoloured content is not. First reading, 250 files at 72 dpi, tolerance 8: 229 compared, median 0.89%, mean 2.16%, p90 5.35%, 25 within 0.1%. It reports rather than gates -- the corpus is not on CI, and a first reading of a borrowed renderer is a baseline to improve on, not a bar to clear. The worst results cluster on JBIG2 `bitmap-*` files and on ZapfDingbats, which aims the renderer work at something specific for the first time. 15 files could not be compared because the two renderers round the page box to different pixel sizes; they are reported as unmeasurable rather than scored. Verified against the knob's bounds end to end: tolerance 255 scores every file 0.00%, tolerance 0 takes the median to 0.95% and p90 to 26.36%. Also turns on `extract_type3_shared_xobjects_matches_poppler_pixels`, which had never run anywhere. Poppler is now installed on the Linux runner, and the test is version-robust by construction -- both sides of its comparison go through the same pdftoppm, so it is about our two documents rather than about Poppler's output. Verified in all three states: runs and passes with pdftoppm present, skips without it, fails without it when PDFCRAFT_ORACLES_REQUIRED is set. `run_capture` keeps its exact behaviour, including leaving the buffer empty on non-UTF-8 output; the byte-oriented capture an image needs is a second entry point on the same timeout logic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pdfcraft-oracle
The external programs PdfCraft checks its own output against, and one rule: a missing oracle is either a declared skip or a failure, never a silent pass.
- Layer: test support (
Class::Testkitinxtask/src/layers.rs). A dev-dependency only, never compiled into the product, and so usable even from the standalone crates (cos,geom,filters,crypt,arlington) whose own tests need it. - No panicking path of its own. The
or_skip!macro expands itsassert!into the caller, which is test code.
Why it exists
Every test that wanted an oracle used to probe for it by hand and decide on its own what to do when it was missing. The common choice — print a line and return — reports as a pass, so a suite with no oracles installed looked exactly like one in which every oracle agreed.
That was not hypothetical. No CI runner installed qpdf, so cos/tests/encryption.rs::qpdf_agrees
had never executed on a runner, while README.md said saved output was "independently checked with
qpdf". For R2–R4 that test is the only check of owner-password authentication (Algorithm 7) that
does not go through our own code.
API
// In a test: bind the oracle, or return after saying why it was skipped.
let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf());
let out = qpdf.command().arg("--check").arg(&path).output()?;
// Elsewhere (xtask), handle the absence yourself.
match pdfcraft_oracle::pdftotext() {
Ok(tool) => { /* tool.command() */ }
Err(absent) => bail!("{absent}"),
}
| Probe | Tool | Used for |
|---|---|---|
qpdf() |
qpdf | --check structural validation; the independent owner-password oracle |
pdftoppm() |
Poppler | page pixels, for comparing against our own raster output |
pdftotext() |
Poppler | text extraction (cargo xtask text-oracle scores word F1 against it) |
Each returns Result<Oracle, Absent>. Oracle::command() hands back a fresh Command;
Absent renders as an actionable sentence naming the install command.
The gate
PDFCRAFT_ORACLES_REQUIRED (any value, including 0 — the same convention as
CRAFT_FONTS_REQUIRED) turns absence from a skip into a failure. CI sets it on the runners that
install the tools, so a skip notice there becomes a red build.
$ cargo test -p pdfcraft-cos --test encryption qpdf_agrees
test result: ok. 1 passed # qpdf absent: skipped, with a notice on stderr
$ PDFCRAFT_ORACLES_REQUIRED=1 cargo test -p pdfcraft-cos --test encryption qpdf_agrees
oracle `qpdf` is required but is not on PATH (program not found): PDFCRAFT_ORACLES_REQUIRED is
set, so this is a failure rather than a skip (apt install qpdf / brew install qpdf)
test result: FAILED. 1 failed
or_skip! expands to a return, so it belongs in a test returning (); a test returning Result
should match on the probe directly.
Comparing pixels (pixels)
Rendering needs more than "did the tool run". A raw pixel-for-pixel difference is useless here: two correct renderers disagree on every glyph edge, so a strict comparison calls a perfect page badly wrong and tells you nothing about whether a shading is missing.
pixels::compare counts a pixel as differing only when it differs from every pixel in the
other image's 3x3 neighbourhood by more than a tolerance. A one-pixel shift or a differently
weighted edge finds a near match next door and is forgiven; a glyph that is not there, or a region
painted the wrong colour, has no near match and is counted. The test runs in both directions and
reports the worse result, so neither argument order flatters the blurrier renderer.
decode_pgm reads what pdftoppm -gray writes; decode_pam_gray reads what
pdfcraft-cli render --out x.pam writes, compositing premultiplied alpha over white first so a
transparent margin is not scored as black.
cargo xtask render-oracle drives it over the corpus. It reports; it does not gate.
Still to do
- Acrobat itself as the reference, rather than other open renderers.
- Oracles that are not external processes (hayro,
lopdf) are ordinary dev-dependencies and are not routed through this crate. - Two renderers sometimes round the same page box to different pixel sizes, and those files are counted as unmeasurable rather than compared.