Files
lightcraft/crates/codecs/src/jpeg.rs
T
2026-10-14 22:32:07 -04:00

555 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! JPEG: marker parsing (EXIF/XMP/ICC/Adobe/MPF), full decode via `zune-jpeg`, DCT-scaled and
//! CMYK/YCCK decode via `jpeg-decoder`, embedded-preview thumbnails.
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::{DecodeOptions, Decoded, Error, Format, Result, Thumbnail, ThumbnailSource, exif};
const F: Format = Format::Jpeg;
/// What we learn from walking the marker segments before the first scan.
#[derive(Default, Debug)]
pub(crate) struct Markers {
pub width: u32,
pub height: u32,
pub components: u8,
pub precision: u8,
/// SOFn marker byte (0xC0 baseline, 0xC1 extended, 0xC2 progressive, …).
pub sof: u8,
/// Number of components in the first scan (< `components` for non-interleaved sequential files).
pub first_scan_components: u8,
pub exif: Option<Vec<u8>>,
/// Offset of the EXIF TIFF header within the file.
pub exif_offset: usize,
pub xmp: Option<String>,
pub icc: Option<Vec<u8>>,
pub adobe_transform: Option<u8>,
/// Offset of the MPF TIFF header within the file and its bytes.
pub mpf: Option<(usize, Vec<u8>)>,
/// The ISO 21496-1 APP2 payload after its URN: 4 bytes (versions only) on a primary image
/// that has a gain map, the full metadata on the gain map image itself.
pub iso_gainmap: Option<Vec<u8>>,
}
impl Markers {
/// This image is a gain map (ISO 21496-1 metadata or Adobe `hdrgm` gain parameters), not a
/// picture: never a preview, never the image to show.
pub fn is_gain_map(&self) -> bool {
self.iso_gainmap.as_ref().is_some_and(|p| p.len() > 4) || self.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:GainMapMax"))
}
}
pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
if !b.starts_with(&[0xFF, 0xD8]) {
return None;
}
let mut m = Markers::default();
let mut icc_chunks: Vec<(u8, &[u8])> = Vec::new();
let mut p = 2;
while p + 4 <= b.len() {
if b[p] != 0xFF {
// Tolerate garbage between segments: resync on the next 0xFF.
p += 1;
continue;
}
let marker = b[p + 1];
if marker == 0xFF {
p += 1;
continue;
}
if marker == 0xD8 || (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
p += 2;
continue;
}
if marker == 0xD9 {
break;
}
if marker == 0xDA {
m.first_scan_components = b.get(p + 4).copied().unwrap_or(0);
break;
}
let len = u16::from_be_bytes([b[p + 2], b[p + 3]]) as usize;
if len < 2 {
return None;
}
let start = p + 4;
let end = (p + 2 + len).min(b.len());
let seg = &b[start..end];
match marker {
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC && seg.len() >= 6 && m.components == 0 => {
m.precision = seg[0];
m.sof = marker;
m.height = u16::from_be_bytes([seg[1], seg[2]]) as u32;
m.width = u16::from_be_bytes([seg[3], seg[4]]) as u32;
m.components = seg[5];
}
0xE1 => {
if seg.starts_with(b"Exif\0\0") && m.exif.is_none() {
m.exif = Some(seg[6..].to_vec());
m.exif_offset = start + 6;
} else if let Some(x) = seg.strip_prefix(b"http://ns.adobe.com/xap/1.0/\0")
&& m.xmp.is_none()
{
m.xmp = Some(String::from_utf8_lossy(x).trim_end_matches('\0').to_string());
}
}
0xE2 => {
if seg.starts_with(b"ICC_PROFILE\0") && seg.len() >= 14 {
icc_chunks.push((seg[12], &seg[14..]));
} else if seg.starts_with(b"MPF\0") && m.mpf.is_none() {
m.mpf = Some((start + 4, seg[4..].to_vec()));
} else if let Some(p) = seg.strip_prefix(crate::gainmap::ISO_URN)
&& m.iso_gainmap.is_none()
{
m.iso_gainmap = Some(p.to_vec());
}
}
0xEE if seg.starts_with(b"Adobe") && seg.len() >= 12 => {
m.adobe_transform = Some(seg[11]);
}
_ => {}
}
p += 2 + len;
}
if !icc_chunks.is_empty() {
icc_chunks.sort_by_key(|c| c.0);
m.icc = Some(icc_chunks.iter().flat_map(|c| c.1.iter().copied()).collect());
}
Some(m)
}
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
decode_with_fallback(bytes, opts, None)
}
/// Stored dimensions and EXIF orientation from the markers, without decoding the scan data. The
/// file is refused, as a decode would refuse it, when it has no frame header, an unsupported frame
/// type or component count, or when its markers don't run to an end-of-image marker after the
/// image data (a truncated file). Damage *inside* the entropy-coded data can't be seen this way.
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
if m.components == 0 {
return Err(Error::Malformed(F, "no frame header".into()));
}
check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?;
if !matches!(m.sof, 0xC0..=0xC3) {
return Err(Error::Unsupported(F, "arithmetic-coded or hierarchical JPEG"));
}
if !matches!(m.components, 1 | 3 | 4) {
return Err(Error::Malformed(F, format!("{} colour components", m.components)));
}
if m.first_scan_components == 0 || !reaches_eoi(bytes) {
return Err(Error::Malformed(F, "truncated: the image data doesn't end with an end-of-image marker".into()));
}
let orientation = m.exif.as_deref().map(exif::summarize).unwrap_or_default().orientation.unwrap_or(1);
Ok((m.width, m.height, orientation))
}
/// Whether the markers after SOI, followed through every segment and scan, reach an EOI marker
/// (the first one: the primary image's, ahead of any MPF images appended to the file). Segments
/// are skipped by their length; in scan data `FF 00` (stuffing), `FF FF` (fill) and RSTn are not
/// markers that end it.
fn reaches_eoi(b: &[u8]) -> bool {
let mut p = 2usize;
loop {
// entropy-coded data (or garbage between segments, as `parse_markers` tolerates it)
let Some(skip) = b.get(p..).and_then(find_ff) else { return false };
p = p.saturating_add(skip);
let Some(&marker) = b.get(p.saturating_add(1)) else { return false };
p = match marker {
0xD9 => return true,
0xFF => p.saturating_add(1),
0x00 | 0x01 | 0xD0..=0xD8 => p.saturating_add(2),
_ => {
let Some(len) = b.get(p.saturating_add(2)..p.saturating_add(4)).map(|l| u16::from_be_bytes([l[0], l[1]]) as usize) else {
return false;
};
if len < 2 {
return false;
}
p.saturating_add(2).saturating_add(len)
}
};
}
}
pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option<crate::NamedSpace>) -> Result<Decoded> {
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
if m.components == 0 {
return Err(Error::Malformed(F, "no frame header".into()));
}
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, 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 || lossless {
decode_jpeg_decoder(bytes, &m, scale_to)?
} else {
decode_zune(bytes, &m)?
};
// A raw container may carry the colour declaration of an otherwise untagged preview.
// Apply it before linearization/resizing; existing JPEG metadata always wins.
let hint = fallback.filter(|_| m.icc.is_none() && m.exif.is_none()).map(|space| crate::SourceSpace::named(space, crate::SpaceOrigin::Container));
let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, hint, ..Default::default() };
finish(F, raw, meta, (m.width, m.height), opts)
}
fn decode_zune(bytes: &[u8], m: &Markers) -> Result<Raw> {
use zune_core::bytestream::ZCursor;
use zune_core::colorspace::ColorSpace;
use zune_core::options::DecoderOptions;
let gray = m.components == 1;
let opts = DecoderOptions::default().set_max_width(1 << 16).set_max_height(1 << 16).set_strict_mode(false).jpeg_set_out_colorspace(if gray {
ColorSpace::Luma
} else {
ColorSpace::RGB
});
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), opts);
let px = d.decode().map_err(|e| Error::Malformed(F, e.to_string()))?;
let info = d.info().ok_or_else(|| Error::Malformed(F, "no info".into()))?;
let (w, h) = (info.width as usize, info.height as usize);
let model = if gray { Model::Gray } else { Model::Rgb };
if px.len() < w * h * model.channels() {
return Err(Error::Malformed(F, "short output".into()));
}
Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf: Buf::U8(px), bit_depth: 8 })
}
fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) -> Result<Raw> {
use jpeg_decoder::PixelFormat;
let mut d = jpeg_decoder::Decoder::new(bytes);
d.read_info().map_err(|e| Error::Malformed(F, e.to_string()))?;
// `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()))?;
}
let px = d.decode().map_err(|e| Error::Malformed(F, e.to_string()))?;
let info = d.info().ok_or_else(|| Error::Malformed(F, "no info".into()))?;
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 => (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> {
const ONES: u64 = 0x0101_0101_0101_0101;
let (words, tail) = b.as_chunks::<8>();
for (i, w) in words.iter().enumerate() {
// a zero byte in !w is an 0xFF byte in w
let x = !u64::from_ne_bytes(*w);
if x.wrapping_sub(ONES) & !x & (ONES << 7) != 0 {
return w.iter().position(|&v| v == 0xFF).map(|j| i * 8 + j);
}
}
tail.iter().position(|&v| v == 0xFF).map(|j| words.len() * 8 + j)
}
/// The box to request from `jpeg-decoder::scale` so the decoded image still covers `mw × mh` once
/// fitted with the source aspect ratio.
fn scaled_request(w: u32, h: u32, mw: u32, mh: u32) -> (u16, u16) {
let s = (mw as f64 / w as f64).min(mh as f64 / h as f64).min(1.0);
let tw = ((w as f64 * s).ceil() as u32).clamp(1, u16::MAX as u32);
let th = ((h as f64 * s).ceil() as u32).clamp(1, u16::MAX as u32);
(tw as u16, th as u16)
}
/// Fast path: an embedded JPEG preview (EXIF IFD1 thumbnail, or the largest MPF preview) whose long
/// edge is ≥ `max_edge`, decoded with DCT scaling and fitted.
pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) -> Option<Thumbnail> {
let m = parse_markers(bytes)?;
let summary = m.exif.as_deref().map(exif::summarize).unwrap_or_default();
let orientation = summary.orientation.unwrap_or(1);
let mut candidates: Vec<(&[u8], ThumbnailSource)> = Vec::new();
if let (Some(ex), Some((o, l))) = (m.exif.as_deref(), summary.thumbnail) {
candidates.push((&ex[o..o + l], ThumbnailSource::ExifThumbnail));
}
for e in mpf_images(&m) {
if e.kind == MPF_GAIN_MAP {
continue;
}
if let Some(s) = bytes.get(e.offset..e.offset.saturating_add(e.len)) {
candidates.push((s, ThumbnailSource::MpfPreview));
}
}
// Smallest adequate preview wins.
let mut best: Option<(u32, &[u8], ThumbnailSource)> = None;
for (data, src) in candidates {
let Some(pm) = parse_markers(data) else { continue };
// a gain map (also when its MPF entry doesn't say so) is greyscale gain data, not a preview
if pm.is_gain_map() {
continue;
}
let long = pm.width.max(pm.height);
// Previews must have the main image's aspect ratio (within 2%), else they are letterboxed/cropped.
let aspect_ok = m.width > 0
&& m.height > 0
&& pm.height > 0
&& ((pm.width as f64 / pm.height as f64) / (m.width as f64 / m.height as f64) - 1.0).abs() < 0.02;
// Prefer the smallest preview covering `max_edge`; otherwise the largest one ≥ `min_edge`.
let better = match &best {
None => true,
Some(b) if b.0 >= max_edge => long >= max_edge && long < b.0,
Some(b) => long > b.0,
};
if long >= min_edge && aspect_ok && better {
best = Some((long, data, src));
}
}
let (_, data, source) = best?;
let d = decode(data, &DecodeOptions::fit(max_edge, max_edge)).ok()?;
Some(Thumbnail { image: d.to_srgb8(), orientation, source, source_width: m.width, source_height: m.height })
}
/// MP type code of a gain map image (CIPA DC-007 entry attribute, low 24 bits).
pub(crate) const MPF_GAIN_MAP: u32 = 0x05_0000;
/// An MPF-listed image other than the primary.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct MpfEntry {
/// File offset and length of the image.
pub offset: usize,
pub len: usize,
/// MP type code (attribute low 24 bits).
pub kind: u32,
}
/// The MPF-listed images other than the primary.
pub(crate) fn mpf_images(m: &Markers) -> Vec<MpfEntry> {
let Some((base, data)) = &m.mpf else { return vec![] };
let Some(t) = exif::Tiff::new(data) else { return vec![] };
let Some(ifd) = t.first_ifd() else { return vec![] };
let Some((entries, _)) = t.ifd(ifd) else { return vec![] };
let Some(e) = entries.iter().find(|e| e.tag == 0xB002) else { return vec![] };
let Some(list) = t.bytes(e) else { return vec![] };
let mut out = Vec::new();
for (i, rec) in list.as_chunks::<16>().0.iter().enumerate().take(16) {
if i == 0 {
continue;
}
let rd = |o: usize| {
let a = [rec[o], rec[o + 1], rec[o + 2], rec[o + 3]];
if data.starts_with(b"II") { u32::from_le_bytes(a) } else { u32::from_be_bytes(a) }
};
let (attr, size, off) = (rd(0), rd(4) as usize, rd(8) as usize);
if size > 0 && off > 0 {
out.push(MpfEntry { offset: base.saturating_add(off), len: size, kind: attr & 0x00FF_FFFF });
}
}
out
}
/// Decode to the file's 8-bit samples as stored (no colour management): 1 channel for greyscale
/// files, else 3 (RGB). For data images such as gain maps, whose values are codes, not colours.
pub(crate) fn decode_codes8(bytes: &[u8]) -> Result<(usize, usize, usize, Vec<u8>)> {
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
if m.components == 0 {
return Err(Error::Malformed(F, "no frame header".into()));
}
check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?;
if !matches!(m.components, 1 | 3) || m.precision != 8 {
return Err(Error::Malformed(F, "expected an 8-bit greyscale or RGB image".into()));
}
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
let raw = if non_interleaved { decode_jpeg_decoder(bytes, &m, None)? } else { decode_zune(bytes, &m)? };
let ch = raw.model.channels();
match raw.buf {
Buf::U8(v) if v.len() >= raw.width * raw.height * ch => Ok((raw.width, raw.height, ch, v)),
_ => Err(Error::Malformed(F, "unexpected sample format".into())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn container_fallback_matches_tagged_jpeg_before_resizing_and_keeps_own_metadata() {
use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, SpaceOrigin, encode_jpeg};
let pixels: Vec<u8> = (0..16 * 16).flat_map(|i| if i % 2 == 0 { [120, 180, 90] } else { [30, 100, 200] }).collect();
let encode = |icc, exif| {
encode_jpeg(
&EncodeImage::new(16, 16, 3, Samples::U8(&pixels)),
100,
ChromaSubsampling::S444,
&EncodeMeta { icc, exif, ..Default::default() },
)
.unwrap()
};
let untagged = encode(None, None);
let adobe = crate::icc::write_named(NamedSpace::AdobeRgb);
let tagged = encode(Some(&adobe), None);
for opts in [DecodeOptions::default(), DecodeOptions::fit(3, 3)] {
let with = crate::decode_jpeg_with_fallback(&untagged, opts, NamedSpace::AdobeRgb).unwrap();
let expected = crate::decode(&tagged, opts).unwrap();
assert_eq!(with.space.origin, SpaceOrigin::Container);
let (a, b) = (with.to_working(), expected.to_working());
for (p, q) in a.data.iter().zip(&b.data) {
assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() < 0.0001), "{p:?} != {q:?}");
}
}
let srgb = crate::icc::write_named(NamedSpace::Srgb);
let exif = crate::exif::minimal_exif(6);
for bytes in [encode(Some(&srgb), None), encode(None, Some(&exif)), encode(Some(b"bad ICC"), None)] {
let opts = DecodeOptions::fit(5, 5);
let with = crate::decode_jpeg_with_fallback(&bytes, opts, NamedSpace::AdobeRgb).unwrap();
let expected = crate::decode(&bytes, opts).unwrap();
assert_eq!(with.space, expected.space);
assert_eq!(with.image, expected.image);
assert_eq!(with.orientation, expected.orientation);
}
assert!(crate::decode_jpeg_with_fallback(b"bad JPEG", DecodeOptions::default(), NamedSpace::AdobeRgb).is_err());
}
#[test]
fn scaled_request_covers() {
assert_eq!(scaled_request(6000, 4000, 256, 256), (256, 171));
assert_eq!(scaled_request(100, 100, 256, 256), (100, 100));
}
#[test]
fn find_ff_matches_a_plain_search() {
let mut b: Vec<u8> = (0..300u32).map(|i| (i * 7 % 255) as u8).collect();
for at in [None, Some(0), Some(7), Some(8), Some(15), Some(203), Some(296), Some(299)] {
if let Some(i) = at {
b[i] = 0xFF;
}
for start in [0, 1, 5, 9, 290, 299, 300] {
let s = &b[start..];
assert_eq!(find_ff(s), s.iter().position(|&v| v == 0xFF), "{at:?} from {start}");
}
}
}
#[test]
fn unbounded_fit_box_decodes_at_full_size() {
// a full-size load asks for the largest box (`usize::MAX` saturated to `u32::MAX`): no DCT scaling,
// and no overflow working out whether to scale
use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
let pixels = vec![128u8; 32 * 24 * 3];
let jpeg = encode_jpeg(&EncodeImage::new(32, 24, 3, Samples::U8(&pixels)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
let d = crate::decode(&jpeg, DecodeOptions::fit(u32::MAX, u32::MAX)).unwrap();
assert_eq!((d.width, d.height), (32, 24));
}
#[test]
fn markers_on_garbage() {
assert!(parse_markers(b"").is_none());
assert!(parse_markers(&[0xFF, 0xD8, 0xFF, 0xE1, 0, 1]).is_none());
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);
}
}