nigig-org/crates/apps/pdf/pdf-document/tests/jpeg_images.rs
andodeki 63ff45149a
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
feat(pdf): a real JPEG decoder — the old one was a stub returning black
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

294 lines
10 KiB
Rust

//! JPEG decode, downsampling and filter round-trip acceptance tests.
//!
//! The merge criteria from `REVIEWS/adr/0016-pdf-image-decode-surface.md`.
//!
//! The defect these exist for was the most convincing kind. `decode_jpeg_data`
//! took five underscore-prefixed arguments, ignored all of them, and returned
//! `Some(())`. Its caller allocated a zero-filled buffer and returned it as
//! decoded pixels, so **every JPEG in every PDF rendered as a black rectangle
//! at full alpha** with no error anywhere.
//!
//! A test asserting "decoding returned a buffer of the right length" would
//! have passed against that stub. So every test here asserts **pixel
//! values**.
use std::path::PathBuf;
use nigig_pdf_cos::filter::{decode_stream, encode_ascii_hex, encode_flate};
use nigig_pdf_cos::{PdfDict, PdfObj, PdfStream};
use nigig_pdf_document::PdfDocument;
use nigig_pdf_graphics::image::ImageInfo;
use nigig_pdf_graphics::jpeg::{self, JpegError};
fn corpus(relative: &str) -> Vec<u8> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../tests/corpus")
.join(relative);
std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display()))
}
/// Pull the raw `/DCTDecode` bytes of the first image XObject out of a
/// fixture, the way a renderer reaches them.
fn image_of(fixture: &str) -> ImageInfo {
let bytes = corpus(fixture);
let mut doc = PdfDocument::parse(&bytes).expect("fixture parses");
let page = doc.page(0).expect("page 0");
let entry = page.xobjects.get("Im1").expect("the image xobject");
let obj = doc.resolve_ref(entry.obj_ref).expect("resolves");
let stream = obj.as_stream().expect("images are streams").clone();
ImageInfo::from_dict(&stream.dict, "Im1", stream.data).expect("image info")
}
fn close(a: u8, b: u8, tolerance: i32) -> bool {
(a as i32 - b as i32).abs() <= tolerance
}
// ------------------------------------------------------------------ JPEG
/// The headline test: real pixel values, not a buffer length.
#[test]
fn a_red_jpeg_decodes_to_red_pixels() {
let image = image_of("images/jpeg_rgb.pdf");
assert!(image.is_jpeg());
let rgba = image.decode_to_rgba().expect("a baseline JPEG must decode");
assert_eq!(rgba.len(), 16 * 16 * 4);
// JPEG is lossy; 95-quality solid colour lands within a couple of levels.
assert!(
close(rgba[0], 255, 3) && close(rgba[1], 0, 3) && close(rgba[2], 0, 3),
"expected red, got ({}, {}, {}) - the stub returned all zeroes",
rgba[0],
rgba[1],
rgba[2]
);
assert_eq!(rgba[3], 255, "opaque");
}
/// The stub's exact signature: a correctly sized, uniformly zero buffer.
/// This is the test that would have caught it.
#[test]
fn a_decoded_jpeg_is_not_a_uniformly_zero_buffer() {
for fixture in [
"images/jpeg_rgb.pdf",
"images/jpeg_gray.pdf",
"images/jpeg_subsampled.pdf",
] {
let image = image_of(fixture);
let rgba = image.decode_to_rgba().expect("decodes");
let colour: Vec<u8> = rgba
.chunks(4)
.flat_map(|px| [px[0], px[1], px[2]])
.collect();
assert!(
colour.iter().any(|b| *b != 0),
"{fixture} decoded to all-zero colour, which is what the stub \
produced for every JPEG in every document"
);
}
}
#[test]
fn a_grayscale_jpeg_decodes_to_its_grey_level() {
let image = image_of("images/jpeg_gray.pdf");
let rgba = image.decode_to_rgba().expect("decodes");
assert!(close(rgba[0], 128, 3), "mid-grey expected, got {}", rgba[0]);
// Grayscale expands to equal RGB channels.
assert_eq!(rgba[0], rgba[1]);
assert_eq!(rgba[1], rgba[2]);
}
/// With 4:2:0 the chroma planes are half resolution, so the upsampling path
/// runs. A decoder that ignored sampling factors would produce a colour
/// shift here and pass the solid-red test.
#[test]
fn a_subsampled_jpeg_decodes_with_correct_colour() {
let image = image_of("images/jpeg_subsampled.pdf");
let rgba = image.decode_to_rgba().expect("decodes");
assert!(
close(rgba[0], 0, 6) && close(rgba[1], 128, 6) && close(rgba[2], 255, 6),
"expected (0,128,255), got ({}, {}, {})",
rgba[0],
rgba[1],
rgba[2]
);
}
/// ADR 0016 rule 2: progressive is refused by name, never approximated.
#[test]
fn a_progressive_jpeg_is_refused_by_name() {
let bytes = corpus("images/jpeg_progressive.pdf");
let mut doc = PdfDocument::parse(&bytes).expect("the document still opens");
let page = doc.page(0).expect("page 0");
let entry = page.xobjects.get("Im1").expect("image");
let obj = doc.resolve_ref(entry.obj_ref).expect("resolves");
let stream = obj.as_stream().expect("stream").clone();
match jpeg::decode(&stream.data) {
Err(JpegError::UnsupportedProfile { name, .. }) => {
assert_eq!(name, "progressive")
}
Ok(_) => panic!("progressive JPEG must not decode as if it were baseline"),
Err(other) => panic!("expected a named refusal, got {other:?}"),
}
}
/// A page whose image cannot be decoded must still be readable.
#[test]
fn a_page_with_an_undecodable_image_still_parses() {
let bytes = corpus("images/jpeg_progressive.pdf");
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let page = doc.page(0).expect("page 0");
assert!(
!page.content_data.is_empty(),
"one undecodable image must not cost the page its content"
);
}
#[test]
fn a_truncated_jpeg_is_an_error_not_partial_garbage() {
let image = image_of("images/jpeg_rgb.pdf");
let mut truncated = image.data.clone();
truncated.truncate(truncated.len() / 3);
assert!(
jpeg::decode(&truncated).is_err(),
"a truncated JPEG must be refused rather than half-decoded"
);
}
#[test]
fn the_dimensions_come_from_the_jpeg_itself() {
let image = image_of("images/jpeg_subsampled.pdf");
let decoded = jpeg::decode(&image.data).expect("decodes");
assert_eq!((decoded.width, decoded.height), (32, 32));
assert_eq!(decoded.components, 3);
}
// ----------------------------------------------------------- downsampling
#[test]
fn downsampling_averages_pixels_and_halves_dimensions() {
// A 4x2 grayscale ramp, downsampled by 2: each output pixel is the mean
// of a 2x2 block.
let mut dict = PdfDict::new();
dict.set("Width", PdfObj::Int(4));
dict.set("Height", PdfObj::Int(2));
dict.set("BitsPerComponent", PdfObj::Int(8));
dict.set("ColorSpace", PdfObj::Name("DeviceGray".into()));
let data = vec![0, 10, 100, 110, 0, 10, 100, 110];
let image = ImageInfo::from_dict(&dict, "Im", data).expect("image");
let small = image.downsample(2).expect("downsamples");
assert_eq!((small.width, small.height), (2, 1));
// Left block is (0+10+0+10)/4 = 5; right is (100+110+100+110)/4 = 105.
assert_eq!(small.data, vec![5, 105]);
}
#[test]
fn downsampling_by_one_is_the_identity() {
let image = image_of("images/jpeg_rgb.pdf");
let same = image.downsample(1).expect("identity");
assert_eq!(same.width, image.width);
assert_eq!(same.data, image.data);
}
#[test]
fn downsampling_by_zero_is_refused() {
let image = image_of("images/jpeg_rgb.pdf");
assert!(
image.downsample(0).is_none(),
"factor 0 would divide by zero; it must be refused"
);
}
/// Downsampling must not average bytes that are not pixels — a still
/// compressed stream is not raw samples.
#[test]
fn downsampling_refuses_data_that_is_not_raw_samples() {
let image = image_of("images/jpeg_rgb.pdf");
// `image.data` is still JPEG-compressed, far shorter than w*h*channels.
assert!(
image.downsample(2).is_none(),
"compressed data must not be box-filtered as though it were pixels"
);
}
// ------------------------------------------------------------ round trips
#[test]
fn flate_round_trips_adversarial_inputs() {
for case in [
Vec::new(),
vec![0u8],
vec![0u8; 1000],
vec![0xFFu8; 1000],
(0..=255u8).cycle().take(4096).collect(),
b"the quick brown fox".to_vec(),
] {
let encoded = encode_flate(&case);
let mut dict = PdfDict::new();
dict.set("Filter", PdfObj::Name("FlateDecode".into()));
let decoded = decode_stream(&PdfStream {
dict,
data: encoded,
})
.expect("flate round-trips");
assert_eq!(
decoded,
case,
"flate lost data for a {}-byte input",
case.len()
);
}
}
#[test]
fn ascii_hex_round_trips_adversarial_inputs() {
for case in [
Vec::new(),
vec![0u8],
vec![0xFFu8, 0x00, 0x7F],
(0..=255u8).collect(),
] {
let encoded = encode_ascii_hex(&case);
let mut dict = PdfDict::new();
dict.set("Filter", PdfObj::Name("ASCIIHexDecode".into()));
let decoded = decode_stream(&PdfStream {
dict,
data: encoded,
})
.expect("hex round-trips");
assert_eq!(
decoded,
case,
"hex lost data for a {}-byte input",
case.len()
);
}
}
#[test]
fn every_jpeg_fixture_parses_without_panicking() {
for name in [
"jpeg_rgb.pdf",
"jpeg_gray.pdf",
"jpeg_subsampled.pdf",
"jpeg_progressive.pdf",
] {
let bytes = corpus(&format!("images/{name}"));
let mut doc = PdfDocument::parse(&bytes)
.unwrap_or_else(|e| panic!("images/{name} should parse: {e}"));
let Ok(page) = doc.page(0) else { continue };
for entry in page.xobjects.values() {
if let Ok(obj) = doc.resolve_ref(entry.obj_ref) {
if let Some(stream) = obj.as_stream() {
if let Some(img) = ImageInfo::from_dict(&stream.dict, "x", stream.data.clone())
{
let _ = img.decode_to_rgba();
let _ = img.downsample(2);
}
}
}
}
}
}