codecs: decode lossless (SOF3) JPEGs correctly

`header` accepts a lossless frame but the decoder routing only sent
CMYK/cropped/12-bit/non-interleaved files to jpeg-decoder: an 8-bit SOF3
file went to zune-jpeg, which refuses lossless, so a listed file could not
open at all. The files that did reach jpeg-decoder were wrong too:
jpeg-decoder emits its lossless samples native-endian (its `to_ne_bytes`),
so `from_be_bytes` swapped every sample (a 12-bit 2048 decoded as 8), the
raw `precision`-bit value was never left-justified into the 16-bit range
`Buf::U16` feeds onward, and a 3- or 4-component frame over 8 bits still
reports `RGB24`/`CMYK32` while holding two bytes per sample, so it was
wrapped as 8-bit data over double-length rows. `scale()` (a DCT size, not
a lossless resample) also cut a lossless frame to the top-left when a
thumbnail box was requested.

Every SOF3 file now routes to jpeg-decoder; lossless frames skip `scale`
(`finish` scales after); lossless samples are read native-endian and
left-justified; high-precision RGB/CMYK become `Buf::U16`.

Four hand-built SOF3 regression tests (12-bit gray, 12-bit RGB, 12-bit
CMYK, and 8-bit through the public `decode`); all four fail before the fix
(byte-swapped samples, U8-wrapped high-precision buffers, and the zune
refusal) and pass after. The crate's other tests stay green.
This commit is contained in:
midasmatterhorn
2026-10-14 23:12:32 +03:00
parent 3b4279fc24
commit 66a202bbcf
+110 -7
View File
@@ -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<u16> {
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<usize> {
@@ -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<u8>, 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<u8> {
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);
}
}