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.