Ten PDF feature commits landed without an ADR, covering three whole phases.
Every feature gets one; these are the four that were owed. Written against
the code as it stands and re-verified by running it, not transcribed from
the commit messages.
0020 CCITT, JBIG2 and JPEG 2000 — the codecs ADR 0015 refused by name
0021 Phase 4 completion — stamping, reconciliation, CFF, cmap, and the
audit that corrected a false "complete" in ADR 0019
0022 Editing — content_edit, page_ops, flatten, catalog_edit
0023 Redaction and compaction, and the three reader defects they found
Verified rather than assumed, on the tree at f75c1cc:
ccitt 30, jbig2 29, jpx 44 unit tests; jpx_roundtrip's 7 compare against
OpenJPEG-generated pixels, exactly, because a JPEG 2000 bug produces a
plausible image rather than an error.
Redaction re-checked with a test written from outside the module. The
middle line is the one worth keeping:
after redact, secret present = true <- earlier revision
after compact, secret present = false
public text retained = true
Redaction alone leaves the secret in the bytes. That is why the two
shipped together, and why the report carries
earlier_revisions_retain_content.
Determinism re-checked by generating the same document from four separate
processes: byte-identical. The HashMap key-order defect really is fixed.
This ADR contains a correction to its own first draft. 0022 initially
listed text-run rewriting and annotation sub-types as deferred, on the
strength of a grep for "rewrite" finding nothing. Both are implemented —
the rewriter is ContentEditor::set_text, which preserves the operator kind
so a ' keeps its line advance and a TJ keeps its kerning; and "callout" and
"comment" are dart-pdf's names for a FreeText with a /CL and a Text
annotation, not distinct /Subtype values. Checking the plan's claim against
the code was right; concluding from a failed grep was not.
One criterion across the four is left unticked, and it is real: the ui.rs
interaction tests, blocked on the Makepad headless backend since Phase 1.
0021 also records a process note. ADR 0019's merge criteria were derived
from what had been built, so all of them ticked while three unbuilt items
stayed invisible. Criteria should come from the plan text first, then be
marked done or explicitly deferred — a deferral stated is fine, a deferral
unstated is a false claim.
pdf: 1187 passed. No code changed.
6.5 KiB
ADR 0020: CCITT, JBIG2 and JPEG 2000 — the three codecs Phase 3 refused
- Status: Accepted
- Date: 2026-08-17
- Review item:
NIGIG_PDF_FEATURE_PARITY_PLAN.md§1 Phase 3, "Lossy/ bitonal: CCITT G4 (and G3), JBIG2 (arith coding, huffman, refinement), JPX/JPEG2000" - Supersedes: the
REFUSED_CODECSdecision in ADR 0015, in part - Related: ADR 0015 (filters — refused these three by name), ADR 0016 (the JPEG decoder that was a stub)
- Written retrospectively. The work landed in
b26e6a1,81e846aand674b2bewithout an ADR; this documents it against the code as it stands, with every claim re-verified by running it.
Context
ADR 0015 refused CCITT, JBIG2 and JPX by name, with typed errors, and said so loudly rather than returning compressed bytes as though they were decoded. That was the right call at the time — ADR 0016 had just found a JPEG "decoder" that returned black rectangles at full alpha — but it left three codecs unimplemented, and between them they cover most scanned documents in existence. CCITT G4 is what a fax machine and almost every document scanner emits; JBIG2 is what a modern scanner emits instead; JPX turns up in archival and medical PDFs.
A viewer that refuses all three renders a blank page for a large fraction of real-world scanned files. The refusal was honest, but honest and useless.
Decision
Implement all three, as pure Rust in pdf-cos, with no C dependency. The
engine crates compile no C today and that property is worth keeping: it is
what makes the WASM and mobile targets viable and keeps the untrusted-input
surface inside the memory-safety guarantees of the language.
| Codec | File | LOC | Tests |
|---|---|---|---|
| CCITT G3/G4 | ccitt.rs |
1,320 | 30 |
| JBIG2 generic region | jbig2.rs |
1,282 | 29 |
| JPEG 2000 | jpx.rs |
2,110 | 44 |
Where each one is decoded, and why they differ
CCITTFaxDecode joins SUPPORTED_FILTERS and decodes through the generic
filter path. JBIG2Decode and JPXDecode stay in REFUSED_CODECS — but
the reason changed, and the reason is the whole point:
("JBIG2Decode",
"JBIG2 is decoded on the image path, where /Width and /Height are known"),
("JPXDecode",
"JPEG 2000 is decoded on the image path, where the codestream \
carries its own geometry"),
This is not a leftover. CCITT's parameters arrive in /DecodeParms, so the
filter layer has everything it needs. JBIG2 needs the image dictionary's
/Width and /Height, which the filter layer cannot see. JPX carries its
own geometry inside the codestream, which may disagree with the image
dictionary, and resolving that conflict is an image-layer decision.
Returning bytes from the generic path for either would repeat exactly the
DCTDecode defect ADR 0015 fixed: a caller receives a Vec<u8>, believes
it is decoded, and gets garbage. Refusing by name with an accurate reason is
correct here, and the reason string is what tells a maintainer it is
deliberate.
Fixtures generated by a reference implementation
The JPX corpus is generated by OpenJPEG, through Pillow, into
tests/corpus/jpx/ with expected pixels in expected.json. The generator's
own doc comment states the reasoning better than a summary would:
a JPEG 2000 bug does not raise an error, it produces a slightly soft or banded image that looks completely plausible, and only a reference encoder's pixels can tell you it happened.
Every fixture is lossless — reversible 5/3 wavelet, no quantisation — so the comparison is exact. An irreversible fixture would need a tolerance, and a tolerance is where a subtly wrong decoder hides. That is the same argument ADR 0016 reached the hard way when a uniform 64-level error in the IDCT took three attempts to find.
Verification
Re-run against the tree at f75c1cc, not taken from the commit messages:
cargo test -p nigig-pdf-cos --lib ccitt:: 30 passed
cargo test -p nigig-pdf-cos --lib jbig2:: 29 passed
cargo test -p nigig-pdf-cos --lib jpx:: 44 passed
cargo test -p nigig-pdf-cos --test jpx_roundtrip
a_lossless_grayscale_codestream_decodes_exactly ... ok
a_lossless_rgb_image_decodes_exactly ... ok
an_rgb_image_using_the_colour_transform_decodes_exactly ... ok
the_jp2_container_decodes_to_the_same_pixels_as_the_raw_codestream ... ok
a_larger_lossless_image_decodes_exactly ... ok
a_truncated_codestream_does_not_return_the_whole_image ... ok
a_corrupt_codestream_never_panics ... ok
"Decodes exactly" is against OpenJPEG's pixels, not ours.
Two of those seven are the ones that matter for untrusted input: a truncated codestream must not return a whole image (returning a plausible partial image is the silent-empty failure), and a corrupt one must not panic.
Merge criteria
- CCITT G3 and G4 decode; registered in
SUPPORTED_FILTERS - JBIG2 generic region decodes on the image path
- JPEG 2000 decodes, both raw codestream and JP2 container
- Reversible 5/3 wavelet exact against OpenJPEG
- Multi-component transform (MCT) exact against OpenJPEG
- Fixtures generated by a reference encoder, not by us
- Truncated input does not yield a whole image
- Corrupt input never panics
- JBIG2 and JPX remain refused at the generic filter boundary, with a reason naming where they are decoded instead
- No C dependency added
TEST_TARGET=pdfgreen
Consequences
Positive. Scanned documents render. Between CCITT and JBIG2 that is most scanner output of the last thirty years.
Negative. 4,712 lines of dense codec arithmetic to maintain, none of it exercised by the ordinary text-and-vector path. The JPX wavelet in particular is the kind of code where a sign error produces a plausible image, which is why the OpenJPEG fixtures are load-bearing rather than decorative: without them this code is untestable in practice.
Risk. Each decoder is a parser of hostile input in a security-sensitive position. They are pure Rust, so a bug is a wrong image or a panic rather than memory corruption, and the corpus includes truncated and corrupt cases for each. The fuzz targets should grow to cover all three; today the manifest declares 14 targets and the codec surface is newer than that list.
Not done, deliberately: JBIG2 refinement regions, symbol dictionaries and Huffman-coded segments (only the arithmetic-coded generic region is implemented); JPEG 2000 irreversible 9/7 wavelet at full precision, ROI coding, and progression orders beyond those the fixtures exercise. Each would need its own reference fixtures before it could be claimed.