Files
pdfcraft/crates/oracle/README.md
Tanish Manku 50cbf5fa19 feat(oracle): score reading order, which word F1 cannot see
`cargo xtask text-oracle` reported one number: word F1 against `pdftotext`. F1 is a bag of
words. It catches a character read wrong, a glyph not read at all, a font we cannot map -- and
it is blind to order, so a page emitted back to front scores a perfect 1.000. Reading order is
most of what text extraction has left to get wrong: a column read into the middle of its
neighbour (#2387), a figure's labels threaded through a paragraph (#2586), a form's value parted
from its label.

The hole is not hypothetical. Reviewing #2431, a change that moves text and alters nothing else,
**word F1 called 26 of the 29 files whose output changed identical**, while scoring the word
sequence separated 18 improvements from 6 regressions and named the shape of the regressions
(forms: `prefilled_f1040.pdf` 0.672 -> 0.481, a checkbox's number parted from its label). The
harness could not see the only thing that change does.

So a second score per file: the longest common subsequence of the two word sequences, as a share
of the longer one. It shows up in the first three corpus files -- `Test-plusminus.pdf` scores F1
1.000 and reading order 0.875, the same forty words in a title block that we read down the
column and `pdftotext` reads across.

Both sides are cut to the first 3,000 words, because the comparison is quadratic and three pages
is plenty of signal; the run says how many files that bit, so the cap cannot quietly become the
measurement. It reports; it does not gate, and like the pixel score it is a *disagreement* with
Poppler rather than a verdict -- in the #2431 measurement the same split scored as a win on one
file and a loss on another purely because `pdftotext` makes it in one place and not the other.
The function's doc comment, `crates/oracle/README.md` and `docs/gaps.md` gap 12 all say so.

`words` now takes the sequence rather than re-splitting the text, so both scores read the same
tokens. `docs/file-format-parity.md` claimed plain-text export was good "(word-F1 0.98)" in a row
whose own column is reading order; it now says the two are scored separately.

Verified: `cargo xtask text-oracle --limit 60` end to end; `cargo test -p xtask` 49 passed; fmt
and clippy clean. The unit test was watched failing with the score blinded to order (sorting both
sides made it 1.000 where it must be below 0.7). The corpus-wide first reading is not in this
commit: the machine is shared with a parallel build and the full sweep was stopped twice for
memory.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 13:33:22 -07:00

126 lines
6.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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::Testkit` in `xtask/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 its `assert!` 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
```rust
// 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 and reading order 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.
A high score is a *disagreement*, not a verdict on us. The worst file of the first 250,
`bug1552113.pdf` at 84%, is Poppler painting a link's `/Border [0 0 112]` as a band over the text
the fixture exists to show; ours is the readable rendering. Look at both images before believing
the number. `--hide-annotations` separates annotation differences from page content, though the
default shows them: hiding is slightly worse overall, because it then scores Poppler for every
appearance we do draw.
**Poppler is not a usable oracle for JBIG2.** The 96 `bitmap-*.pdf` corpus fixtures encode one
picture through every corner of the format, so a correct decoder renders them all identically. We
render 1 distinct image across all 96; Poppler renders 8, drawing a blank page for seven of them.
`crates/render/tests/jbig2_invariants.rs` pins that invariant, and needs no oracle to do it.
## Comparing text
`cargo xtask text-oracle` scores two different things against `pdftotext`, because one number
hides the other.
**Word F1** is a bag of words: how much of what each side extracted the other also extracted,
with multiplicity. It catches a character read wrong, a glyph not read at all, a font we cannot
map. It is blind to *order* — a page emitted back to front scores 1.000.
**Reading order** is the longest common subsequence of the two word sequences, as a share of the
longer one. It catches the failures F1 cannot see: a column read into the middle of its
neighbour, a figure's labels threaded through a paragraph, a form's value parted from its label.
Both sides are cut to the first 3,000 words, because the comparison is quadratic and three pages
is plenty of signal; the run says how many files that bit.
The second score exists because of a measurement. Reviewing #2431, a change that moves text and
alters nothing else, word F1 called 26 of the 29 files whose output changed *identical*, while
the order score separated 18 improvements from 6 regressions — the regressions on forms, where
a checkbox's number was parted from its label.
Read it the same way as the pixel score, and then once more: Poppler's reading order is a second
opinion, not the truth, and the same split can score as a win on one file and a loss on another
purely because `pdftotext` happens to make it in one place and not the other.
## 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.