nigig-org/REVIEWS/adr/0020-pdf-bitonal-and-wavelet-codecs.md
andodeki ad3fe19b90
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
docs(pdf): the four missing ADRs — codecs, Phase 4 completion, editing, redaction
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.
2026-08-18 05:53:36 +00:00

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_CODECS decision 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, 81e846a and 674b2be without 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=pdf green

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.