mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
test(render): pin that every JBIG2 conformance fixture decodes to the same picture
The render oracle put four JBIG2 fixtures at the top of its disagreement list against Poppler, and the obvious reading was that hayro-jbig2, which carries local patches, had a bug. Investigating it found the reverse. The 96 `bitmap-*.pdf` corpus files are a conformance suite: one small drawing encoded through every corner of JBIG2 -- each generic-region template, custom AT pixels, TPGDON and TPGRON, refinement, halftone with and without skip, symbol dictionaries with Huffman tables, MMR, the composition operators, random access, unknown-height segments. Those features change how the bytes are compressed, not what the picture is, so a correct decoder renders all 96 identically. That is a real test with no reference image and no external oracle: the suite checks itself. We render 1 distinct image across all 96. Poppler renders 8: it draws a blank page for the seven halftone-skip and symbol fixtures, and its output changes when TPGRON is switched on, which a correct decoder's cannot. So roughly a fifth of Poppler's JBIG2 output is wrong, the top of the oracle's worst list is its bug rather than ours, and Poppler is not a usable oracle for JBIG2. Watched failing by admitting one unrelated file to the set: the test fails and names it as the odd one out rather than merely reporting a count. The expectation holds of the pinned corpus. Whoever re-pins it to a newer pdf.js should re-check this rather than assume it still holds, which the test's own documentation says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5e08aa8719
commit
e0c63546bb
@@ -87,6 +87,11 @@ the number. `--hide-annotations` separates annotation differences from page cont
|
||||
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.
|
||||
|
||||
## Still to do
|
||||
|
||||
- Acrobat itself as the reference, rather than other open renderers.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Every JBIG2 conformance fixture in the corpus encodes the same picture.
|
||||
//!
|
||||
//! The 96 `bitmap-*.pdf` files are a conformance suite: one small drawing, encoded through every
|
||||
//! corner of JBIG2 — generic regions with each template and with custom AT pixels, typical
|
||||
//! prediction (TPGDON, TPGRON), refinement, halftone regions with and without skip, symbol
|
||||
//! dictionaries with Huffman tables, MMR, composition operators, random access, unknown-height
|
||||
//! segments. The features change how the bytes are compressed. They do not change the picture.
|
||||
//!
|
||||
//! So a correct decoder renders all 96 identically, and any file that comes out different is a
|
||||
//! decoder bug. That is a real test with no reference image and no external oracle: the suite
|
||||
//! checks itself.
|
||||
//!
|
||||
//! It is worth having because the property settled a question the render oracle raised and could
|
||||
//! not answer. `cargo xtask render-oracle` put four JBIG2 fixtures at the top of its disagreement
|
||||
//! list against Poppler, and the natural reading was that `hayro-jbig2`, which carries local
|
||||
//! patches, had a bug. The opposite is true. Measured over this suite: **we render 1 distinct
|
||||
//! image across all 96; Poppler renders 8**, drawing nothing at all for the seven halftone-skip
|
||||
//! and symbol fixtures and mis-decoding TPGRON refinement. An oracle is not ground truth. This
|
||||
//! invariant is.
|
||||
//!
|
||||
//! Runs over `corpus/pdfjs/test/pdfs` when present (`cargo xtask corpus` fetches and verifies it);
|
||||
//! a no-op when absent, so CI without the corpus stays green. The expectation that every fixture
|
||||
//! holds the same picture is a property of the **pinned** corpus; whoever re-pins it to a newer
|
||||
//! pdf.js should re-check this test rather than assume it still holds.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
|
||||
/// `corpus/pdfjs/test/pdfs`, or `None` when nothing has been fetched.
|
||||
///
|
||||
/// Panics when the checkout exists without the stamp `cargo xtask corpus` writes on a successful
|
||||
/// verification: an unverified corpus must fail loudly rather than skip, as in `pdfcraft-cos`'s
|
||||
/// corpus tests.
|
||||
fn corpus() -> Option<std::path::PathBuf> {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pdfjs");
|
||||
if !dir.is_dir() {
|
||||
return None;
|
||||
}
|
||||
assert!(
|
||||
dir.join(".pdfcraft-corpus.json").is_file(),
|
||||
"{} exists but has not been verified against the pin; run `cargo xtask corpus`",
|
||||
dir.display()
|
||||
);
|
||||
Some(dir.join("test/pdfs"))
|
||||
}
|
||||
|
||||
/// Page 1 as premultiplied RGBA at 1:1, or `None` when it could not be rendered.
|
||||
fn render(path: &std::path::Path) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig { reject_oversize: true, ..Default::default() });
|
||||
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
page.error.is_none().then_some((page.width, page.height, page.rgba.to_vec()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs corpus/; run with --ignored"]
|
||||
fn every_jbig2_conformance_fixture_decodes_to_the_same_picture() {
|
||||
let Some(dir) = corpus() else {
|
||||
eprintln!("corpus not fetched; skipping (cargo xtask corpus)");
|
||||
return;
|
||||
};
|
||||
let mut fixtures: Vec<std::path::PathBuf> = std::fs::read_dir(&dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "pdf") && p.file_stem().and_then(|s| s.to_str()).is_some_and(|s| s.starts_with("bitmap")))
|
||||
.collect();
|
||||
fixtures.sort();
|
||||
|
||||
// Group by what each one drew. One group means every feature decoded to the same picture.
|
||||
let mut groups: BTreeMap<(u32, u32, Vec<u8>), Vec<String>> = BTreeMap::new();
|
||||
let mut unrenderable = Vec::new();
|
||||
for path in &fixtures {
|
||||
let name = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
match render(path) {
|
||||
Some(image) => groups.entry(image).or_default().push(name),
|
||||
None => unrenderable.push(name),
|
||||
}
|
||||
}
|
||||
|
||||
assert!(fixtures.len() >= 90, "expected the JBIG2 conformance fixtures in the pinned corpus, found {}", fixtures.len());
|
||||
assert!(unrenderable.is_empty(), "every fixture must render: {unrenderable:?}");
|
||||
|
||||
if groups.len() > 1 {
|
||||
// Name the odd ones out: the largest group is what the picture should be, so anything
|
||||
// else is the bug, and saying which files they are is the whole value of the failure.
|
||||
let mut sized: Vec<(usize, &Vec<String>)> = groups.values().map(|names| (names.len(), names)).collect();
|
||||
sized.sort_by_key(|(n, _)| std::cmp::Reverse(*n));
|
||||
let report: Vec<String> = sized.iter().skip(1).map(|(n, names)| format!("{n} file(s): {names:?}")).collect();
|
||||
panic!(
|
||||
"JBIG2 coding features must not change the picture, but {} of {} fixtures decoded differently:\n {}",
|
||||
fixtures.len().saturating_sub(sized.first().map_or(0, |(n, _)| *n)),
|
||||
fixtures.len(),
|
||||
report.join("\n ")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,15 @@ update the numbers.
|
||||
112]` as a 112-unit band over the two lines of text, and the fixture is named "this text should
|
||||
be visible". Ours is the readable rendering. Anything at the top of the list needs a person to
|
||||
look at both images before it is treated as a bug of ours.
|
||||
- **The JBIG2 cluster was Poppler too, and the suite checks itself (2026-10-14).** The next four
|
||||
worst files were JBIG2, which looked like a defect in `hayro-jbig2` and its local patches. It is
|
||||
the reverse. The 96 `bitmap-*.pdf` fixtures are a conformance suite: one picture encoded through
|
||||
every corner of JBIG2, so a correct decoder renders all 96 identically — a real test needing no
|
||||
reference image and no oracle. **We render 1 distinct image across all 96; Poppler renders 8**,
|
||||
drawing a blank page for the seven halftone-skip and symbol fixtures and mis-decoding TPGRON
|
||||
refinement (its output changes when TPGRON is switched on, which a correct decoder's cannot).
|
||||
Pinned as `crates/render/tests/jbig2_invariants.rs`. **Poppler is not a usable oracle for JBIG2**,
|
||||
and roughly a fifth of its fixtures should be read as its bugs rather than ours.
|
||||
- **Annotations are shown, and that was measured rather than argued.** Poppler synthesises an
|
||||
appearance for an annotation that has none, where we draw only what the document carries (link
|
||||
borders without `/AP` are recorded as not covered under `view.render-annotation-appearances`), so
|
||||
|
||||
Reference in New Issue
Block a user