Commit graph

1 commit

Author SHA1 Message Date
63ff45149a feat(pdf): a real JPEG decoder — the old one was a stub returning black
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
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.
2026-08-16 18:33:56 +00:00