# 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: ```rust ("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`, 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 - [x] CCITT G3 and G4 decode; registered in `SUPPORTED_FILTERS` - [x] JBIG2 generic region decodes on the image path - [x] JPEG 2000 decodes, both raw codestream and JP2 container - [x] Reversible 5/3 wavelet exact against OpenJPEG - [x] Multi-component transform (MCT) exact against OpenJPEG - [x] Fixtures generated by a reference encoder, not by us - [x] Truncated input does not yield a whole image - [x] Corrupt input never panics - [x] JBIG2 and JPX remain refused at the *generic filter* boundary, with a reason naming where they are decoded instead - [x] No C dependency added - [x] `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.