Files
lightcraft/crates/codecs/src/jpeg.rs
T
Claude f6f5e6f1b7 Part of #367: probes read image headers instead of decoding the pixels
The import probe of a non-raw image decoded it (at a 64 px fit, which for
JPEG still entropy-decodes every block and for PNG inflates everything)
only to learn its stored size and orientation: ~95 % of import CPU.

lightcraft-codecs gets `read_header(bytes) -> Header { format, width,
height, orientation }`: JPEG from its markers (EXIF orientation), PNG from
its chunks (IHDR, eXIf anywhere), TIFF from the first IFD (tiff crate,
no strips read), WebP from its RIFF chunks / bitstream header (image-webp),
PSD from its header and image resources. GIF, BMP and JPEG XL keep the
small decode. `files::probe_bytes` uses it; raw handling is unchanged.
The TIFF colour-layout check is shared with the decoder (`layout`).

Measured (release, 4 cores, throwaway benchmark, per file, bytes in
memory): 200 generated 4000x3000 JPEGs (3.6 MB): 72.7 ms -> 2.1 ms
(read_header alone 1.05 ms; the rest is the content hash and metadata);
20 PNGs (30.8 MB): 236-265 ms -> 9.0-9.6 ms.

Corrupt files (behaviour note): the header readers also refuse what a
decode refuses before the pixels (unsupported layout, compression, frame
type, bad IHDR, ...) and truncated files: a JPEG whose markers don't reach
its EOI, a PNG cut before the chunk after its IDAT run, TIFF strips/tiles
that start past the end, a WebP image chunk or PSD merged data past the
end. So truncated files (interrupted copies, partial downloads) are still
marked `error` in the import review, as before; tests check that whenever
the old decode accepted a (cut or byte-mutated) file the header agrees.
Changed: damage *inside* compressed data that is all present (flipped or
garbled bytes) was refused at scan and is now imported, and shows as
unreadable ("!" in the grid) when rendered. Also: tiny JPEGs (< 128 px)
were decoded leniently by the old probe, so a truncated one imported; it
is now refused. A TIFF truncated inside its last strip is not detected
(StripByteCounts are often overstated, so only strip starts are checked).

Tests: header readers vs decode on generated JPEG/PNG/TIFF/WebP/PSD/BMP
with and without EXIF/TIFF orientation; truncations and random mutations
agree with decode; hostile headers (zero/absurd sizes, bad lengths,
truncation, garbage) are errors; read_header added to the fuzz tests;
probe_bytes oriented dims match the decode-based values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 22:09:30 +00:00

388 lines
17 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>)>,
}
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 => {
if 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()));
}
}
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 >= 2 * mw || m.height >= 2 * mh));
// 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).
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 {
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()))?;
if let Some((mw, mh)) = scale_to {
// 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 => {
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::RGB24 => (Model::Rgb, Buf::U8(px), 8),
PixelFormat::CMYK32 => (Model::Cmyk, Buf::U8(px), 8),
};
Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth })
}
/// 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 (o, l) in mpf_images(&m) {
if let Some(s) = bytes.get(o..o.saturating_add(l)) {
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 };
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 })
}
/// (file offset, length) of MPF-listed images other than the primary.
fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
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 (size, off) = (rd(4) as usize, rd(8) as usize);
if size > 0 && off > 0 {
out.push((base + off, size));
}
}
out
}
#[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 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());
}
}