Completes Phase 3 of NIGIG_PDF_FEATURE_PARITY_PLAN.md. Design and merge
criteria in REVIEWS/adr/0016-pdf-image-decode-surface.md.
ADR 0015 refused DCTDecode at the generic filter boundary and left the
image path alone, noting JPEG "is decoded on the image path". That claim
did not hold:
fn decode_jpeg_data(_data, _pixels, _width, _height, _components)
-> Option<()> { Some(()) }
Every argument discarded. It wrote nothing and returned success. The caller
allocated a zero-filled buffer, passed it in, and returned it as decoded
pixels. Probing a real 8x8 JPEG through ImageInfo:
decode_to_rgba -> 256 bytes, first 12: [0,0,0,255, 0,0,0,255, 0,0,0,255]
Pure black at full alpha. Not an error, not None - a correctly sized,
entirely fabricated image. EVERY JPEG IN EVERY PDF rendered as a black
rectangle and nothing reported it. The underscore-prefixed parameters are
the tell: the signature was written to silence the unused warnings that
would otherwise have announced the stub. image.rs was at 14.2% line
coverage, the lowest in the crate.
Replaced with a real baseline decoder in pdf-graphics/src/jpeg.rs: huffman,
dequantisation, IDCT, chroma upsampling, YCbCr/YCCK conversion including
the Adobe APP14 transform flag. No new dependency - adding `image` or
`jpeg-decoder` would pull a tree into a crate that has one, on a target
the team is already fighting to cross-compile.
Progressive JPEG is refused BY NAME rather than approximated; a partial
implementation would reproduce exactly the defect being fixed.
decode_to_rgba's Option is why the stub survived - "could not decode" and
"decoded to nothing" were the same value. The decoder returns a typed
JpegError so a caller learns why an image is missing.
Also in this tranche, from the same plan bullets:
- ImageInfo::downsample, integer-factor box filter. Refuses factor 0, and
refuses data that is not raw samples rather than averaging compressed
bytes as though they were pixels.
- Round-trip tests for encode_flate and encode_ascii_hex over adversarial
inputs: empty, single byte, all-zero, all-0xFF, random binary.
THE IDCT TOOK THREE ATTEMPTS AND THE FAILURES WERE INFORMATIVE
The first version, adapted from a hand-tuned integer kernel, decoded
greyscale exactly (128 -> 128) while colour came out a UNIFORM 64 levels
off. A constant offset across every channel is a scaling-factor mistake,
not a coefficient one - guessing at coefficients would never have found
it. Two rounds of guess-and-check made it worse. The fix was to stop
guessing: derive ground truth from the float reference in T.81 A.3.3, then
transcribe the separable form directly with a documented fixed-point
scale. The cosine table is a const fn so it cannot drift from the formula
beside it, and tests assert against the reference rather than our output.
4 corpus fixtures with real JPEGs (Pillow at generate time only; the .pdf
files are committed so CI never needs it), 16 acceptance tests asserting
PIXEL VALUES rather than buffer lengths - a length assertion would have
passed against the stub. Mutation-checked: reinstating the zero buffer
fails four tests.
Coverage on image.rs 14.2% -> 32.9%, new jpeg.rs 82.8%, crate 83.65% ->
84.22%.
TEST_TARGET=pdf 651 -> 680, TEST_TARGET=pdf-ui 696 -> 725.
rustfmt and clippy -D warnings clean.
194 lines
8.3 KiB
Markdown
194 lines
8.3 KiB
Markdown
# ADR 0016: the image decode surface — a fake JPEG decoder, downsampling, round-trips
|
||
|
||
- **Status:** Accepted
|
||
- **Date:** 2026-08-03
|
||
- **Review item:** `NIGIG_PDF_FEATURE_PARITY_PLAN.md` §1 Phase 3, the bullets
|
||
ADR 0015 did not cover: "Image decode surface: PNG/JPEG image decoding for
|
||
raster image XObjects and inline images … image downsampling for render
|
||
performance", and the exit criterion's "round-trip encode tests where the
|
||
format allows"
|
||
- **Supersedes:** nothing
|
||
- **Related:** ADR 0015 (filters — refused the *generic* `DCTDecode` filter
|
||
and explicitly left the image path alone; this is that path)
|
||
|
||
## Context
|
||
|
||
ADR 0015 closed the lossless filters and refused the image codecs at the
|
||
generic filter boundary. It deliberately did **not** touch `image.rs`,
|
||
noting that JPEG "is decoded on the image path". That claim deserved
|
||
checking, and it does not hold.
|
||
|
||
### The JPEG decoder is a stub that reports success
|
||
|
||
```rust
|
||
fn decode_jpeg_data(
|
||
_data: &[u8],
|
||
_pixels: &mut [u8],
|
||
_width: u32,
|
||
_height: u32,
|
||
_num_components: u32,
|
||
) -> Option<()> {
|
||
Some(())
|
||
}
|
||
```
|
||
|
||
Every argument is discarded. It writes nothing into `pixels` and returns
|
||
`Some(())`, which the caller reads as success. `read_jpeg_info` allocates a
|
||
zero-filled buffer, hands it to this function, and returns it as decoded
|
||
pixels.
|
||
|
||
Probing an 8×8 baseline JPEG through the real `ImageInfo` path:
|
||
|
||
```
|
||
is_jpeg = true
|
||
decode_to_rgba -> 256 bytes, 64 non-zero
|
||
first 12: [0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255]
|
||
```
|
||
|
||
256 bytes of **pure black at full alpha**. Not an error, not a `None` — a
|
||
correctly sized, plausible-looking RGBA buffer that is entirely fabricated.
|
||
Every JPEG in every PDF renders as a black rectangle and nothing reports it.
|
||
|
||
This is the exact defect class this codebase has removed repeatedly, and it
|
||
is worse than the `DCTDecode` pass-through ADR 0015 fixed: that returned
|
||
obviously-wrong bytes, this returns convincingly-wrong ones. The `_`-prefixed
|
||
parameters are the tell — the signature was written to silence the unused
|
||
warnings that would otherwise have announced the stub.
|
||
|
||
The coverage numbers agree: `image.rs` is at **14.2% line coverage**, the
|
||
lowest in the crate, with 617 lines never executed by any test.
|
||
|
||
### Downsampling does not exist
|
||
|
||
The plan asks for it for render performance. Nothing in `image.rs` scales an
|
||
image; a 4000×3000 scan is decoded and held at full size to fill a 200-pixel
|
||
box.
|
||
|
||
### There are no round-trip tests
|
||
|
||
`encode_flate` and `encode_ascii_hex` exist, with no test that
|
||
`decode(encode(x)) == x` for any filter.
|
||
|
||
## Decision
|
||
|
||
### Implement a real baseline JPEG decoder
|
||
|
||
Baseline sequential DCT (SOF0), which is what PDF `DCTDecode` overwhelmingly
|
||
carries: huffman tables, dequantisation, inverse DCT, upsampling, and
|
||
YCbCr→RGB. Grayscale, YCbCr and CMYK/YCCK component counts, including the
|
||
Adobe APP14 transform flag that decides whether a 4-component image is CMYK
|
||
or YCCK.
|
||
|
||
**Progressive JPEG (SOF2) is refused by name**, not approximated. It is a
|
||
substantially different coder, and a partial implementation would reproduce
|
||
exactly the failure being fixed here.
|
||
|
||
No new dependency: this is a few hundred lines of well-specified arithmetic,
|
||
and adding `image` or `jpeg-decoder` would pull a dependency tree into a
|
||
crate that currently has one, on a target the team is already fighting to
|
||
cross-compile.
|
||
|
||
### `Option` is the wrong return type here, and it is why the stub survived
|
||
|
||
`decode_to_rgba` returns `Option<Vec<u8>>`, so "could not decode" and "decoded
|
||
to nothing" are the same value, and a stub returning `Some` is
|
||
indistinguishable from success. The image path gains a typed
|
||
`ImageDecodeError` so a caller can tell *why* an image is missing —
|
||
unsupported progressive JPEG, truncated data, unknown colour space — instead
|
||
of getting `None` or, worse, black pixels.
|
||
|
||
### Downsampling, exactly and only by integer factors
|
||
|
||
`ImageInfo::downsample(factor)` box-filters by an integer factor. Integer
|
||
only, deliberately: arbitrary rescaling is a resampling-quality question
|
||
belonging to the renderer, while the memory win — not holding a 12-megapixel
|
||
scan to fill a thumbnail — comes almost entirely from the integer case.
|
||
|
||
### Round-trip tests for the encoders that exist
|
||
|
||
`encode_flate` and `encode_ascii_hex` get `decode(encode(x)) == x` over
|
||
adversarial inputs: empty, single byte, all-zero, all-0xFF, and random
|
||
binary.
|
||
|
||
## Non-negotiable rules
|
||
|
||
1. **No decoder returns fabricated pixels.** If it cannot decode, it says so
|
||
with a typed error. A stub that returns `Some(())` is forbidden.
|
||
2. **Progressive JPEG is refused by name**, never approximated.
|
||
3. **No new external dependency** for image decoding.
|
||
4. **Decoded output is asserted against known pixel values**, not against
|
||
"a buffer of the right length" — the stub would have passed that.
|
||
5. **Downsampling is integer-factor only**, so no resampling policy is
|
||
smuggled into the parser.
|
||
|
||
## Merge criteria
|
||
|
||
- [x] A baseline JPEG decodes to its real pixel values, asserted against
|
||
known colours, not buffer length.
|
||
- [x] A grayscale JPEG decodes.
|
||
- [x] A JPEG with chroma subsampling (4:2:0) decodes.
|
||
- [x] Progressive JPEG returns a typed refusal naming the format.
|
||
- [x] A truncated JPEG returns a typed error rather than partial garbage.
|
||
- [x] The stub can no longer exist: a test fails if a decode returns a
|
||
uniformly zero buffer for a non-black image.
|
||
- [x] PNG decoding keeps working, with a colour-type test.
|
||
- [x] `downsample` reduces dimensions by an integer factor and averages
|
||
pixels.
|
||
- [x] `downsample(1)` is the identity; `downsample(0)` is refused.
|
||
- [x] `decode_flate` and `decode_ascii_hex` round-trip their encoders over
|
||
adversarial inputs.
|
||
- [x] Corpus fixtures: a real JPEG image XObject, a grayscale one, and a
|
||
progressive one.
|
||
- [x] `image.rs` line coverage rises materially from 14.2%: now **32.9%**,
|
||
with the new `jpeg.rs` at **82.8%** and the crate total 83.65% → 84.22%.
|
||
- [x] `TEST_TARGET=pdf ./tools/test-rust-clean.sh` passes, rustfmt and
|
||
clippy `-D warnings` clean.
|
||
|
||
All criteria met. `TEST_TARGET=pdf` went from 651 to 680 and
|
||
`TEST_TARGET=pdf-ui` from 696 to 725.
|
||
|
||
Mutation-checked: reinstating the stub — returning a correctly sized
|
||
zero-filled buffer — fails four tests, including
|
||
`a_decoded_jpeg_is_not_a_uniformly_zero_buffer`, which exists solely to
|
||
catch that shape.
|
||
|
||
### The IDCT took three attempts, and the failures were informative
|
||
|
||
The first version was adapted from a hand-tuned integer kernel and had a
|
||
scaling error. The symptom was diagnostic rather than random: **greyscale
|
||
decoded exactly (128 → 128) while colour came out a uniform 64 levels off**.
|
||
A constant offset across every channel is a scaling-factor mistake, not a
|
||
coefficient one, so guessing at coefficients would never have found it.
|
||
|
||
Two further rounds of guess-and-check made it worse. The fix was to stop
|
||
guessing: compute ground truth from the float reference in T.81 A.3.3 (a
|
||
DC-only block of 512 must give every sample 64, plus the 128 level shift),
|
||
then write the transform as a direct transcription of the separable form
|
||
with an explicitly documented fixed-point scale. The cosine table is a
|
||
`const fn` so it is computed once and cannot drift from the formula beside
|
||
it, and the tests assert the DC-only and all-zero cases against the
|
||
reference rather than against our own output.
|
||
|
||
Worth recording as a method note: when a numeric bug produces a *uniform*
|
||
error, the constant is the bug. Iterating on the algorithm is wasted effort
|
||
until the scale is derived rather than guessed.
|
||
|
||
## Consequences
|
||
|
||
**Positive.** JPEG images in PDFs actually render. Given JPEG is the
|
||
dominant image format in scanned and photographic PDFs, this is the
|
||
difference between a viewer that shows documents and one that shows black
|
||
rectangles where the content should be.
|
||
|
||
**Negative.** A few hundred lines of dense DCT arithmetic to maintain. Held
|
||
to the baseline profile precisely to bound that.
|
||
|
||
**Risk.** A subtly wrong IDCT or dequantisation produces an image that looks
|
||
*almost* right — harder to notice than black. Mitigated by asserting exact
|
||
pixel values on fixtures whose expected colours are known by construction,
|
||
and by a test that rejects a uniformly-zero decode.
|
||
|
||
**Out of scope, deliberately:** progressive JPEG, arithmetic-coded JPEG,
|
||
12-bit JPEG, JPEG encoding, ICC-aware colour conversion for JPEG (the
|
||
existing `colorspace.rs` handles that once pixels exist), and non-integer
|
||
downsampling.
|