diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs index e40cf7e73..90a1b0eed 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -182,9 +182,11 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: check_size(F, m.width as u64, m.height as u64, opts)?; let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= mw.saturating_mul(2) || m.height >= mh.saturating_mul(2))); // zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK, - // 12-bit and non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling). + // 12-bit, non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling) + // and every lossless (SOF3) file, which zune-jpeg refuses outright. + let lossless = m.sof == 0xC3; let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components; - let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved { + let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved || lossless { decode_jpeg_decoder(bytes, &m, scale_to)? } else { decode_zune(bytes, &m)? @@ -221,7 +223,9 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) use jpeg_decoder::PixelFormat; let mut d = jpeg_decoder::Decoder::new(bytes); d.read_info().map_err(|e| Error::Malformed(F, e.to_string()))?; - if let Some((mw, mh)) = scale_to { + // `scale` is the DCT size, not a lossless resample: on a lossless frame it would cut the + // output to the top-left of each plane. Those decode at full size; `finish` scales after. + if let Some((mw, mh)) = scale_to.filter(|_| m.sof != 0xC3) { // Ask for the smallest DCT scale still covering the target box. let (sw, sh) = scaled_request(m.width, m.height, mw, mh); d.scale(sw, sh).map_err(|e| Error::Malformed(F, e.to_string()))?; @@ -231,16 +235,25 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) let (w, h) = (info.width as usize, info.height as usize); let (model, buf, depth) = match info.pixel_format { PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8), - PixelFormat::L16 => { - let v = px.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - (Model::Gray, Buf::U16(v), 16) - } + PixelFormat::L16 => (Model::Gray, Buf::U16(lossless_samples(&px, m.precision)), 16), + // A lossless frame over 8 bits per sample still reports `RGB24` (or `CMYK32`), but its + // buffer holds two native-endian bytes per sample like L16. + PixelFormat::RGB24 if m.precision > 8 => (Model::Rgb, Buf::U16(lossless_samples(&px, m.precision)), 16), PixelFormat::RGB24 => (Model::Rgb, Buf::U8(px), 8), + PixelFormat::CMYK32 if m.precision > 8 => (Model::Cmyk, Buf::U16(lossless_samples(&px, m.precision)), 16), PixelFormat::CMYK32 => (Model::Cmyk, Buf::U8(px), 8), }; Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth }) } +/// jpeg-decoder's lossless samples: one native-endian `u16` per sample holding the raw +/// `precision`-bit value. Left-justify it into the 16-bit range the rest of the pipeline reads, +/// as every 16-bit decoder feeding [`Buf::U16`] does. +fn lossless_samples(px: &[u8], precision: u8) -> Vec { + let shift = 16u8.saturating_sub(precision); + px.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes([c[0], c[1]]) << shift).collect() +} + /// The first `0xFF` byte, eight bytes at a time (scan data has one every few hundred bytes: this /// is most of [`header`]'s time). fn find_ff(b: &[u8]) -> Option { @@ -450,4 +463,94 @@ mod tests { let m = parse_markers(&[0xFF, 0xD8, 0xFF, 0xE1, 0, 200, 1, 2]).unwrap(); assert!(m.exif.is_none()); } + + // --- lossless (SOF3) synthetic files: one DHT table with a single 1-bit category-0 code, so + // every pixel is a zero difference and decodes to the predictor's initial sample. No such + // encoder exists here; the bytes are the smallest a decoder accepts. + + fn lossless_header(out: &mut Vec, components: u8, w: u16, precision: u8) { + // DHT: one DC table (id 0) with a single 1-bit code for the category-0 symbol. + out.extend_from_slice(&[0xFF, 0xC4]); + out.extend_from_slice(&(2u16 + 1 + 16 + 1).to_be_bytes()); + out.push(0x00); + let mut counts = [0u8; 16]; + counts[0] = 1; + out.extend_from_slice(&counts); + out.push(0x00); + // SOF3 + out.extend_from_slice(&[0xFF, 0xC3]); + out.extend_from_slice(&(8u16 + 3 * components as u16).to_be_bytes()); + out.push(precision); + out.extend_from_slice(&1u16.to_be_bytes()); + out.extend_from_slice(&w.to_be_bytes()); + out.push(components); + for c in 1..=components { + out.extend_from_slice(&[c, 0x11, 0]); + } + // SOS: predictor Ra, Se = 0, Ah/Al = 0 + out.extend_from_slice(&[0xFF, 0xDA]); + out.extend_from_slice(&(6u16 + 2 * components as u16).to_be_bytes()); + out.push(components); + for c in 1..=components { + out.extend_from_slice(&[c, 0]); + } + out.extend_from_slice(&[1, 0, 0]); + } + + /// A minimal lossless JPEG, `w` x 1, `components` samples per pixel, every sample the initial + /// prediction `1 << (precision - 1)`. + fn lossless_jpeg(components: u8, w: u16, precision: u8) -> Vec { + let mut out = vec![0xFF, 0xD8]; + lossless_header(&mut out, components, w, precision); + // entropy: one zero bit per sample (a category-0 code), padded to whole bytes + out.extend_from_slice(&vec![0u8; (components as usize * w as usize).div_ceil(8)]); + out.extend_from_slice(&[0xFF, 0xD9]); + out + } + + /// jpeg-decoder emits lossless samples native-endian (its `to_ne_bytes`) holding the raw + /// `precision`-bit value, so the old `from_be_bytes` swapped every sample (2048 -> 8) and the + /// raw value was never left-justified into the 16-bit buffer. + #[test] + fn lossless_grayscale_samples_are_native_endian_and_left_justified() { + let bytes = lossless_jpeg(1, 8, 12); + let m = parse_markers(&bytes).unwrap(); + let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap(); + let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") }; + assert_eq!(v, &vec![2048u16 << 4; 8], "0x0800 native-endian, left-justified"); + assert_eq!((raw.model, raw.bit_depth), (Model::Gray, 16)); + } + + /// A lossless frame over 8 bits per sample reports `RGB24`, but its buffer is two bytes per + /// sample: it must become `Buf::U16`, not `Buf::U8` over double-length rows. + #[test] + fn lossless_high_precision_rgb_is_16_bit() { + let bytes = lossless_jpeg(3, 2, 12); + let m = parse_markers(&bytes).unwrap(); + let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap(); + let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") }; + assert_eq!(v, &vec![2048u16 << 4; 6]); + assert_eq!(raw.model, Model::Rgb); + } + + /// The same 16-bit wrapping for four components: `CMYK32` over 8 bits is two bytes a sample. + #[test] + fn lossless_high_precision_cmyk_is_16_bit() { + let bytes = lossless_jpeg(4, 2, 12); + let m = parse_markers(&bytes).unwrap(); + let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap(); + let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") }; + assert_eq!(v, &vec![2048u16 << 4; 8]); + assert_eq!((raw.model, raw.bit_depth), (Model::Cmyk, 16)); + } + + /// zune-jpeg refuses SOF3 ("Lossless Huffman"), so an 8-bit lossless file must route to + /// jpeg-decoder; `header` accepts it, so `decode` used to fail a file we listed as readable. + #[test] + fn eight_bit_lossless_jpeg_decodes() { + let bytes = lossless_jpeg(1, 8, 8); + let d = crate::decode(&bytes, DecodeOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (8, 1)); + assert_eq!(d.bit_depth, 8); + } }