From 7ff39bfda85fae5eca6eb6b6e8a0990611a26008 Mon Sep 17 00:00:00 2001 From: Christos Date: Mon, 9 Nov 2026 14:03:25 +0200 Subject: [PATCH 01/11] image-codecs: read a JPEG's frame from its markers (jpeg_info) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For storytold/photocraft#12473: PhotoCraft lifts its 2^30 px JPEG cap only after admitting the decode against free memory, and for that it needs the frame before decoding. jpeg_info reads it from the markers alone: the stored size, the layout decode returns, and whole_coefficients, set when decode hands the file to craft-jpeg (arithmetic-coded, lossless or 12-bit frames), whose working set is several times the decoded image. None without a frame of 1, 3 or 4 components. This was part of storytold/photocraft#12604 until storytold/photocraft#14236 moved the codecs here. Co-Authored-By: Νύξ (Nyx, AI) 🌑 --- crates/image-codecs/src/codecs/jpeg.rs | 27 ++++++++++++++++++++++++++ crates/image-codecs/src/lib.rs | 17 ++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/image-codecs/src/codecs/jpeg.rs b/crates/image-codecs/src/codecs/jpeg.rs index 8e68451ef..2612a7f22 100644 --- a/crates/image-codecs/src/codecs/jpeg.rs +++ b/crates/image-codecs/src/codecs/jpeg.rs @@ -42,6 +42,18 @@ pub(crate) struct JpegMeta { pub frame: Option<(u32, u32, u8)>, } +/// The first frame of `b` as [`crate::JpegInfo`], from its markers alone (see [`crate::jpeg_info`]). +pub(crate) fn info(b: &[u8]) -> Option { + let (width, height, components) = scan_metadata(b).frame?; + let layout = match components { + 1 => ChannelLayout::Gray, + 3 => ChannelLayout::Rgb, + 4 => ChannelLayout::Cmyk, + _ => return None, + }; + Some(crate::JpegInfo { width, height, layout, whole_coefficients: uses_craft_jpeg(b) }) +} + /// Walk the marker segments up to SOS and collect metadata. pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta { let mut m = JpegMeta::default(); @@ -573,6 +585,21 @@ mod tests { bytes.get(2..11) == Some(b"\xff\xe0\0\x10JFIF\0") } + /// `info` reads the frame from the markers, and which decoder the file goes to. + #[test] + fn info_reads_the_frame_and_the_decoder() { + let img = Image::from_raw(37, 21, ChannelLayout::Rgb, SampleType::U8, vec![128; 37 * 21 * 3]).unwrap(); + let rgb = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + assert_eq!(info(&rgb), Some(crate::JpegInfo { width: 37, height: 21, layout: ChannelLayout::Rgb, whole_coefficients: false })); + // A 12-bit extended frame (SOF1) declaring 40,000 × 30,000 gray, markers only. + let mut twelve = vec![0xFF, 0xD8, 0xFF, 0xC1, 0, 11, 12]; + twelve.extend_from_slice(&30_000u16.to_be_bytes()); + twelve.extend_from_slice(&40_000u16.to_be_bytes()); + twelve.extend_from_slice(&[1, 1, 0x11, 0, 0xFF, 0xD9]); + assert_eq!(info(&twelve), Some(crate::JpegInfo { width: 40_000, height: 30_000, layout: ChannelLayout::Gray, whole_coefficients: true })); + assert_eq!(info(b"not a jpeg"), None); + } + /// An APP1 Extended XMP fragment (GUID, total size, offset, data). fn extended_xmp(offset: u32) -> Vec { let mut seg = EXTENDED_XMP_HEADER.to_vec(); diff --git a/crates/image-codecs/src/lib.rs b/crates/image-codecs/src/lib.rs index d3ab272fa..3045cd090 100644 --- a/crates/image-codecs/src/lib.rs +++ b/crates/image-codecs/src/lib.rs @@ -212,6 +212,23 @@ pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> { Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) }) } +/// A JPEG's frame as its header declares it, read without decoding pixels: the stored size, the +/// layout [`decode`] returns, and whether [`decode`] hands the file to its whole-coefficient +/// decoder (arithmetic-coded, lossless or 12-bit frames), whose working set is several times the +/// decoded image. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct JpegInfo { + pub width: u32, + pub height: u32, + pub layout: ChannelLayout, + pub whole_coefficients: bool, +} + +/// [`JpegInfo`] of `bytes`; `None` without a readable frame of 1, 3 or 4 components. +pub fn jpeg_info(bytes: &[u8]) -> Option { + jpeg::info(bytes) +} + /// The colour table of a GIF's first frame (its Local Color Table, else the Global one) and the /// entry its Graphic Control Extension makes transparent, read without decoding pixels. pub fn gif_palette(bytes: &[u8]) -> Option<(Vec<[u8; 3]>, Option)> { From 44dfda20d7684195deeb68a3f4075e1ea26bad91 Mon Sep 17 00:00:00 2001 From: Christos Date: Mon, 9 Nov 2026 20:38:25 +0200 Subject: [PATCH 02/11] image-codecs: count the coefficients an 8-bit progressive decode keeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zune-jpeg keeps every coefficient of an 8-bit progressive frame (SOF2) beside its output: 2 bytes per sample, per component at its sampling. That is 9.85 GB for a 48,000 x 34,200 4:4:4 file, more than the decoded image. JpegInfo::progressive_coefficients reports them from the frame header, so a caller can admit the decode against its real peak. Co-Authored-By: Νύξ (Nyx, AI) 🌑 --- crates/image-codecs/src/codecs/jpeg.rs | 57 ++++++++++++++++++++++++-- crates/image-codecs/src/lib.rs | 9 +++- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/image-codecs/src/codecs/jpeg.rs b/crates/image-codecs/src/codecs/jpeg.rs index 2612a7f22..3664c2666 100644 --- a/crates/image-codecs/src/codecs/jpeg.rs +++ b/crates/image-codecs/src/codecs/jpeg.rs @@ -40,18 +40,37 @@ pub(crate) struct JpegMeta { pub adobe_transform: Option, /// (width, height, components) from the first SOFn. pub frame: Option<(u32, u32, u8)>, + /// [`crate::JpegInfo::progressive_coefficients`] of the first SOFn. + pub progressive_coefficients: u64, } /// The first frame of `b` as [`crate::JpegInfo`], from its markers alone (see [`crate::jpeg_info`]). pub(crate) fn info(b: &[u8]) -> Option { - let (width, height, components) = scan_metadata(b).frame?; + let m = scan_metadata(b); + let (width, height, components) = m.frame?; let layout = match components { 1 => ChannelLayout::Gray, 3 => ChannelLayout::Rgb, 4 => ChannelLayout::Cmyk, _ => return None, }; - Some(crate::JpegInfo { width, height, layout, whole_coefficients: uses_craft_jpeg(b) }) + let progressive_coefficients = m.progressive_coefficients; + Some(crate::JpegInfo { width, height, layout, whole_coefficients: uses_craft_jpeg(b), progressive_coefficients }) +} + +/// Bytes of the coefficients `zune-jpeg` keeps through an 8-bit progressive frame (SOF2), from +/// its header segment `seg`: per component, a 64-value block of `i16`s for each of its H × V +/// blocks in every MCU (A.2), the MCUs covering the frame at the largest sampling factors. 0 +/// for any other frame. +fn progressive_coefficients(marker: u8, seg: &[u8]) -> u64 { + let (0xC2, &[8, h0, h1, w0, w1, n, ref comps @ ..]) = (marker, seg) else { return 0 }; + let (h, w) = (u64::from(u16::from_be_bytes([h0, h1])), u64::from(u16::from_be_bytes([w0, w1]))); + let factors: Vec<(u64, u64)> = + comps.as_chunks::<3>().0.iter().take(usize::from(n)).map(|&[_, f, _]| (u64::from(f >> 4), u64::from(f & 15))).collect(); + let hmax = factors.iter().map(|f| f.0).max().unwrap_or(1).max(1); + let vmax = factors.iter().map(|f| f.1).max().unwrap_or(1).max(1); + let mcus = w.div_ceil(8 * hmax) * h.div_ceil(8 * vmax); + factors.iter().map(|&(fh, fv)| mcus * fh * fv * 64 * 2).sum() } /// Walk the marker segments up to SOS and collect metadata. @@ -112,6 +131,7 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta { let h = u16::from_be_bytes([seg[1], seg[2]]) as u32; let w = u16::from_be_bytes([seg[3], seg[4]]) as u32; m.frame = Some((w, h, seg[5])); + m.progressive_coefficients = progressive_coefficients(marker, seg); } 0xEE if seg.len() >= 12 && seg.starts_with(b"Adobe") => { m.adobe_transform = Some(seg[11]); @@ -590,16 +610,45 @@ mod tests { fn info_reads_the_frame_and_the_decoder() { let img = Image::from_raw(37, 21, ChannelLayout::Rgb, SampleType::U8, vec![128; 37 * 21 * 3]).unwrap(); let rgb = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); - assert_eq!(info(&rgb), Some(crate::JpegInfo { width: 37, height: 21, layout: ChannelLayout::Rgb, whole_coefficients: false })); + let rgb_info = crate::JpegInfo { width: 37, height: 21, layout: ChannelLayout::Rgb, whole_coefficients: false, progressive_coefficients: 0 }; + assert_eq!(info(&rgb), Some(rgb_info)); // A 12-bit extended frame (SOF1) declaring 40,000 × 30,000 gray, markers only. let mut twelve = vec![0xFF, 0xD8, 0xFF, 0xC1, 0, 11, 12]; twelve.extend_from_slice(&30_000u16.to_be_bytes()); twelve.extend_from_slice(&40_000u16.to_be_bytes()); twelve.extend_from_slice(&[1, 1, 0x11, 0, 0xFF, 0xD9]); - assert_eq!(info(&twelve), Some(crate::JpegInfo { width: 40_000, height: 30_000, layout: ChannelLayout::Gray, whole_coefficients: true })); + let twelve_info = + crate::JpegInfo { width: 40_000, height: 30_000, layout: ChannelLayout::Gray, whole_coefficients: true, progressive_coefficients: 0 }; + assert_eq!(info(&twelve), Some(twelve_info)); assert_eq!(info(b"not a jpeg"), None); } + /// An 8-bit progressive frame (SOF2) reports every coefficient the decoder keeps through its + /// scans: a block of 64 `i16`s per component block of every MCU. + #[test] + fn info_counts_a_progressive_frames_coefficients() { + // Markers only: SOI, a frame of `marker` declaring 48,000 × 34,200 with `factors`, EOI. + let frame = |marker: u8, precision: u8, factors: &[u8]| { + let mut b = vec![0xFF, 0xD8, 0xFF, marker, 0, 8 + 3 * factors.len() as u8, precision]; + b.extend_from_slice(&34_200u16.to_be_bytes()); + b.extend_from_slice(&48_000u16.to_be_bytes()); + b.push(factors.len() as u8); + for (i, &f) in factors.iter().enumerate() { + b.extend_from_slice(&[i as u8 + 1, f, 0]); + } + b.extend_from_slice(&[0xFF, 0xD9]); + b + }; + let coefficients = |b: &[u8]| info(b).map(|i| i.progressive_coefficients); + // 4:4:4: 6,000 × 4,275 MCUs of one block per component. + assert_eq!(coefficients(&frame(0xC2, 8, &[0x11, 0x11, 0x11])), Some(6_000 * 4_275 * 3 * 128)); + // 4:2:0: 3,000 × 2,138 MCUs of 4 + 1 + 1 blocks. + assert_eq!(coefficients(&frame(0xC2, 8, &[0x22, 0x11, 0x11])), Some(3_000 * 2_138 * 6 * 128)); + // Baseline and 12-bit progressive frames (the latter decoded by craft-jpeg) hold none. + assert_eq!(coefficients(&frame(0xC0, 8, &[0x11, 0x11, 0x11])), Some(0)); + assert_eq!(coefficients(&frame(0xC2, 12, &[0x11, 0x11, 0x11])), Some(0)); + } + /// An APP1 Extended XMP fragment (GUID, total size, offset, data). fn extended_xmp(offset: u32) -> Vec { let mut seg = EXTENDED_XMP_HEADER.to_vec(); diff --git a/crates/image-codecs/src/lib.rs b/crates/image-codecs/src/lib.rs index 3045cd090..018890286 100644 --- a/crates/image-codecs/src/lib.rs +++ b/crates/image-codecs/src/lib.rs @@ -213,15 +213,20 @@ pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> { } /// A JPEG's frame as its header declares it, read without decoding pixels: the stored size, the -/// layout [`decode`] returns, and whether [`decode`] hands the file to its whole-coefficient +/// layout [`decode`] returns, whether [`decode`] hands the file to its whole-coefficient /// decoder (arithmetic-coded, lossless or 12-bit frames), whose working set is several times the -/// decoded image. +/// decoded image, and the coefficients it holds beside its output for an 8-bit progressive one. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct JpegInfo { pub width: u32, pub height: u32, pub layout: ChannelLayout, pub whole_coefficients: bool, + /// Bytes of DCT coefficients [`decode`] holds at once beside its output for an 8-bit + /// progressive frame (SOF2): every block of every component, at its own sampling, 64 + /// 16-bit values each, since the scans refine them in turn. 0 for the frames it decodes a + /// line of MCUs at a time. + pub progressive_coefficients: u64, } /// [`JpegInfo`] of `bytes`; `None` without a readable frame of 1, 3 or 4 components. From b3558f68213c47969e2a4350c0b897c01bd12335 Mon Sep 17 00:00:00 2001 From: Christos Date: Mon, 9 Nov 2026 21:15:27 +0200 Subject: [PATCH 03/11] image-codecs: test jpeg_info with four and two components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four components read as CMYK. Two, a layout decode doesn't return, give None. Co-Authored-By: Νύξ (Nyx, AI) 🌑 --- crates/image-codecs/src/codecs/jpeg.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/image-codecs/src/codecs/jpeg.rs b/crates/image-codecs/src/codecs/jpeg.rs index 3664c2666..b814a025b 100644 --- a/crates/image-codecs/src/codecs/jpeg.rs +++ b/crates/image-codecs/src/codecs/jpeg.rs @@ -621,6 +621,17 @@ mod tests { crate::JpegInfo { width: 40_000, height: 30_000, layout: ChannelLayout::Gray, whole_coefficients: true, progressive_coefficients: 0 }; assert_eq!(info(&twelve), Some(twelve_info)); assert_eq!(info(b"not a jpeg"), None); + // Markers only, 200 × 100 with `n` components: four are CMYK, two no layout `decode` returns. + let sof0 = |n: u8| { + let mut b = vec![0xFF, 0xD8, 0xFF, 0xC0, 0, 8 + 3 * n, 8, 0, 100, 0, 200, n]; + for i in 1..=n { + b.extend_from_slice(&[i, 0x11, 0]); + } + b.extend_from_slice(&[0xFF, 0xD9]); + b + }; + assert_eq!(info(&sof0(4)).map(|i| (i.width, i.height, i.layout)), Some((200, 100, ChannelLayout::Cmyk))); + assert_eq!(info(&sof0(2)), None); } /// An 8-bit progressive frame (SOF2) reports every coefficient the decoder keeps through its From b270477c251c1096d9c9686ed32e0f21166a1ad5 Mon Sep 17 00:00:00 2001 From: Christos Date: Mon, 9 Nov 2026 21:47:08 +0200 Subject: [PATCH 04/11] image-codecs: count the coefficients of a frame whose first scan lacks components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zune-jpeg also holds every coefficient beside its output when an 8-bit sequential frame's first scan lacks some of its components: no row is finished until the scans that bring the others. jpeg_info now reads the first scan's component count and reports these as held_coefficients (was progressive_coefficients). The #12473 map rewritten with one component per scan was admitted at 12.6 GB without this, and OOM-killed in a 14 GB cgroup. Co-Authored-By: Νύξ (Nyx, AI) 🌑 --- crates/image-codecs/src/codecs/jpeg.rs | 79 ++++++++++++++++++-------- crates/image-codecs/src/lib.rs | 13 +++-- 2 files changed, 62 insertions(+), 30 deletions(-) diff --git a/crates/image-codecs/src/codecs/jpeg.rs b/crates/image-codecs/src/codecs/jpeg.rs index b814a025b..8d613ce38 100644 --- a/crates/image-codecs/src/codecs/jpeg.rs +++ b/crates/image-codecs/src/codecs/jpeg.rs @@ -40,8 +40,8 @@ pub(crate) struct JpegMeta { pub adobe_transform: Option, /// (width, height, components) from the first SOFn. pub frame: Option<(u32, u32, u8)>, - /// [`crate::JpegInfo::progressive_coefficients`] of the first SOFn. - pub progressive_coefficients: u64, + /// [`crate::JpegInfo::held_coefficients`] of the first SOFn and the first scan. + pub held_coefficients: u64, } /// The first frame of `b` as [`crate::JpegInfo`], from its markers alone (see [`crate::jpeg_info`]). @@ -54,16 +54,27 @@ pub(crate) fn info(b: &[u8]) -> Option { 4 => ChannelLayout::Cmyk, _ => return None, }; - let progressive_coefficients = m.progressive_coefficients; - Some(crate::JpegInfo { width, height, layout, whole_coefficients: uses_craft_jpeg(b), progressive_coefficients }) + let held_coefficients = m.held_coefficients; + Some(crate::JpegInfo { width, height, layout, whole_coefficients: uses_craft_jpeg(b), held_coefficients }) } -/// Bytes of the coefficients `zune-jpeg` keeps through an 8-bit progressive frame (SOF2), from -/// its header segment `seg`: per component, a 64-value block of `i16`s for each of its H × V -/// blocks in every MCU (A.2), the MCUs covering the frame at the largest sampling factors. 0 -/// for any other frame. -fn progressive_coefficients(marker: u8, seg: &[u8]) -> u64 { - let (0xC2, &[8, h0, h1, w0, w1, n, ref comps @ ..]) = (marker, seg) else { return 0 }; +/// Bytes of the coefficients `zune-jpeg` holds beside its output for a frame it can't turn into +/// rows as it reads (see [`crate::JpegInfo::held_coefficients`]), from the frame's header +/// segment `seg` and the number of components in its first scan: per component, a 64-value +/// block of `i16`s for each of its H × V blocks in every MCU (A.2), the MCUs covering the frame +/// at the largest sampling factors. 0 for any other frame. +fn held_coefficients(marker: u8, seg: &[u8], scan_components: Option) -> u64 { + let &[8, h0, h1, w0, w1, n, ref comps @ ..] = seg else { return 0 }; + // Progressive scans refine every block in turn; a sequential first scan without every + // component leaves its rows unfinished until the scans that bring the others. + let held = match marker { + 0xC2 => true, + 0xC0 | 0xC1 => scan_components.is_some_and(|s| s < n), + _ => false, + }; + if !held { + return 0; + } let (h, w) = (u64::from(u16::from_be_bytes([h0, h1])), u64::from(u16::from_be_bytes([w0, w1]))); let factors: Vec<(u64, u64)> = comps.as_chunks::<3>().0.iter().take(usize::from(n)).map(|&[_, f, _]| (u64::from(f >> 4), u64::from(f & 15))).collect(); @@ -80,6 +91,9 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta { // Photoshop splits a large resource block over consecutive APP13 segments. let mut photoshop: Vec = Vec::new(); let mut jfif_dpi = None; + // The first frame header and the number of components in the first scan. + let mut sof: Option<(u8, &[u8])> = None; + let mut scan_components = None; let mut i = 2; while i + 4 <= b.len() { if b[i] != 0xFF { @@ -95,6 +109,7 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta { continue; } if marker == 0xDA || marker == 0xD9 { + scan_components = (marker == 0xDA).then(|| b.get(i + 4).copied()).flatten(); break; } let len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize; @@ -131,7 +146,7 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta { let h = u16::from_be_bytes([seg[1], seg[2]]) as u32; let w = u16::from_be_bytes([seg[3], seg[4]]) as u32; m.frame = Some((w, h, seg[5])); - m.progressive_coefficients = progressive_coefficients(marker, seg); + sof = Some((marker, seg)); } 0xEE if seg.len() >= 12 && seg.starts_with(b"Adobe") => { m.adobe_transform = Some(seg[11]); @@ -140,6 +155,7 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta { } i += 2 + len; } + m.held_coefficients = sof.map_or(0, |(marker, seg)| held_coefficients(marker, seg, scan_components)); m.dpi = photoshop_resolution(&photoshop) .or_else(|| m.xmp.as_deref().and_then(xmp_resolution)) .or_else(|| m.exif.as_deref().and_then(exif_resolution)) @@ -610,7 +626,7 @@ mod tests { fn info_reads_the_frame_and_the_decoder() { let img = Image::from_raw(37, 21, ChannelLayout::Rgb, SampleType::U8, vec![128; 37 * 21 * 3]).unwrap(); let rgb = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); - let rgb_info = crate::JpegInfo { width: 37, height: 21, layout: ChannelLayout::Rgb, whole_coefficients: false, progressive_coefficients: 0 }; + let rgb_info = crate::JpegInfo { width: 37, height: 21, layout: ChannelLayout::Rgb, whole_coefficients: false, held_coefficients: 0 }; assert_eq!(info(&rgb), Some(rgb_info)); // A 12-bit extended frame (SOF1) declaring 40,000 × 30,000 gray, markers only. let mut twelve = vec![0xFF, 0xD8, 0xFF, 0xC1, 0, 11, 12]; @@ -618,7 +634,7 @@ mod tests { twelve.extend_from_slice(&40_000u16.to_be_bytes()); twelve.extend_from_slice(&[1, 1, 0x11, 0, 0xFF, 0xD9]); let twelve_info = - crate::JpegInfo { width: 40_000, height: 30_000, layout: ChannelLayout::Gray, whole_coefficients: true, progressive_coefficients: 0 }; + crate::JpegInfo { width: 40_000, height: 30_000, layout: ChannelLayout::Gray, whole_coefficients: true, held_coefficients: 0 }; assert_eq!(info(&twelve), Some(twelve_info)); assert_eq!(info(b"not a jpeg"), None); // Markers only, 200 × 100 with `n` components: four are CMYK, two no layout `decode` returns. @@ -634,12 +650,14 @@ mod tests { assert_eq!(info(&sof0(2)), None); } - /// An 8-bit progressive frame (SOF2) reports every coefficient the decoder keeps through its - /// scans: a block of 64 `i16`s per component block of every MCU. + /// A frame the decoder can't turn into rows as it reads reports every coefficient it holds: + /// a block of 64 `i16`s per component block of every MCU. That is an 8-bit progressive frame + /// (SOF2), or an 8-bit sequential one whose first scan lacks some of its components. #[test] - fn info_counts_a_progressive_frames_coefficients() { - // Markers only: SOI, a frame of `marker` declaring 48,000 × 34,200 with `factors`, EOI. - let frame = |marker: u8, precision: u8, factors: &[u8]| { + fn info_counts_the_coefficients_a_decode_holds() { + // Markers only: SOI, a frame of `marker` declaring 48,000 × 34,200 with `factors`, a first + // scan of the first `scan` components when given, EOI. + let frame = |marker: u8, precision: u8, factors: &[u8], scan: Option| { let mut b = vec![0xFF, 0xD8, 0xFF, marker, 0, 8 + 3 * factors.len() as u8, precision]; b.extend_from_slice(&34_200u16.to_be_bytes()); b.extend_from_slice(&48_000u16.to_be_bytes()); @@ -647,17 +665,30 @@ mod tests { for (i, &f) in factors.iter().enumerate() { b.extend_from_slice(&[i as u8 + 1, f, 0]); } + if let Some(n) = scan { + b.extend_from_slice(&[0xFF, 0xDA, 0, 6 + 2 * n, n]); + for c in 1..=n { + b.extend_from_slice(&[c, 0]); + } + b.extend_from_slice(&[0, 63, 0]); + } b.extend_from_slice(&[0xFF, 0xD9]); b }; - let coefficients = |b: &[u8]| info(b).map(|i| i.progressive_coefficients); + let held = |b: &[u8]| info(b).map(|i| i.held_coefficients); + let all_444 = 6_000 * 4_275 * 3 * 128; // 4:4:4: 6,000 × 4,275 MCUs of one block per component. - assert_eq!(coefficients(&frame(0xC2, 8, &[0x11, 0x11, 0x11])), Some(6_000 * 4_275 * 3 * 128)); + assert_eq!(held(&frame(0xC2, 8, &[0x11, 0x11, 0x11], Some(1))), Some(all_444)); // 4:2:0: 3,000 × 2,138 MCUs of 4 + 1 + 1 blocks. - assert_eq!(coefficients(&frame(0xC2, 8, &[0x22, 0x11, 0x11])), Some(3_000 * 2_138 * 6 * 128)); - // Baseline and 12-bit progressive frames (the latter decoded by craft-jpeg) hold none. - assert_eq!(coefficients(&frame(0xC0, 8, &[0x11, 0x11, 0x11])), Some(0)); - assert_eq!(coefficients(&frame(0xC2, 12, &[0x11, 0x11, 0x11])), Some(0)); + assert_eq!(held(&frame(0xC2, 8, &[0x22, 0x11, 0x11], None)), Some(3_000 * 2_138 * 6 * 128)); + // Sequential frames whose first scan lacks components hold them all too. + assert_eq!(held(&frame(0xC0, 8, &[0x11, 0x11, 0x11], Some(1))), Some(all_444)); + assert_eq!(held(&frame(0xC1, 8, &[0x11, 0x11, 0x11], Some(2))), Some(all_444)); + // A sequential first scan with every component is read a line of MCUs at a time, and a + // 12-bit progressive frame goes to craft-jpeg: they hold none. + assert_eq!(held(&frame(0xC0, 8, &[0x11, 0x11, 0x11], Some(3))), Some(0)); + assert_eq!(held(&frame(0xC0, 8, &[0x11, 0x11, 0x11], None)), Some(0)); + assert_eq!(held(&frame(0xC2, 12, &[0x11, 0x11, 0x11], None)), Some(0)); } /// An APP1 Extended XMP fragment (GUID, total size, offset, data). diff --git a/crates/image-codecs/src/lib.rs b/crates/image-codecs/src/lib.rs index 018890286..2c1fb1e40 100644 --- a/crates/image-codecs/src/lib.rs +++ b/crates/image-codecs/src/lib.rs @@ -215,18 +215,19 @@ pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> { /// A JPEG's frame as its header declares it, read without decoding pixels: the stored size, the /// layout [`decode`] returns, whether [`decode`] hands the file to its whole-coefficient /// decoder (arithmetic-coded, lossless or 12-bit frames), whose working set is several times the -/// decoded image, and the coefficients it holds beside its output for an 8-bit progressive one. +/// decoded image, and the coefficients it holds beside its output when it can't decode row by row. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct JpegInfo { pub width: u32, pub height: u32, pub layout: ChannelLayout, pub whole_coefficients: bool, - /// Bytes of DCT coefficients [`decode`] holds at once beside its output for an 8-bit - /// progressive frame (SOF2): every block of every component, at its own sampling, 64 - /// 16-bit values each, since the scans refine them in turn. 0 for the frames it decodes a - /// line of MCUs at a time. - pub progressive_coefficients: u64, + /// Bytes of DCT coefficients [`decode`] holds at once beside its output when it can't turn + /// the frame into rows as it reads: an 8-bit progressive frame (SOF2), whose scans refine + /// every block in turn, or an 8-bit Huffman one (SOF0, SOF1) whose first scan lacks some of + /// its components. Every block of every component at its own sampling, 64 16-bit values + /// each; 0 for the frames it decodes a line of MCUs at a time. + pub held_coefficients: u64, } /// [`JpegInfo`] of `bytes`; `None` without a readable frame of 1, 3 or 4 components. From 7ac384839d9bf58873c301300cefd46f1afcf5fd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 10:45:23 +0000 Subject: [PATCH 05/11] fix(docx): ignore malformed list levels --- crates/docx/src/read/props.rs | 25 ++++++++++++++++--------- crates/docx/src/read/styles.rs | 16 +++++++++++----- crates/docx/tests/it/passthrough.rs | 25 +++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 05386aa56..dc61b3a28 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -389,15 +389,22 @@ impl PropCtx { } "w:numPr" => { let num = k.child_val("w:numId").and_then(u32_of); - let ilvl = k.child_val("w:ilvl").and_then(u32_of); - let level = ilvl.unwrap_or(0).min(8) as u8; - match (num, ilvl) { - (Some(num), _) => p.numbering = Some(NumRef { num, level }), - // A level without a list: the list comes from the based-on style, as in - // a Heading 3 that only says "level 3" of Heading 2's list. Resolved once - // the styles are read (`resolve_list_levels`). - (None, Some(_)) => p.numbering = Some(NumRef { num: LEVEL_ONLY, level }), - (None, None) => {} + let ilvl_node = k.child("w:ilvl"); + let ilvl = + ilvl_node.and_then(|node| node.attr("w:val")).and_then(int).filter(|level| (0..=8).contains(level)).map(|level| level as u32); + // A missing level means level zero. A present but malformed/out-of-range + // level invalidates this numbering reference instead of silently becoming + // level zero; the latter can turn hostile input into a real list. + if ilvl_node.is_none() || ilvl.is_some() { + let level = ilvl.unwrap_or(0) as u8; + match (num, ilvl) { + (Some(num), _) => p.numbering = Some(NumRef { num, level }), + // A level without a list: the list comes from the based-on style, as in + // a Heading 3 that only says "level 3" of Heading 2's list. Resolved once + // the styles are read (`resolve_list_levels`). + (None, Some(_)) => p.numbering = Some(NumRef { num: LEVEL_ONLY, level }), + (None, None) => {} + } } } "w:pBdr" => p.borders = Some(self.borders(k)), diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index bba06907c..3d7e61bf8 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -6,6 +6,13 @@ use crate::units::tw_i16; use craft_wordmodel::props::PropChange; use std::sync::Arc; +fn numbering_level_index(value: Option<&str>) -> Option { + match value { + None => Some(0), + Some(value) => int(value).filter(|level| (0..=8).contains(level)).map(|level| level as usize), + } +} + /// Built-in style names Word stores in lower case, and how the UI shows them. pub(crate) const LOWER_NAMES: &[(&str, &str)] = &[ ("caption", "Caption"), @@ -294,10 +301,8 @@ impl Reader<'_> { let Some(id) = a.attr("w:abstractNumId").and_then(u32_of) else { continue }; let mut levels: Vec = (0..9).map(default_level).collect(); for l in a.children("w:lvl") { - let i = l.attr("w:ilvl").and_then(u32_of).unwrap_or(0) as usize; - if let Some(slot) = levels.get_mut(i) { - *slot = self.level(l, i); - } + let Some(i) = numbering_level_index(l.attr("w:ilvl")) else { continue }; + levels[i] = self.level(l, i); } if let Some(link) = a.child_val("w:numStyleLink") { links.push((id, link.to_string())); @@ -321,7 +326,8 @@ impl Reader<'_> { let (Some(id), Some(abs)) = (n.attr("w:numId").and_then(u32_of), n.child_val("w:abstractNumId").and_then(u32_of)) else { continue }; let (mut start_overrides, mut level_overrides) = (Vec::new(), Vec::new()); for o in n.children("w:lvlOverride").take(9) { - let lvl = o.attr("w:ilvl").and_then(u32_of).unwrap_or(0).min(8) as u8; + let Some(lvl) = numbering_level_index(o.attr("w:ilvl")) else { continue }; + let lvl = lvl as u8; if let Some(s) = o.child_val("w:startOverride").and_then(u32_of) { start_overrides.push((lvl, s)); } else if let Some(s) = o.child("w:lvl").and_then(|l| l.child_val("w:start")).and_then(u32_of) { diff --git a/crates/docx/tests/it/passthrough.rs b/crates/docx/tests/it/passthrough.rs index f59131e17..97741a813 100644 --- a/crates/docx/tests/it/passthrough.rs +++ b/crates/docx/tests/it/passthrough.rs @@ -320,6 +320,31 @@ fn list_identities_survive_and_a_copied_definition_gets_none() { assert_eq!(n.matches("1F2E3D4C").count(), 1, "{n}"); } +#[test] +fn malformed_numbering_levels_are_skipped_instead_of_becoming_level_zero() { + let numbering = r#""#; + let body = r#"default levelvalid levelnegative levelout of rangeoverflow"#; + let bytes = package(body, &[("rId1", &format!("{REL}/numbering"), "numbering.xml")], &[], &[("word/numbering.xml", numbering)]); + let doc = craft_docx::read(&bytes).unwrap(); + let paragraphs: Vec<_> = doc + .body + .iter() + .filter_map(|block| match &**block { + craft_wordmodel::Block::Para(p) => Some(p), + _ => None, + }) + .collect(); + assert_eq!(paragraphs.len(), 5); + assert_eq!(paragraphs[0].props.numbering.map(|n| (n.num, n.level)), Some((1, 0)), "an omitted level defaults to zero"); + assert_eq!(paragraphs[1].props.numbering.map(|n| (n.num, n.level)), Some((1, 2)), "a valid level is preserved"); + for p in ¶graphs[2..] { + assert_eq!(p.props.numbering, None, "malformed explicit level is not treated as a real list: {}", p.text); + } + let level_zero = doc.numbering.level(1, 0).expect("the valid level zero"); + assert_eq!(level_zero.format, craft_wordmodel::section::NumFormat::Decimal); + assert_eq!(level_zero.start, 1, "invalid level overrides do not affect level zero"); +} + #[test] fn extended_properties_are_kept_without_words_counts_and_personal_ones_follow_the_privacy_setting() { let app = r#"4210740000250000Microsoft Office Word020001700falseMFirmafalse290000falsehttps://example.com/false16.0000"#; From 4baa7d81cabd92176da516b6f4223298e2ebd1c4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 10:57:01 +0000 Subject: [PATCH 06/11] fix(docx): ignore overrides without a list level --- crates/docx/src/read/styles.rs | 3 ++- crates/docx/tests/it/passthrough.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index 3d7e61bf8..d2946b8ec 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -326,7 +326,8 @@ impl Reader<'_> { let (Some(id), Some(abs)) = (n.attr("w:numId").and_then(u32_of), n.child_val("w:abstractNumId").and_then(u32_of)) else { continue }; let (mut start_overrides, mut level_overrides) = (Vec::new(), Vec::new()); for o in n.children("w:lvlOverride").take(9) { - let Some(lvl) = numbering_level_index(o.attr("w:ilvl")) else { continue }; + let Some(raw_level) = o.attr("w:ilvl") else { continue }; + let Some(lvl) = numbering_level_index(Some(raw_level)) else { continue }; let lvl = lvl as u8; if let Some(s) = o.child_val("w:startOverride").and_then(u32_of) { start_overrides.push((lvl, s)); diff --git a/crates/docx/tests/it/passthrough.rs b/crates/docx/tests/it/passthrough.rs index 97741a813..9e5f35a9f 100644 --- a/crates/docx/tests/it/passthrough.rs +++ b/crates/docx/tests/it/passthrough.rs @@ -322,7 +322,7 @@ fn list_identities_survive_and_a_copied_definition_gets_none() { #[test] fn malformed_numbering_levels_are_skipped_instead_of_becoming_level_zero() { - let numbering = r#""#; + let numbering = r#""#; let body = r#"default levelvalid levelnegative levelout of rangeoverflow"#; let bytes = package(body, &[("rId1", &format!("{REL}/numbering"), "numbering.xml")], &[], &[("word/numbering.xml", numbering)]); let doc = craft_docx::read(&bytes).unwrap(); From 1cc7a214161621da7816903af85c048e965f95de Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 11:05:21 +0000 Subject: [PATCH 07/11] jpeg: add optimized baseline encoder --- Cargo.lock | 11 + crates/jpeg/Cargo.toml | 6 + crates/jpeg/src/encode.rs | 869 ++++++++++++++++++++++++++++++++++++++ crates/jpeg/src/lib.rs | 7 +- 4 files changed, 891 insertions(+), 2 deletions(-) create mode 100644 crates/jpeg/src/encode.rs diff --git a/Cargo.lock b/Cargo.lock index 722a8f1f9..e3f5264d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1886,7 +1886,9 @@ dependencies = [ name = "craft-jpeg" version = "0.1.0" dependencies = [ + "jpeg-decoder", "jpeg-encoder 0.6.1", + "rayon", "thiserror 2.0.21", "zune-jpeg", ] @@ -5327,6 +5329,15 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +dependencies = [ + "rayon", +] + [[package]] name = "jpeg-encoder" version = "0.6.1" diff --git a/crates/jpeg/Cargo.toml b/crates/jpeg/Cargo.toml index afdae2fdb..708a36db0 100644 --- a/crates/jpeg/Cargo.toml +++ b/crates/jpeg/Cargo.toml @@ -12,8 +12,14 @@ workspace = true [dependencies] thiserror = { workspace = true } +rayon = { workspace = true, optional = true } [dev-dependencies] # Reference encoder and baseline decoder for the comparison tests. jpeg-encoder = "0.6" zune-jpeg = "0.5" +jpeg-decoder = "0.3" + +[features] +default = [] +parallel = ["dep:rayon"] diff --git a/crates/jpeg/src/encode.rs b/crates/jpeg/src/encode.rs new file mode 100644 index 000000000..4f3494a4c --- /dev/null +++ b/crates/jpeg/src/encode.rs @@ -0,0 +1,869 @@ +//! Parallel baseline JPEG encoder (ITU-T T.81 / JFIF). +//! +//! The image is cut into bands of MCU rows separated by restart markers (`DRI` + `RSTn`): DC +//! prediction restarts at every marker, so bands are entropy-coded independently on all cores and +//! concatenated. Quantization uses the example tables of T.81 Annex K scaled by the usual 1–100 +//! quality convention. Huffman tables are made for each image from its own symbol counts (T.81 +//! Annex K.2): a first pass over the bands counts the symbols, a second codes them. The file stays +//! interleaved baseline with the tables in its `DHT` segments, readable by every decoder, and is +//! smaller than with the Annex K example tables at the same pixels. Supports grayscale and YCbCr +//! 4:4:4, 4:2:2, and 4:2:0. + +/// Chroma sampling for baseline JPEG encoding. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ChromaSubsampling { + /// Full resolution chroma. + #[default] + S444, + /// Half horizontal resolution chroma. + S422, + /// Half horizontal and vertical resolution chroma. + S420, +} + +/// Why a baseline JPEG could not be encoded. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum EncodeError { + /// Invalid dimensions, channel count, or source buffer length. + #[error("invalid JPEG input: {0}")] + InvalidInput(String), +} + +/// T.81 Annex K.1 / K.2 example quantization tables (natural order). +const Q_LUMA: [u16; 64] = [ + 16, 11, 10, 16, 24, 40, 51, 61, 12, 12, 14, 19, 26, 58, 60, 55, 14, 13, 16, 24, 40, 57, 69, 56, 14, 17, 22, 29, 51, 87, 80, 62, 18, 22, 37, 56, + 68, 109, 103, 77, 24, 35, 55, 64, 81, 104, 113, 92, 49, 64, 78, 87, 103, 121, 120, 101, 72, 92, 95, 98, 112, 100, 103, 99, +]; +const Q_CHROMA: [u16; 64] = [ + 17, 18, 24, 47, 99, 99, 99, 99, 18, 21, 26, 66, 99, 99, 99, 99, 24, 26, 56, 99, 99, 99, 99, 99, 47, 66, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, +]; + +/// Zig-zag position k → natural index. +const ZIGZAG: [usize; 64] = [ + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, + 36, 29, 22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, +]; + +/// T.81 Annex K.3 example Huffman tables: (code counts per length 1..16, symbols). +const DC_LUMA_BITS: [u8; 16] = [0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]; +const DC_CHROMA_BITS: [u8; 16] = [0, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0]; +const DC_VALS: [u8; 12] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; +const AC_LUMA_BITS: [u8; 16] = [0, 2, 1, 3, 3, 2, 4, 3, 5, 5, 4, 4, 0, 0, 1, 0x7d]; +const AC_LUMA_VALS: [u8; 162] = [ + 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08, + 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, + 0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, + 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, + 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, + 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, + 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, +]; +const AC_CHROMA_BITS: [u8; 16] = [0, 2, 1, 2, 4, 4, 3, 4, 7, 5, 4, 4, 0, 1, 2, 0x77]; +const AC_CHROMA_VALS: [u8; 162] = [ + 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, + 0xa1, 0xb1, 0xc1, 0x09, 0x23, 0x33, 0x52, 0xf0, 0x15, 0x62, 0x72, 0xd1, 0x0a, 0x16, 0x24, 0x34, 0xe1, 0x25, 0xf1, 0x17, 0x18, 0x19, 0x1a, 0x26, + 0x27, 0x28, 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, + 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, + 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, + 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, + 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, +]; + +/// Huffman code table: symbol → (code, length). +struct Huff { + code: [(u16, u8); 256], +} + +impl Huff { + /// Canonical codes from counts (T.81 Annex C). + fn new(bits: &[u8; 16], vals: &[u8]) -> Huff { + let mut code = [(0u16, 0u8); 256]; + let (mut c, mut k) = (0u16, 0usize); + for (len, &n) in bits.iter().enumerate() { + for _ in 0..n { + if let Some(&v) = vals.get(k) { + code[v as usize] = (c, len as u8 + 1); + } + c = c.wrapping_add(1); + k += 1; + } + c = c.wrapping_shl(1); + } + Huff { code } + } +} + +/// Symbol counts of one Huffman table: index = symbol (DC size category, or AC run/size byte). +type Freq = [u64; 256]; + +/// The code lengths (`BITS`, counts per length 1..16) and symbols (`HUFFVAL`) of an optimal +/// Huffman table for `freq`, limited to 16-bit codes and never assigning the all-ones code +/// (T.81 Annex K.2, figures K.1–K.4). Symbols with a zero count get no code. At least one symbol +/// is always present (a table nothing used gets symbol 0), so the result is a valid `DHT`. +fn optimal_table(freq: &Freq) -> ([u8; 16], Vec) { + // 256 is the reserved pseudo-symbol: counted once, it takes the longest code, which is + // then dropped so no real code is all ones (K.2) + let mut f = [0u64; 257]; + f[..256].copy_from_slice(freq); + if f[..256].iter().all(|&c| c == 0) { + f[0] = 1; + } + f[256] = 1; + let mut size = [0usize; 257]; + let mut next: [Option; 257] = [None; 257]; + loop { + // the two smallest non-zero counts; on equal counts the larger symbol goes first (K.1) + let mut c1: Option = None; + for i in 0..257 { + if f[i] > 0 && c1.is_none_or(|c| f[i] <= f[c]) { + c1 = Some(i); + } + } + let Some(c1) = c1 else { break }; + let mut c2: Option = None; + for i in 0..257 { + if i != c1 && f[i] > 0 && c2.is_none_or(|c| f[i] <= f[c]) { + c2 = Some(i); + } + } + let Some(c2) = c2 else { break }; + f[c1] = f[c1].saturating_add(f[c2]); + f[c2] = 0; + // every symbol in both trees goes one level deeper; c2's chain is appended to c1's + let mut k = c1; + size[k] += 1; + while let Some(n) = next[k] { + k = n; + size[k] += 1; + } + next[k] = Some(c2); + let mut k = c2; + size[k] += 1; + while let Some(n) = next[k] { + k = n; + size[k] += 1; + } + } + // counts per code length; 257 symbols need at most 256 levels + let mut bits = [0usize; 257]; + for &s in &size { + if s > 0 { + bits[s.min(256)] += 1; + } + } + // limit to 16 bits (K.3): move pairs of the longest codes up, one level at a time (the + // deepest level of a full tree always holds an even number of codes) + for i in (17..257).rev() { + while bits[i] >= 2 { + let Some(j) = (1..i - 1).rev().find(|&j| bits[j] > 0) else { break }; + bits[i] -= 2; + bits[i - 1] += 1; + bits[j + 1] += 2; + bits[j] -= 1; + } + } + // drop the reserved symbol's code: it is one of the longest + if let Some(i) = (1..=16).rev().find(|&i| bits[i] > 0) { + bits[i] -= 1; + } + // symbols by code length, then by value (K.4); the reserved symbol is not listed + let mut vals = Vec::new(); + for len in 1..257 { + vals.extend((0..256).filter(|&s| size[s] == len).map(|s| s as u8)); + } + let mut out = [0u8; 16]; + for (o, &b) in out.iter_mut().zip(&bits[1..=16]) { + *o = b.min(255) as u8; + } + (out, vals) +} + +fn scaled_quant(base: &[u16; 64], quality: u8) -> [u16; 64] { + let q = quality.clamp(1, 100) as u32; + let scale = if q < 50 { 5000 / q } else { 200 - 2 * q }; + base.map(|b| ((b as u32 * scale + 50) / 100).clamp(1, 255) as u16) +} + +/// Entropy-coded segment writer with 0xFF byte stuffing. +struct Bits { + out: Vec, + acc: u32, + n: u32, +} + +impl Bits { + fn new() -> Bits { + Bits { out: Vec::new(), acc: 0, n: 0 } + } + #[inline] + fn put(&mut self, code: u32, len: u32) { + if len == 0 { + return; + } + self.acc = (self.acc << len) | (code & ((1 << len) - 1)); + self.n += len; + while self.n >= 8 { + let b = (self.acc >> (self.n - 8)) as u8; + self.out.push(b); + if b == 0xFF { + self.out.push(0); + } + self.n -= 8; + } + self.acc &= (1 << self.n) - 1; + } + /// Pad with 1-bits to a byte boundary. + fn finish(mut self) -> Vec { + if self.n > 0 { + let pad = 8 - self.n; + self.put((1 << pad) - 1, pad); + } + self.out + } +} + +/// AAN output scale factors: `s[0] = 1`, `s[k] = cos(kπ/16)·√2`. +fn aan_scale() -> [f32; 8] { + std::array::from_fn(|k| if k == 0 { 1.0 } else { ((k as f64 * std::f64::consts::PI / 16.0).cos() * std::f64::consts::SQRT_2) as f32 }) +} + +/// Quantization reciprocals with the AAN scaling folded in: coefficient = out / (8·s[u]·s[v]·q). +fn recip_table(q: &[u16; 64]) -> [f32; 64] { + let s = aan_scale(); + std::array::from_fn(|i| 1.0 / (q[i] as f32 * s[i / 8] * s[i % 8] * 8.0)) +} + +/// One 8-point forward DCT (Arai–Agui–Nakajima flowgraph, 5 multiplies) on `d[o + k·stride]`. +#[inline(always)] +fn aan_1d(d: &mut [f32; 64], o: usize, stride: usize) { + let g = |k: usize| o + k * stride; + let (t0, t7) = (d[g(0)] + d[g(7)], d[g(0)] - d[g(7)]); + let (t1, t6) = (d[g(1)] + d[g(6)], d[g(1)] - d[g(6)]); + let (t2, t5) = (d[g(2)] + d[g(5)], d[g(2)] - d[g(5)]); + let (t3, t4) = (d[g(3)] + d[g(4)], d[g(3)] - d[g(4)]); + // even part + let (t10, t13, t11, t12) = (t0 + t3, t0 - t3, t1 + t2, t1 - t2); + d[g(0)] = t10 + t11; + d[g(4)] = t10 - t11; + let z1 = (t12 + t13) * 0.707_106_77; + d[g(2)] = t13 + z1; + d[g(6)] = t13 - z1; + // odd part + let (t10, t11, t12) = (t4 + t5, t5 + t6, t6 + t7); + let z5 = (t10 - t12) * 0.382_683_43; + let z2 = 0.541_196_1 * t10 + z5; + let z4 = 1.306_563 * t12 + z5; + let z3 = t11 * 0.707_106_77; + let (z11, z13) = (t7 + z3, t7 - z3); + d[g(5)] = z13 + z2; + d[g(3)] = z13 - z2; + d[g(1)] = z11 + z4; + d[g(7)] = z11 - z4; +} + +/// Forward DCT of a level-shifted 8×8 block and quantization. +#[inline] +fn fdct_quant(block: &[f32; 64], recip: &[f32; 64], out: &mut [i16; 64]) { + let mut d = *block; + for r in 0..8 { + aan_1d(&mut d, r * 8, 1); + } + for c in 0..8 { + aan_1d(&mut d, c, 8); + } + for i in 0..64 { + out[i] = round_i16(d[i] * recip[i]); + } +} +/// `x.round() as i16` (half away from zero, saturating, NaN to 0) for every `x` (checked for all of them), in plain +/// arithmetic: without SSE4.1 `f32::round` is a libm call, one per coefficient, that keeps the loop from vectorising. +#[inline(always)] +fn round_i16(x: f32) -> i16 { + // (below 2^24 the truncation is exact and so is the remainder) + let c = x.clamp(-65536.0, 65536.0); + let t = c as i32; + let f = c - t as f32; + let r = t + i32::from(f >= 0.5) - i32::from(f <= -0.5); + r.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16 +} + +#[inline] +fn magnitude(v: i32) -> (u32, u32) { + let a = v.unsigned_abs(); + let size = 32 - a.leading_zeros(); + let bits = if v < 0 { (v - 1) as u32 } else { v as u32 }; + (size, bits & ((1u32 << size) - 1)) +} + +/// The Huffman symbols of one quantized block in coding order: `sym(ac, symbol, bits, n)` for the +/// DC size category (`ac` false) and each AC run/size, ZRL and EOB (`ac` true), each followed by +/// its `n` extra bits `bits`. +#[inline(always)] +fn block_symbols(q: &[i16; 64], pred: &mut i32, mut sym: impl FnMut(bool, usize, u32, u32)) { + let d = q[0] as i32 - *pred; + *pred = q[0] as i32; + let (s, b) = magnitude(d); + sym(false, s as usize, b, s); + let mut run = 0u32; + for &zi in &ZIGZAG[1..] { + let v = q[zi] as i32; + if v == 0 { + run += 1; + continue; + } + while run >= 16 { + sym(true, 0xF0, 0, 0); + run -= 16; + } + let (s, b) = magnitude(v); + sym(true, ((run << 4) | s) as usize, b, s); + run = 0; + } + if run > 0 { + sym(true, 0x00, 0, 0); + } +} + +/// One coded symbol of the entropy-coded data: bits 0–7 the symbol, 8–9 its table (0 DC luma, +/// 1 AC luma, 2 DC chroma, 3 AC chroma), 10–13 the number of extra bits, 14–29 those bits (a +/// baseline coefficient needs at most 11). +type Token = u32; + +/// Append one block's symbols to `out` and count them in `freq` (by table, as in [`Token`]). +fn tokenize_block(q: &[i16; 64], pred: &mut i32, chroma: bool, out: &mut Vec, freq: &mut [Freq; 4]) { + let base = if chroma { 2 } else { 0 }; + block_symbols(q, pred, |is_ac, s, bits, n| { + let table = base + is_ac as usize; + freq[table][s & 0xFF] += 1; + out.push((s as u32 & 0xFF) | (table as u32) << 8 | (n & 0xF) << 10 | (bits & 0xFFFF) << 14); + }); +} + +/// Code a band's symbols with `tables` (indexed as in [`Token`]). +fn write_tokens(tokens: &[Token], tables: &[Huff; 4]) -> Vec { + let mut w = Bits::new(); + for &t in tokens { + let (s, table, n, bits) = (t & 0xFF, (t >> 8) & 3, (t >> 10) & 0xF, t >> 14); + let (c, l) = tables[table as usize].code[s as usize]; + w.put(c as u32, l as u32); + w.put(bits, n); + } + w.finish() +} + +/// Planar component samples (level-shifted to −128..127) with edge replication on read. +struct Plane { + w: usize, + h: usize, + data: Vec, +} + +impl Plane { + #[inline] + fn block(&self, bx: usize, by: usize, out: &mut [f32; 64]) { + let (x0, y0) = (bx * 8, by * 8); + if x0 + 8 <= self.w && y0 + 8 <= self.h { + for y in 0..8 { + let i = (y0 + y) * self.w + x0; + out[y * 8..y * 8 + 8].copy_from_slice(&self.data[i..i + 8]); + } + return; + } + for y in 0..8 { + let sy = (by * 8 + y).min(self.h - 1); + let row = &self.data[sy * self.w..(sy + 1) * self.w]; + for x in 0..8 { + out[y * 8 + x] = row[(bx * 8 + x).min(self.w - 1)]; + } + } + } +} + +fn segment(out: &mut Vec, marker: u8, payload: &[u8]) { + out.extend_from_slice(&[0xFF, marker]); + out.extend_from_slice(&((payload.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(payload); +} + +fn dht(out: &mut Vec, class_id: u8, bits: &[u8; 16], vals: &[u8]) { + let mut p = vec![class_id]; + p.extend_from_slice(bits); + p.extend_from_slice(vals); + segment(out, 0xC4, &p); +} + +/// Encode interleaved 8-bit samples (`channels` 1, 3 or 4; alpha is dropped), with Huffman +/// tables made for the image. +pub fn encode( + data: &[u8], + width: usize, + height: usize, + channels: usize, + quality: u8, + sub: ChromaSubsampling, + app_segments: &[(u8, Vec)], +) -> Vec { + encode_with(data, width, height, channels, quality, sub, app_segments, true) +} + +/// Encode an 8-bit grayscale, RGB, or RGBA image as an interleaved baseline JPEG with per-image +/// optimized Huffman tables. Alpha is ignored. Metadata APP segments are supplied as `(marker, +/// payload)` pairs. +pub fn encode_baseline( + data: &[u8], + width: usize, + height: usize, + channels: usize, + quality: u8, + sub: ChromaSubsampling, + app_segments: &[(u8, Vec)], +) -> Result, EncodeError> { + let fits = width.checked_mul(height).and_then(|n| n.checked_mul(channels)).is_some_and(|n| n <= data.len()); + if !fits || width == 0 || height == 0 || width > 65_535 || height > 65_535 || !matches!(channels, 1 | 3 | 4) { + return Err(EncodeError::InvalidInput(format!("{width}x{height}, {channels} channels, {} bytes", data.len()))); + } + if app_segments.iter().any(|(marker, payload)| !(0xE0..=0xEF).contains(marker) || payload.len() > 65_533) { + return Err(EncodeError::InvalidInput("APP markers must be 0xE0..=0xEF with payloads no larger than 65533 bytes".into())); + } + Ok(encode(data, width, height, channels, quality, sub, app_segments)) +} + +/// [`encode`], with optimized Huffman tables or (`optimize` false) the Annex K example tables. +#[allow(clippy::too_many_arguments)] +fn encode_with( + data: &[u8], + width: usize, + height: usize, + channels: usize, + quality: u8, + sub: ChromaSubsampling, + app_segments: &[(u8, Vec)], + optimize: bool, +) -> Vec { + // The public entry takes caller-sized dimensions: refuse (an empty output) what would size an allocation from + // them or index past `data`, rather than panic or allocate for them. + let fits = width.checked_mul(height).and_then(|n| n.checked_mul(channels)).is_some_and(|n| n <= data.len()); + if !fits || width == 0 || height == 0 || width > 65_535 || height > 65_535 || !matches!(channels, 1 | 3 | 4) { + return Vec::new(); + } + let gray = channels == 1; + let (hs, vs) = match (gray, sub) { + (true, _) | (false, ChromaSubsampling::S444) => (1usize, 1usize), + (false, ChromaSubsampling::S422) => (2, 1), + (false, ChromaSubsampling::S420) => (2, 2), + }; + #[cfg(feature = "parallel")] + use rayon::prelude::*; + // colour conversion (JFIF YCbCr, full range, level-shifted) of the pixel rows `rows`, one band's worth at a + // time (in the band's task: the planes of the whole image would be ~290 MB at 24 MP, written and read back + // once): 4:4:4 Y, Cb, Cr at full resolution in one pass; 4:2:0 Y, and chroma (rows `crows` of the + // half-size planes) straight from 2×2 RGB means (conversion is linear: average first, then convert once) + let ncomp = if gray || hs == 2 { 1 } else { 3 }; + let (cw, ch) = (width.div_ceil(hs), height.div_ceil(vs)); + let convert = |rows: std::ops::Range, crows: std::ops::Range| -> Vec { + let mut full: Vec> = (0..ncomp).map(|_| vec![0f32; rows.len() * width]).collect(); + for (k, y) in rows.clone().enumerate() { + let src = &data[y * width * channels..(y + 1) * width * channels]; + let mut out: Vec<&mut [f32]> = full.iter_mut().map(|p| &mut p[k * width..(k + 1) * width]).collect(); + if gray { + for (o, p) in out[0].iter_mut().zip(src.chunks_exact(channels)) { + *o = p[0] as f32 - 128.0; + } + continue; + } + if out.len() == 1 { + for (o, p) in out[0].iter_mut().zip(src.chunks_exact(channels)) { + *o = 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32 - 128.0; + } + continue; + } + for (x, p) in src.chunks_exact(channels).enumerate() { + let (r, g, b) = (p[0] as f32, p[1] as f32, p[2] as f32); + out[0][x] = 0.299 * r + 0.587 * g + 0.114 * b - 128.0; + out[1][x] = -0.168_736 * r - 0.331_264 * g + 0.5 * b; + out[2][x] = 0.5 * r - 0.418_688 * g - 0.081_312 * b; + } + } + let mut planes: Vec = full.into_iter().map(|data| Plane { w: width, h: rows.len(), data }).collect(); + if !gray && hs == 2 { + let (mut cb, mut cr) = (vec![0f32; cw * crows.len()], vec![0f32; cw * crows.len()]); + for (cy, (ob, or)) in crows.clone().zip(cb.chunks_mut(cw).zip(cr.chunks_mut(cw))) { + for cx in 0..cw { + let s = |k: usize| { + let mut sum = 0.0; + for dy in 0..vs { + for dx in 0..hs { + let y = (cy * vs + dy).min(height - 1); + let x = (cx * hs + dx).min(width - 1); + sum += data[(y * width + x) * channels + k] as f32; + } + } + sum / (hs * vs) as f32 + }; + let (r, g, b) = (s(0), s(1), s(2)); + ob[cx] = -0.168_736 * r - 0.331_264 * g + 0.5 * b; + or[cx] = 0.5 * r - 0.418_688 * g - 0.081_312 * b; + } + } + planes.push(Plane { w: cw, h: crows.len(), data: cb }); + planes.push(Plane { w: cw, h: crows.len(), data: cr }); + } + planes + }; + + let ql = scaled_quant(&Q_LUMA, quality); + let qc = scaled_quant(&Q_CHROMA, quality); + let (rl, rc) = (recip_table(&ql), recip_table(&qc)); + let (mcu_w, mcu_h) = (8 * hs, 8 * vs); + let (mx, my) = (width.div_ceil(mcu_w), height.div_ceil(mcu_h)); + // restart interval: whole MCU rows per band, ≤ 65535 MCUs, enough bands to keep all cores busy + let rows_per_band = (my / 64).clamp(1, (65_535 / mx.max(1)).max(1)); + let bands: Vec = (0..my.div_ceil(rows_per_band)).collect(); + // first pass: each band's quantized blocks as symbols, with their counts per table (DC + // prediction restarts per band, as the restart markers say) + let tokenize_band = |band: &usize| -> (Vec, [Freq; 4]) { + let (mut blk, mut q) = ([0f32; 64], [0i16; 64]); + let (mut tokens, mut freq) = (Vec::new(), [[0u64; 256]; 4]); + let mut pred = [0i32; 3]; + let (m0, m1) = (band * rows_per_band, ((band + 1) * rows_per_band).min(my)); + // the band's planes, its MCU row `m0` at their top (blocks past the image's bottom repeat its last row, + // which is the band's last row then) + let planes = convert(m0 * mcu_h..(m1 * mcu_h).min(height), m0 * 8..(m1 * 8).min(ch)); + for mcu_y in m0..m1 { + let mcu_y = mcu_y - m0; + for mcu_x in 0..mx { + for vy in 0..vs { + for hx in 0..hs { + planes[0].block(mcu_x * hs + hx, mcu_y * vs + vy, &mut blk); + fdct_quant(&blk, &rl, &mut q); + tokenize_block(&q, &mut pred[0], false, &mut tokens, &mut freq); + } + } + if !gray { + for (plane, pred) in planes.iter().zip(pred.iter_mut()).take(3).skip(1) { + plane.block(mcu_x, mcu_y, &mut blk); + fdct_quant(&blk, &rc, &mut q); + tokenize_block(&q, pred, true, &mut tokens, &mut freq); + } + } + } + } + (tokens, freq) + }; + #[cfg(feature = "parallel")] + let tokenized: Vec<(Vec, [Freq; 4])> = bands.par_iter().map(tokenize_band).collect(); + #[cfg(not(feature = "parallel"))] + let tokenized: Vec<(Vec, [Freq; 4])> = bands.iter().map(tokenize_band).collect(); + // the tables: DC luma, AC luma, DC chroma, AC chroma + let tables: [([u8; 16], Vec); 4] = if optimize { + let mut total = [[0u64; 256]; 4]; + for (_, f) in &tokenized { + for (t, f) in total.iter_mut().zip(f) { + for (t, f) in t.iter_mut().zip(f) { + *t = t.saturating_add(*f); + } + } + } + total.each_ref().map(optimal_table) + } else { + [ + (DC_LUMA_BITS, DC_VALS.to_vec()), + (AC_LUMA_BITS, AC_LUMA_VALS.to_vec()), + (DC_CHROMA_BITS, DC_VALS.to_vec()), + (AC_CHROMA_BITS, AC_CHROMA_VALS.to_vec()), + ] + }; + let huff = tables.each_ref().map(|(bits, vals)| Huff::new(bits, vals)); + // second pass: code the symbols + #[cfg(feature = "parallel")] + let coded: Vec> = tokenized.into_par_iter().map(|(t, _)| write_tokens(&t, &huff)).collect(); + #[cfg(not(feature = "parallel"))] + let coded: Vec> = tokenized.into_iter().map(|(t, _)| write_tokens(&t, &huff)).collect(); + + // headers + let mut out = Vec::with_capacity(coded.iter().map(Vec::len).sum::() + 4096); + out.extend_from_slice(&[0xFF, 0xD8]); + // JFIF APP0 first (callers may pass their own, e.g. with a print density) + if !app_segments.iter().any(|(m, _)| *m == 0xE0) { + segment(&mut out, 0xE0, &[b'J', b'F', b'I', b'F', 0, 1, 2, 0, 0, 1, 0, 1, 0, 0]); + } + for (marker, payload) in app_segments { + segment(&mut out, *marker, payload); + } + let dqt = |id: u8, q: &[u16; 64]| -> Vec { + let mut p = vec![id]; + p.extend(ZIGZAG.iter().map(|&i| q[i] as u8)); + p + }; + segment(&mut out, 0xDB, &dqt(0, &ql)); + if !gray { + segment(&mut out, 0xDB, &dqt(1, &qc)); + } + let mut sof = vec![8]; + sof.extend_from_slice(&(height as u16).to_be_bytes()); + sof.extend_from_slice(&(width as u16).to_be_bytes()); + if gray { + sof.extend_from_slice(&[1, 1, 0x11, 0]); + } else { + sof.extend_from_slice(&[3, 1, ((hs as u8) << 4) | vs as u8, 0, 2, 0x11, 1, 3, 0x11, 1]); + } + segment(&mut out, 0xC0, &sof); + let [(dlb, dlv), (alb, alv), (dcb, dcv), (acb, acv)] = &tables; + dht(&mut out, 0x00, dlb, dlv); + dht(&mut out, 0x10, alb, alv); + if !gray { + dht(&mut out, 0x01, dcb, dcv); + dht(&mut out, 0x11, acb, acv); + } + let interval = (rows_per_band * mx) as u16; + if coded.len() > 1 { + segment(&mut out, 0xDD, &interval.to_be_bytes()); + } + if gray { + segment(&mut out, 0xDA, &[1, 1, 0x00, 0, 63, 0]); + } else { + segment(&mut out, 0xDA, &[3, 1, 0x00, 2, 0x11, 3, 0x11, 0, 63, 0]); + } + let last = coded.len().saturating_sub(1); + for (i, c) in coded.iter().enumerate() { + out.extend_from_slice(c); + if i < last { + out.extend_from_slice(&[0xFF, 0xD0 + (i % 8) as u8]); + } + } + out.extend_from_slice(&[0xFF, 0xD9]); + out +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn hostile_dimensions_give_no_output_instead_of_a_panic_or_allocation() { + for (w, h, c) in [(1usize << 40, 1usize << 40, 3usize), (0, 8, 3), (70_000, 2, 3), (8, 8, 7), (8, 8, 3)] { + assert!(encode(&[0u8; 16], w, h, c, 90, ChromaSubsampling::S444, &[]).is_empty(), "{w} x {h} x {c}"); + } + } + + #[test] + fn checked_entry_point_reports_invalid_input() { + assert!(matches!(encode_baseline(&[0; 16], 8, 8, 3, 90, ChromaSubsampling::S444, &[]), Err(EncodeError::InvalidInput(_)))); + assert!(matches!( + encode_baseline(&[0; 192], 8, 8, 3, 90, ChromaSubsampling::S422, &[]), + Ok(bytes) if bytes.starts_with(&[0xFF, 0xD8]) + )); + let too_large_app = [(0xE1, vec![0; 65_534])]; + assert!(matches!(encode_baseline(&[0; 192], 8, 8, 3, 90, ChromaSubsampling::S444, &too_large_app), Err(EncodeError::InvalidInput(_)))); + } + + fn test_rgb(w: usize, h: usize) -> Vec { + let mut v = Vec::with_capacity(w * h * 3); + for y in 0..h { + for x in 0..w { + let t = ((x as f32 / 13.0).sin() * 0.5 + 0.5) * 255.0; + v.extend_from_slice(&[(x * 255 / w) as u8, (y * 255 / h) as u8, t as u8]); + } + } + v + } + + fn psnr(a: &[u8], b: &[u8]) -> f64 { + let mse = a.iter().zip(b).map(|(x, y)| (*x as f64 - *y as f64).powi(2)).sum::() / a.len() as f64; + 10.0 * (255.0f64.powi(2) / mse.max(1e-9)).log10() + } + + /// The AAN path matches the direct DCT-II definition. + #[test] + fn aan_matches_reference_dct() { + let block: [f32; 64] = std::array::from_fn(|i| ((i * 37 % 255) as f32) - 128.0); + let q = [1u16; 64]; + let mut out = [0i16; 64]; + fdct_quant(&block, &recip_table(&q), &mut out); + for v in 0..8 { + for u in 0..8 { + let c = |k: usize| if k == 0 { std::f64::consts::FRAC_1_SQRT_2 } else { 1.0 }; + let mut s = 0.0; + for y in 0..8 { + for x in 0..8 { + s += block[y * 8 + x] as f64 + * (((2 * x + 1) * u) as f64 * std::f64::consts::PI / 16.0).cos() + * (((2 * y + 1) * v) as f64 * std::f64::consts::PI / 16.0).cos(); + } + } + let f = 0.25 * c(u) * c(v) * s; + assert!((out[v * 8 + u] as f64 - f).abs() <= 0.51, "({u},{v}): {} vs {f}", out[v * 8 + u]); + } + } + } + + #[test] + fn huffman_tables_are_consistent() { + for (bits, n) in [(&AC_LUMA_BITS, AC_LUMA_VALS.len()), (&AC_CHROMA_BITS, AC_CHROMA_VALS.len()), (&DC_LUMA_BITS, 12), (&DC_CHROMA_BITS, 12)] { + assert_eq!(bits.iter().map(|&b| b as usize).sum::(), n); + } + } + + #[test] + fn decodes_with_independent_decoders_at_odd_sizes() { + for (w, h, sub) in [ + (1, 1, ChromaSubsampling::S444), + (37, 23, ChromaSubsampling::S420), + (37, 23, ChromaSubsampling::S422), + (640, 1031, ChromaSubsampling::S420), + (803, 600, ChromaSubsampling::S444), + ] { + let src = test_rgb(w, h); + let jpg = encode(&src, w, h, 3, 92, sub, &[]); + let mut d = jpeg_decoder::Decoder::new(&jpg[..]); + let px = d.decode().expect("jpeg-decoder"); + let info = d.info().unwrap(); + assert_eq!((info.width as usize, info.height as usize), (w, h)); + if w * h > 64 { + assert!(psnr(&src, &px) > 30.0, "{w}x{h} {sub:?}: psnr {}", psnr(&src, &px)); + } + let z = zune_jpeg::JpegDecoder::new(zune_jpeg::zune_core::bytestream::ZCursor::new(&jpg[..])).decode().expect("zune-jpeg"); + assert_eq!(z.len(), px.len()); + } + } + + /// The code lengths of `(bits, vals)`, checked: every listed symbol once, at most 16 bits, + /// and room left in the code space (the all-ones code is never assigned, Kraft sum < 1). + fn check_table(bits: &[u8; 16], vals: &[u8]) { + assert_eq!(bits.iter().map(|&b| b as usize).sum::(), vals.len()); + let mut seen = [false; 256]; + for &v in vals { + assert!(!seen[v as usize], "symbol {v} listed twice"); + seen[v as usize] = true; + } + let kraft: f64 = bits.iter().enumerate().map(|(i, &n)| n as f64 / 2f64.powi(i as i32 + 1)).sum(); + assert!(kraft < 1.0, "code space full: {kraft}"); + let h = Huff::new(bits, vals); + for &v in vals { + let (c, l) = h.code[v as usize]; + assert!((1..=16).contains(&l)); + assert_ne!(c as u32, (1u32 << l) - 1, "symbol {v} has the all-ones code"); + } + } + + #[test] + fn optimal_tables_are_valid_and_bounded() { + // one symbol, two, a typical spread and every symbol + let mut one = [0u64; 256]; + one[7] = 5; + let mut two = [0u64; 256]; + two[0] = 1; + two[0xF0] = 1_000_000; + let typical: Freq = std::array::from_fn(|s| if s % 3 == 0 { (s as u64 * 7919) % 1000 + 1 } else { 0 }); + let all: Freq = std::array::from_fn(|s| s as u64 + 1); + // Fibonacci counts make an unbounded Huffman tree ~40 levels deep: the 16-bit limit applies + let mut fib = [0u64; 256]; + let (mut a, mut b) = (1u64, 1u64); + for f in fib.iter_mut().take(40) { + *f = a; + (a, b) = (b, a + b); + } + for (name, f) in [("one", one), ("two", two), ("typical", typical), ("all", all), ("fib", fib), ("none", [0; 256])] { + let (bits, vals) = optimal_table(&f); + check_table(&bits, &vals); + for (s, &count) in f.iter().enumerate() { + assert_eq!(vals.contains(&(s as u8)), count > 0 || (name == "none" && s == 0), "{name}: symbol {s}"); + } + } + // more frequent symbols never get longer codes + let (bits, vals) = optimal_table(&typical); + let h = Huff::new(&bits, &vals); + for &x in &vals { + for &y in &vals { + if typical[x as usize] > typical[y as usize] { + assert!(h.code[x as usize].1 <= h.code[y as usize].1, "{x} vs {y}"); + } + } + } + // the Annex K example tables pass the same checks + check_table(&AC_LUMA_BITS, &AC_LUMA_VALS); + check_table(&DC_CHROMA_BITS, &DC_VALS); + } + + /// Issue #1373: tables made for the image give a smaller file with exactly the same pixels as + /// the Annex K example tables (the quantized coefficients are the same), at every quality and + /// subsampling, and both decoders read it. + #[test] + fn optimized_tables_shrink_files_without_changing_pixels() { + let (w, h) = (517, 333); + // gradients plus deterministic noise, like a photo's grain + let mut seed = 0x2545_f491_u32; + let src: Vec = test_rgb(w, h) + .into_iter() + .map(|v| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + v.saturating_add((seed % 23) as u8) + }) + .collect(); + for (q, sub) in [ + (100, ChromaSubsampling::S444), + (92, ChromaSubsampling::S444), + (75, ChromaSubsampling::S420), + (30, ChromaSubsampling::S420), + (85, ChromaSubsampling::S422), + ] { + let std = encode_with(&src, w, h, 3, q, sub, &[], false); + let opt = encode(&src, w, h, 3, q, sub, &[]); + assert!(opt.len() < std.len(), "q{q} {sub:?}: {} ≥ {}", opt.len(), std.len()); + let a = jpeg_decoder::Decoder::new(&std[..]).decode().expect("standard tables"); + let b = jpeg_decoder::Decoder::new(&opt[..]).decode().expect("optimized tables"); + assert_eq!(a, b, "q{q} {sub:?}: pixels differ"); + let z = zune_jpeg::JpegDecoder::new(zune_jpeg::zune_core::bytestream::ZCursor::new(&opt[..])).decode().expect("zune-jpeg"); + assert_eq!(z.len(), b.len()); + } + // grayscale too + let gray: Vec = src.iter().step_by(3).copied().collect(); + let std = encode_with(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[], false); + let opt = encode(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[]); + assert!(opt.len() < std.len()); + assert_eq!(jpeg_decoder::Decoder::new(&std[..]).decode().unwrap(), jpeg_decoder::Decoder::new(&opt[..]).decode().unwrap()); + } + + /// The bytes written for 4:4:4, 4:2:0, grayscale and RGBA input at a size cut into many bands, with partial MCUs + /// at the right and bottom edges, pinned: each band converts its own rows to YCbCr, which must not change a bit. + #[test] + fn encoded_bytes_are_pinned() { + let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3)); + let (w, h) = (1203, 1037); + let rgb = test_rgb(w, h); + let gray: Vec = rgb.as_chunks::<3>().0.iter().map(|p| p[1]).collect(); + let rgba: Vec = rgb.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(); + let got = [ + fnv(&encode(&rgb, w, h, 3, 90, ChromaSubsampling::S444, &[])), + fnv(&encode(&rgb, w, h, 3, 75, ChromaSubsampling::S420, &[])), + fnv(&encode(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[])), + fnv(&encode(&rgba, w, h, 4, 85, ChromaSubsampling::S444, &[])), + ]; + assert_eq!(got, PINNED, "{got:#x?}"); + } + /// [`round_i16`] against `f32::round` at the halves, the ends of `i16`, NaN and infinities, and a spread of every + /// magnitude (all 2^32 inputs were compared once, offline). + #[test] + fn round_i16_is_round_as_i16() { + let special = [0.5f32, -0.5, 1.5, -1.5, 2.5, -2.5, 0.499_999_97, -0.499_999_97, 32767.5, -32768.5, 32766.5, 1e9, -1e9]; + let all = special.into_iter().chain([f32::NAN, f32::INFINITY, f32::NEG_INFINITY]).chain((0..=u32::MAX).step_by(9973).map(f32::from_bits)); + for x in all { + assert_eq!(round_i16(x), x.round() as i16, "{x:e}"); + } + } + const PINNED: [u64; 4] = [0x52ba_772a_589c_1430, 0x013f_8364_821f_1dde, 0xb2a3_b5e5_aef3_7402, 0x08df_d6dc_aa2f_1c78]; + #[test] + fn grayscale_and_quality_monotone() { + let (w, h) = (300, 200); + let src: Vec = (0..w * h).map(|i| ((i % w) * 255 / w) as u8 ^ ((i / w) as u8 & 0x10)).collect(); + let lo = encode(&src, w, h, 1, 30, ChromaSubsampling::S444, &[]); + let hi = encode(&src, w, h, 1, 95, ChromaSubsampling::S444, &[]); + assert!(lo.len() < hi.len()); + let px = jpeg_decoder::Decoder::new(&hi[..]).decode().unwrap(); + assert!(psnr(&src, &px) > 35.0); + } +} diff --git a/crates/jpeg/src/lib.rs b/crates/jpeg/src/lib.rs index 185bf3ec5..50e1bd625 100644 --- a/crates/jpeg/src/lib.rs +++ b/crates/jpeg/src/lib.rs @@ -1,6 +1,5 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -//! Clean-room decoder for the JPEG processes most Rust JPEG decoders don't handle, written from -//! ITU-T T.81 (ISO/IEC 10918-1). +//! Clean-room JPEG decoding and baseline encoding, written from ITU-T T.81 (ISO/IEC 10918-1). //! //! [`decode`] supports these frames: //! @@ -1703,3 +1702,7 @@ pub use bands::{Band, Bands, Reader}; #[cfg(test)] mod tests; + +/// Baseline JPEG encoder (8-bit, Huffman-coded sequential DCT). +mod encode; +pub use encode::{ChromaSubsampling, EncodeError, encode_baseline}; From 4fa98367f8f7e177728f6948f6e71d6094957703 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 11:10:12 +0000 Subject: [PATCH 08/11] rtf: preserve legacy invalid Unicode handling --- crates/rtf/src/text.rs | 53 ++++++++++++++++++++-------------- crates/rtf/tests/read_write.rs | 16 ++++++---- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/crates/rtf/src/text.rs b/crates/rtf/src/text.rs index b1411842e..7cca12d8e 100644 --- a/crates/rtf/src/text.rs +++ b/crates/rtf/src/text.rs @@ -12,8 +12,9 @@ fn multibyte(code_page: u16) -> Option<&'static encoding_rs::Encoding> { /// else the default font's, else the document's) and how many fallback characters follow a /// `\uN` (`\ucN`, 1 when not set). /// -/// - `\uN` is one UTF-16 code unit. A high surrogate pairs only with a low surrogate in the -/// very next `\u`; every surrogate that does not pair reads as U+FFFD, as in Word. +/// - `\uN` is one UTF-16 code unit in the signed/unsigned 16-bit range. A high surrogate pairs +/// only with a low surrogate in the very next `\u`; unmatched surrogates and out-of-range +/// values are dropped, matching the historical DesignCraft reader. /// - After a `\uN`, the next `\ucN` characters are its fallback and are skipped: a text byte, /// a `\'hh`, a control word and a control symbol each count as one ([`TextDecoder::skip`]). /// The skipping ends with the group ([`TextDecoder::group`]). @@ -36,27 +37,34 @@ impl TextDecoder { } /// A held high surrogate that nothing paired with. - fn flush_high(&mut self, out: &mut String) { - if self.high.take().is_some() { - out.push('\u{FFFD}'); - } + fn flush_high(&mut self) { + self.high = None; } /// `\uN` with `uc` fallback characters to skip after it. pub fn unicode(&mut self, param: i32, uc: usize, out: &mut String) { self.finish_multibyte(out); + if !(-32_768..=65_535).contains(¶m) { + self.skip = uc; + return; + } let u = i64::from(param).rem_euclid(65_536) as u16; if (0xD800..0xDC00).contains(&u) { - self.flush_high(out); + self.flush_high(); self.high = Some(u); } else { match self.high.take() { - Some(h) if (0xDC00..0xE000).contains(&u) => out.push(char::decode_utf16([h, u]).next().and_then(Result::ok).unwrap_or('\u{FFFD}')), - Some(_) => { - out.push('\u{FFFD}'); - out.push(char::from_u32(u32::from(u)).unwrap_or('\u{FFFD}')); + Some(h) if (0xDC00..0xE000).contains(&u) => { + if let Some(c) = char::decode_utf16([h, u]).next().and_then(Result::ok) { + out.push(c); + } + } + Some(_) | None if (0xDC00..0xE000).contains(&u) => {} + Some(_) | None => { + if let Some(c) = char::from_u32(u32::from(u)) { + out.push(c); + } } - None => out.push(char::from_u32(u32::from(u)).unwrap_or('\u{FFFD}')), } } self.skip = uc; @@ -73,7 +81,7 @@ impl TextDecoder { /// A character a control word or symbol stands for (`\emdash`, `\~`). pub fn char(&mut self, c: char, out: &mut String) { self.finish_multibyte(out); - self.flush_high(out); + self.flush_high(); out.push(c); } @@ -95,7 +103,7 @@ impl TextDecoder { } fn decode(&mut self, chunk: &[u8], cp: u16, out: &mut String) { - self.flush_high(out); + self.flush_high(); if self.mb.is_none() && chunk.is_ascii() { // ASCII is itself in every code page here; with no lead byte pending, as it is. out.push_str(std::str::from_utf8(chunk).unwrap_or_default()); @@ -143,7 +151,7 @@ impl TextDecoder { /// for a partner any longer. pub fn finish(&mut self, out: &mut String) { self.finish_multibyte(out); - self.flush_high(out); + self.flush_high(); } } @@ -165,12 +173,13 @@ mod tests { assert_eq!(units(&[-10179, -8704], 0), "\u{1F600}"); assert_eq!(units(&[0xD83D, 0xDE00], 0), "\u{1F600}", "written unsigned"); assert_eq!(units(&[8364], 0), "€"); - assert_eq!(units(&[-10179], 0), "\u{FFFD}", "a lone high surrogate at the end"); - assert_eq!(units(&[-8704], 0), "\u{FFFD}", "a lone low surrogate"); - assert_eq!(units(&[-8704, -10179], 0), "\u{FFFD}\u{FFFD}", "low then high"); - assert_eq!(units(&[-10179, -10179, -8704], 0), "\u{FFFD}\u{1F600}", "a high surrogate before a pair"); - assert_eq!(units(&[-10179, 65, -8704], 0), "\u{FFFD}A\u{FFFD}"); - assert_eq!(units(&[65536 + 65, -65536 + 66], 0), "AB", "taken modulo 65536"); + assert_eq!(units(&[-10179], 0), "", "a lone high surrogate is dropped"); + assert_eq!(units(&[-8704], 0), "", "a lone low surrogate is dropped"); + assert_eq!(units(&[-8704, -10179], 0), "", "low then high are dropped"); + assert_eq!(units(&[-10179, -10179, -8704], 0), "\u{1F600}", "an unpaired high is dropped before a pair"); + assert_eq!(units(&[-10179, 65, -8704], 0), "A"); + assert_eq!(units(&[65_536 + 65, -65_536 + 66], 0), "", "out-of-range units are dropped"); + assert_eq!(units(&[i32::MAX, 65, i32::MIN], 0), "A", "extreme units are dropped"); } #[test] @@ -187,7 +196,7 @@ mod tests { d.group(&mut out); d.unicode(-8704, 1, &mut out); d.finish(&mut out); - assert_eq!(out, "\u{FFFD}x\u{FFFD}\u{FFFD}\u{2014}\u{1F600}"); + assert_eq!(out, "x\u{2014}\u{1F600}"); } #[test] diff --git a/crates/rtf/tests/read_write.rs b/crates/rtf/tests/read_write.rs index 9ef8fbfb7..32209b992 100644 --- a/crates/rtf/tests/read_write.rs +++ b/crates/rtf/tests/read_write.rs @@ -103,8 +103,8 @@ fn pictures_and_objects_are_counted_not_read() { } #[test] -fn out_of_range_unicode_wraps_and_hyphens_are_text() { - assert_eq!(text(br"{\rtf1 a\u4294967361?b}"), "a\u{FFFF}b", "clamped to i32, then modulo 65536"); +fn invalid_unicode_and_hyphens_are_compatible() { + assert_eq!(text(br"{\rtf1 a\u4294967361?b}"), "ab", "out-of-range signed parameters are dropped"); assert_eq!(text(br"{\rtf1\ansi Rock\emdash-roll, x\tab-y, a\li-720 b}"), "Rock\u{2014}-roll, x\t-y, ab"); assert_eq!(text(br"{\rtf1 {\b A\'4}B}"), "AB"); assert_eq!(text(br"{\rtf1 a\'+fb}"), "a+fb"); @@ -132,9 +132,15 @@ fn nul_is_not_text() { #[test] fn surrogate_pairs() { assert_eq!(text(br"{\rtf1\ansi\uc1 A\u-10179?\u-8704?B \u-10174?\u-8265?\u30000?}"), "A\u{1F600}B \u{20BB7}\u{7530}"); - assert_eq!(text(br"{\rtf1 A\u-10179?B\u-8704?C}"), "A\u{FFFD}B\u{FFFD}C", "half a pair is U+FFFD, as in Word"); - assert_eq!(text(br"{\rtf1 A\u-10179?\par\u-8704?C}"), "A\u{FFFD}\n\u{FFFD}C", "a pair does not span a paragraph end"); - assert_eq!(text(br"{\rtf1 A\u-10179?}"), "A\u{FFFD}"); + assert_eq!(text(br"{\rtf1 A\u-10179?B\u-8704?C}"), "ABC", "unpaired surrogates are dropped, as in the previous DesignCraft reader"); + assert_eq!(text(br"{\rtf1 A\u-10179?\par\u-8704?C}"), "A\nC", "a pair does not span a paragraph end"); + assert_eq!(text(br"{\rtf1 A\u-10179?}"), "A"); +} + +#[test] +fn out_of_range_unicode_escapes_are_dropped() { + assert_eq!(text(br"{\rtf1 a\u4294967361?b}"), "ab"); + assert_eq!(text(br"{\rtf1 a\u-4294967231?b}"), "ab"); } #[test] From 55901e1d1a674cf9ece38e24169994d7605d39a2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 11:17:30 +0000 Subject: [PATCH 09/11] jpeg: assert encoded chroma sampling factors --- crates/jpeg/src/encode.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/jpeg/src/encode.rs b/crates/jpeg/src/encode.rs index 4f3494a4c..548f4c9f9 100644 --- a/crates/jpeg/src/encode.rs +++ b/crates/jpeg/src/encode.rs @@ -659,6 +659,16 @@ mod tests { assert!(matches!(encode_baseline(&[0; 192], 8, 8, 3, 90, ChromaSubsampling::S444, &too_large_app), Err(EncodeError::InvalidInput(_)))); } + #[test] + fn requested_chroma_subsampling_is_written_in_the_frame_header() { + let src = test_rgb(19, 9); + for (sub, expected) in [(ChromaSubsampling::S444, 0x11), (ChromaSubsampling::S422, 0x21), (ChromaSubsampling::S420, 0x22)] { + let jpeg = encode(&src, 19, 9, 3, 90, sub, &[]); + let sof = jpeg.windows(2).position(|w| w == [0xFF, 0xC0]).expect("SOF0 marker"); + assert_eq!(jpeg.get(sof + 11), Some(&expected), "{sub:?}"); + } + } + fn test_rgb(w: usize, h: usize) -> Vec { let mut v = Vec::with_capacity(w * h * 3); for y in 0..h { From 9ce392cb6a30687533c4dd67c902e4bbc741749f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 11:21:01 +0000 Subject: [PATCH 10/11] jpeg: document baseline encoding --- README.md | 7 ++++--- crates/jpeg/Cargo.toml | 2 +- crates/jpeg/README.md | 45 ++++++++++++++++++++++++++++++++---------- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index aa47358da..d094847d0 100644 --- a/README.md +++ b/README.md @@ -231,9 +231,10 @@ See the [release notes](CHANGELOG.md) for included functionality and known limit sockets: session, requests of method calls with back-references, responses, ids, address templates, credentials and the judgement of HTTP answers, over any HTTP implementation. Bounded on hostile answers; what a program asks and makes of the answers stays in it. -- [`craft-jpeg`](crates/jpeg/README.md): clean-room decoder for 12-bit and arithmetic-coded - JPEG (T.81 SOF0/SOF1 at 12 bits, SOF9, SOF10), from PhotoCraft, and a frame probe for routing. - Metadata stays in each app. +- [`craft-jpeg`](crates/jpeg/README.md): clean-room JPEG decoder for 12-bit and arithmetic-coded + frames (T.81 SOF0/SOF1 at 12 bits, SOF9, SOF10), band decoding, and an optimized 8-bit baseline + encoder, from PhotoCraft; includes a frame probe for routing. Metadata interpretation stays in + each app. - [`craft-raster-codecs`](crates/raster-codecs/README.md): small raster formats, one module each, from PhotoCraft: DDS (BC1 to BC3, read and write), DICOM, FITS (read and write), IFF/ILBM (read and write), PCX, PNM/PAM/PFM (read and write) and XPM, into one flat image type. Bounded and diff --git a/crates/jpeg/Cargo.toml b/crates/jpeg/Cargo.toml index 708a36db0..fba86444d 100644 --- a/crates/jpeg/Cargo.toml +++ b/crates/jpeg/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "craft-jpeg" -description = "Clean-room JPEG decoder (ITU-T T.81): 12-bit and arithmetic-coded frames, and baseline frames a band of rows at a time." +description = "Clean-room JPEG decoder and 8-bit optimized baseline encoder (ITU-T T.81)." version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/jpeg/README.md b/crates/jpeg/README.md index 6b04cc8ea..3b72ebd06 100644 --- a/crates/jpeg/README.md +++ b/crates/jpeg/README.md @@ -1,11 +1,14 @@ # craft-jpeg -A clean-room JPEG decoder for the Craft apps, written from ITU-T T.81 (ISO/IEC 10918-1). It -covers the processes that common Rust JPEG decoders skip: 12-bit samples and arithmetic coding. -It also decodes the common sequential 8-bit files a band of rows at a time ([`Bands`](#decoding-in-bands)), -for images too large to decode into one buffer. -No `unsafe`, no panics on malformed input, and it builds for `wasm32-unknown-unknown`. The only -dependency is `thiserror`. +A clean-room JPEG decoder and 8-bit baseline encoder for the Craft apps, written from ITU-T T.81 +(ISO/IEC 10918-1). The decoder covers processes that common Rust JPEG decoders skip: 12-bit +samples and arithmetic coding. It also decodes the common sequential 8-bit files a band of rows +at a time ([`Bands`](#decoding-in-bands)), for images too large to decode into one buffer. The +baseline encoder writes per-image optimized Huffman tables and supports grayscale and YCbCr 4:4:4, +4:2:2 and 4:2:0. + +No `unsafe`, no panics on malformed input, and it builds for `wasm32-unknown-unknown`. The required +dependency is `thiserror`; the optional `parallel` feature adds Rayon for multi-core encoding. ```toml craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "" } @@ -99,6 +102,24 @@ fn use_craft_jpeg(bytes: &[u8]) -> bool { SOF11 (lossless) falls in that range and returns `Unsupported`. +## Baseline encoding + +`encode_baseline(data, width, height, channels, quality, subsampling, app_segments)` writes an +interleaved 8-bit baseline JPEG with optimized Huffman tables. `channels` is 1 (gray), 3 (RGB), or +4 (RGBA; alpha is ignored). Dimensions must be in `1..=65535`, quality is clamped to `1..=100`, +and a short sample buffer is rejected. APP segments must use markers `0xE0..=0xEF` and payloads +no larger than 65533 bytes. Invalid input returns `EncodeError::InvalidInput` instead of an empty +or malformed JPEG. + +```rust +use craft_jpeg::{ChromaSubsampling, encode_baseline}; + +let jpeg = encode_baseline(&rgb, width, height, 3, 90, ChromaSubsampling::S420, &[])?; +``` + +Enable the `parallel` Cargo feature to encode restart-interval bands concurrently. The output is +byte-stable across sequential and parallel execution. + ## Decoding in bands `Bands` decodes a sequential Huffman-coded (SOF0, SOF1) 8-bit file whose first scan holds every @@ -141,10 +162,11 @@ if reader.damaged() { /* warn: part of the image is missing */ } ## Boundaries -- In scope: decoding the frames above into samples. -- Out of scope: metadata. The decoder skips every APPn and COM segment. ICC profiles, EXIF, - XMP, Photoshop resources, resolution and the APP14 `Adobe` segment are the app's to read. - Color management and encoding also stay in the apps. +- In scope: decoding the frames above into samples and encoding 8-bit baseline JPEG. +- Out of scope: metadata interpretation. The decoder skips every APPn and COM segment. ICC + profiles, EXIF, XMP, Photoshop resources, resolution and the APP14 `Adobe` segment are the + caller's to read; the encoder accepts caller-constructed APP segments. Color management stays + with each app. ## Testing @@ -152,6 +174,9 @@ if reader.damaged() { /* warn: part of the image is missing */ } - unit tests in `src/tests.rs`: arithmetic-coded streams from a test-only encoder must decode to the coefficients that went in, and Huffman streams are compared against `zune-jpeg`. +- unit tests in `src/encode.rs`: optimized table validity and file-size reduction, independent + `jpeg-decoder` and `zune-jpeg` decoding, 4:4:4/4:2:2/4:2:0 frame-header sampling, invalid input, + and pinned output hashes for grayscale, RGB, RGBA and the existing 4:2:0 path. - `tests/conformance.rs`, on libjpeg-turbo's test images in `tests/data` (provenance and how the derived files were made: `tests/data/SOURCE.md`): - `testimgari.jpg` (SOF9) decodes to exactly the samples of its jpegtran Huffman transcode. From ea1c84290dadec636900b4d1bce107e59ca3f0d6 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 14 Nov 2026 07:18:01 -0500 Subject: [PATCH 11/11] image-codecs: list InkSet and ExtraSamples on TiffPage (#1307) * image-codecs: list InkSet and ExtraSamples on TiffPage * image-codecs: TiffPage InkSet and ExtraSamples as enums * image-codecs: InkSet::InkNames, and Invalid for undefined tag values --- crates/image-codecs/README.md | 5 +- crates/image-codecs/src/codecs/tiff_ifd.rs | 59 ++++++++++++++++++++++ crates/image-codecs/src/lib.rs | 2 +- crates/image-codecs/tests/it/tiff_pages.rs | 15 ++++++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/crates/image-codecs/README.md b/crates/image-codecs/README.md index c2ca866b3..955542c91 100644 --- a/crates/image-codecs/README.md +++ b/crates/image-codecs/README.md @@ -250,8 +250,9 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them the image: when the image fits but its extra samples don't, it opens without them and carries `DecodeWarning::ExtraSamplesDropped`. * Every directory is reachable: `tiff_info` lists the main IFD chain and its SubIFDs (tag 330) - with each page's size, kind (page, reduced-resolution copy, mask) and storage, walked with a - seen-set, a depth limit and a 10 000-directory cap; `decode_tiff_page` opens any of them. + with each page's size, kind (page, reduced-resolution copy, mask), storage, InkSet and + ExtraSamples, walked with a seen-set, a depth limit and a 10 000-directory cap; + `decode_tiff_page` opens any of them. `decode` opens the first full-resolution page, like Photoshop (thumbnails are skipped). * Banded decoding: the image is allocated once at its final size and each strip or row of tiles is decoded straight into it, in parallel on native targets, with at most one diff --git a/crates/image-codecs/src/codecs/tiff_ifd.rs b/crates/image-codecs/src/codecs/tiff_ifd.rs index 8fac8c6b4..0783cbdf3 100644 --- a/crates/image-codecs/src/codecs/tiff_ifd.rs +++ b/crates/image-codecs/src/codecs/tiff_ifd.rs @@ -41,6 +41,7 @@ pub(crate) const TAG_TILE_WIDTH: u16 = 322; pub(crate) const TAG_TILE_LENGTH: u16 = 323; pub(crate) const TAG_TILE_OFFSETS: u16 = 324; pub(crate) const TAG_SUB_IFDS: u16 = 330; +pub(crate) const TAG_INK_SET: u16 = 332; pub(crate) const TAG_EXTRA_SAMPLES: u16 = 338; pub(crate) const TAG_SAMPLE_FORMAT: u16 = 339; @@ -349,6 +350,56 @@ pub struct TiffPage { pub tiled: bool, /// `PlanarConfiguration` 2: each sample in its own plane. pub planar: bool, + /// TIFF `InkSet` (tag 332), when present. Only meaningful for photometric 5 (Separated). + pub ink_set: Option, + /// TIFF `ExtraSamples` (tag 338), one per extra sample. Empty when the tag is absent or + /// unreadable. + pub extra_samples: Vec, +} + +/// The TIFF `InkSet` tag (332) of a separated image. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InkSet { + /// 1: the samples are cyan, magenta, yellow and black. + Cmyk, + /// 2: the samples are the inks listed in the `InkNames` tag. + InkNames, + /// Any other value; TIFF 6.0 defines only 1 and 2. + Invalid(u16), +} + +impl InkSet { + fn from_tag(value: u16) -> Self { + match value { + 1 => Self::Cmyk, + 2 => Self::InkNames, + v => Self::Invalid(v), + } + } +} + +/// What one extra sample of a TIFF holds, from the `ExtraSamples` tag (338). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtraSampleKind { + /// 0: unspecified data. + Unspecified, + /// 1: associated (premultiplied) alpha. + AssociatedAlpha, + /// 2: unassociated (straight) alpha. + UnassociatedAlpha, + /// Any other value; TIFF 6.0 defines only 0, 1 and 2. + Invalid(u16), +} + +impl ExtraSampleKind { + fn from_tag(value: u16) -> Self { + match value { + 0 => Self::Unspecified, + 1 => Self::AssociatedAlpha, + 2 => Self::UnassociatedAlpha, + v => Self::Invalid(v), + } + } } /// The directory structure of a TIFF file: its byte order, BigTIFF or not, and every image @@ -405,6 +456,14 @@ fn describe(f: &File<'_>, ifd: &Ifd, parent: Option) -> TiffPage { compression: u16_of(TAG_COMPRESSION, 1), tiled: ifd.get(TAG_TILE_OFFSETS).is_some(), planar: f.tag_uint(ifd, TAG_PLANAR) == Some(2), + ink_set: f.tag_uint(ifd, TAG_INK_SET).map(|v| InkSet::from_tag(u16::try_from(v).unwrap_or(u16::MAX))), + // Capped at the sample count: a file cannot have more extra samples than samples. + extra_samples: f + .tag_uints(ifd, TAG_EXTRA_SAMPLES, u64::from(u16_of(TAG_SAMPLES, 1))) + .unwrap_or_default() + .into_iter() + .map(|v| ExtraSampleKind::from_tag(u16::try_from(v).unwrap_or(u16::MAX))) + .collect(), } } diff --git a/crates/image-codecs/src/lib.rs b/crates/image-codecs/src/lib.rs index d3ab272fa..39a035695 100644 --- a/crates/image-codecs/src/lib.rs +++ b/crates/image-codecs/src/lib.rs @@ -42,7 +42,7 @@ pub use crate::codecs::png::{ pub use crate::codecs::tiff::{ PhotoshopTags as TiffPhotoshopTags, TiffBands, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian, }; -pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind}; +pub use crate::codecs::tiff_ifd::{ExtraSampleKind, InkSet, TiffInfo, TiffPage, TiffPageKind}; pub use crate::error::CodecError; pub use crate::exif_color::{EXIF_COLOR_SPACE_SRGB, exif_color_space}; pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with}; diff --git a/crates/image-codecs/tests/it/tiff_pages.rs b/crates/image-codecs/tests/it/tiff_pages.rs index 0e589786f..b94509598 100644 --- a/crates/image-codecs/tests/it/tiff_pages.rs +++ b/crates/image-codecs/tests/it/tiff_pages.rs @@ -382,6 +382,21 @@ fn associated_alpha_is_made_straight() { assert_eq!(img.to_u16_samples().unwrap(), [32768, 32768]); } +#[test] +fn ink_set_and_extra_samples_are_listed() { + let px = [10u8, 20, 30, 40, 17, 128]; + let d = strips(1, 1, 8, 6, 5, 1, &px).tag(332, SHORT, &[2]).tag(338, SHORT, &[0, 1]); + let info = tiff_info(&build(true, false, &[d], &[0]).bytes).unwrap(); + use ExtraSampleKind::*; + assert_eq!((info.pages[0].ink_set, &info.pages[0].extra_samples[..]), (Some(InkSet::InkNames), &[Unspecified, AssociatedAlpha][..])); + let d = strips(1, 1, 8, 6, 5, 1, &px).tag(332, SHORT, &[7]).tag(338, SHORT, &[2, 5]); + let info = tiff_info(&build(true, false, &[d], &[0]).bytes).unwrap(); + assert_eq!((info.pages[0].ink_set, &info.pages[0].extra_samples[..]), (Some(InkSet::Invalid(7)), &[UnassociatedAlpha, Invalid(5)][..])); + let d = strips(1, 1, 8, 4, 5, 1, &px[..4]); + let info = tiff_info(&build(true, false, &[d], &[0]).bytes).unwrap(); + assert_eq!((info.pages[0].ink_set, &info.pages[0].extra_samples[..]), (None, &[][..])); +} + #[test] fn white_is_zero_opens_with_the_right_tones() { // #691: 00 FF 00 in a WhiteIsZero file is white, black, white.