From c2a69bbdc604cdb5f4e99f6accabba4e3911aca1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 12:52:15 +0000 Subject: [PATCH 1/3] Preserve unmarked CMYK JPEG samples --- crates/image-codecs/src/codecs/jpeg.rs | 14 ++++++++++---- crates/image-codecs/tests/it/png_jpeg.rs | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/image-codecs/src/codecs/jpeg.rs b/crates/image-codecs/src/codecs/jpeg.rs index 8d613ce38..379a6792f 100644 --- a/crates/image-codecs/src/codecs/jpeg.rs +++ b/crates/image-codecs/src/codecs/jpeg.rs @@ -37,6 +37,8 @@ pub(crate) struct JpegMeta { pub dpi: Option<(f32, f32)>, /// The APP13 Photoshop image resource blocks (`8BIM` ...), segments joined, without the header. pub photoshop: Option>, + /// Whether an Adobe APP14 marker identifies the four-component samples as inverted CMYK. + pub adobe_marker: bool, pub adobe_transform: Option, /// (width, height, components) from the first SOFn. pub frame: Option<(u32, u32, u8)>, @@ -148,8 +150,11 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta { m.frame = Some((w, h, seg[5])); sof = Some((marker, seg)); } - 0xEE if seg.len() >= 12 && seg.starts_with(b"Adobe") => { - m.adobe_transform = Some(seg[11]); + 0xEE if seg.starts_with(b"Adobe") => { + m.adobe_marker = true; + if seg.len() >= 12 { + m.adobe_transform = Some(seg[11]); + } } _ => {} } @@ -398,8 +403,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result } px.truncate(expected); match input { - // Adobe-style CMYK is stored inverted (255 = no ink). - ColorSpace::CMYK => px.iter_mut().for_each(|v| *v = 255 - *v), + // Adobe-style CMYK is stored inverted (255 = no ink). Four-component JPEGs without + // APP14 are also encountered with their CMYK samples stored as ink values directly. + ColorSpace::CMYK if meta.adobe_marker => px.iter_mut().for_each(|v| *v = 255 - *v), ColorSpace::YCCK => { for p in px.as_chunks_mut::<4>().0 { let (y, cb, cr) = (p[0] as f32, p[1] as f32 - 128.0, p[2] as f32 - 128.0); diff --git a/crates/image-codecs/tests/it/png_jpeg.rs b/crates/image-codecs/tests/it/png_jpeg.rs index 0c7bc03d4..6b213ea83 100644 --- a/crates/image-codecs/tests/it/png_jpeg.rs +++ b/crates/image-codecs/tests/it/png_jpeg.rs @@ -115,6 +115,26 @@ fn jpeg_cmyk_roundtrip_with_adobe_marker() { assert!(psnr(&img, &back) > 35.0, "{}", psnr(&img, &back)); } +#[test] +fn jpeg_cmyk_without_adobe_marker_keeps_samples_as_inks() { + let img = Image::from_u8(16, 16, ChannelLayout::Cmyk, [10u8, 100, 200, 50].repeat(256)).unwrap(); + let marked = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 95, ..Default::default() }).unwrap(); + let signature = b"\xFF\xEE\x00\x0EAdobe"; + let start = marked.windows(signature.len()).position(|part| part == signature).expect("Adobe APP14 marker"); + // APP14's two marker bytes and two-byte length precede its 12-byte payload. + let mut unmarked = Vec::with_capacity(marked.len() - 16); + unmarked.extend_from_slice(&marked[..start]); + unmarked.extend_from_slice(&marked[start + 16..]); + + let adobe_pixels = decode(&marked).unwrap().data().to_vec(); + let unmarked_pixels = decode(&unmarked).unwrap().data().to_vec(); + for (adobe, raw) in adobe_pixels.as_chunks::<4>().0.iter().zip(unmarked_pixels.as_chunks::<4>().0) { + for (&ink, &stored) in adobe.iter().zip(raw) { + assert_eq!(stored, 255 - ink, "unmarked CMYK samples are direct inks"); + } + } +} + #[test] fn jpeg_ycck_decodes_to_cmyk() { let img = synth(40, 32, ChannelLayout::Cmyk, SampleType::U8, 4, 0.02); From 8f3247882d5a5e6924d20b1f2723a3b0321d4d69 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 13:26:52 +0000 Subject: [PATCH 2/3] Fix unmarked CMYK polarity on craft JPEG path --- crates/image-codecs/src/codecs/jpeg.rs | 47 +++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/image-codecs/src/codecs/jpeg.rs b/crates/image-codecs/src/codecs/jpeg.rs index 379a6792f..f7df97e4a 100644 --- a/crates/image-codecs/src/codecs/jpeg.rs +++ b/crates/image-codecs/src/codecs/jpeg.rs @@ -317,9 +317,21 @@ fn craft_jpeg_error(e: craft_jpeg::Error) -> CodecError { } } +fn undo_craft_cmyk_inversion_u8(samples: &mut [u8], enabled: bool) { + if enabled { + samples.iter_mut().for_each(|v| *v = 255 - *v); + } +} + +fn undo_craft_cmyk_inversion_u16(samples: &mut [u16], enabled: bool) { + if enabled { + samples.iter_mut().for_each(|v| *v = 65535 - *v); + } +} + /// Decode a file [`uses_craft_jpeg`] routes away from `zune-jpeg`; `damaged` says entropy-coded /// data was cut short or corrupt. -fn decode_craft_jpeg(bytes: &[u8], limits: &Limits, adobe_transform: Option) -> Result<(Image, bool), CodecError> { +fn decode_craft_jpeg(bytes: &[u8], limits: &Limits, adobe_transform: Option, adobe_marker: bool) -> Result<(Image, bool), CodecError> { let mut lim = craft_jpeg::Limits::default(); lim.max_pixels = limits.max_pixels; lim.max_alloc = limits.max_alloc; @@ -330,9 +342,19 @@ fn decode_craft_jpeg(bytes: &[u8], limits: &Limits, adobe_transform: Option) 4 => ChannelLayout::Cmyk, n => return Err(err(format!("{n} components"))), }; + // craft-jpeg returns non-YCC CMYK samples with Adobe's inversion already undone, + // regardless of whether the file actually has an Adobe marker. Unmarked four-component + // streams are also found with direct ink samples, so restore those when using this backend. + let restore_unmarked_cmyk = !adobe_marker && matches!(layout, ChannelLayout::Cmyk); let img = match d.samples { - craft_jpeg::Samples::U8(px) => Image::from_raw(d.width, d.height, layout, SampleType::U8, px)?, - craft_jpeg::Samples::U16(px) => Image::from_u16(d.width, d.height, layout, &px)?, + craft_jpeg::Samples::U8(mut px) => { + undo_craft_cmyk_inversion_u8(&mut px, restore_unmarked_cmyk); + Image::from_raw(d.width, d.height, layout, SampleType::U8, px)? + } + craft_jpeg::Samples::U16(mut px) => { + undo_craft_cmyk_inversion_u16(&mut px, restore_unmarked_cmyk); + Image::from_u16(d.width, d.height, layout, &px)? + } }; Ok((img, d.damaged)) } @@ -364,7 +386,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { return Err(err("the truncated scan is too short for the frame it declares")); } - let (mut img, damaged) = decode_craft_jpeg(bytes, limits, meta.adobe_transform)?; + let (mut img, damaged) = decode_craft_jpeg(bytes, limits, meta.adobe_transform, meta.adobe_marker)?; img.icc = meta.icc; img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, photoshop_resources: meta.photoshop, ..Default::default() }; if end == DataEnd::Truncated || damaged { @@ -1015,6 +1037,23 @@ mod tests { assert!(img.warnings.is_empty()); } + #[test] + fn unmarked_cmyk_restores_direct_ink_polarity_on_craft_backend() { + // craft-jpeg always returns non-YCC four-component samples with Adobe inversion undone. + // The adapter restores direct inks only when no APP14 marker identifies Adobe storage. + let mut u8_samples = [0, 32, 200, 255]; + undo_craft_cmyk_inversion_u8(&mut u8_samples, true); + assert_eq!(u8_samples, [255, 223, 55, 0]); + undo_craft_cmyk_inversion_u8(&mut u8_samples, false); + assert_eq!(u8_samples, [255, 223, 55, 0], "marked samples keep craft-jpeg polarity"); + + let mut u16_samples = [0, 8192, 32768, 65535]; + undo_craft_cmyk_inversion_u16(&mut u16_samples, true); + assert_eq!(u16_samples, [65535, 57343, 32767, 0]); + undo_craft_cmyk_inversion_u16(&mut u16_samples, false); + assert_eq!(u16_samples, [65535, 57343, 32767, 0], "marked samples keep craft-jpeg polarity"); + } + #[test] fn twelve_bit_jpeg_keeps_its_photoshop_resources() { // An APP13 `8BIM` block (paths, Photoshop settings) right after SOI, as on the zune-jpeg path. From 8ef708255efe58a409b484e8037edbbbf3de07f7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 13:39:46 +0000 Subject: [PATCH 3/3] test(jpeg): cover unmarked CMYK craft decoding --- crates/image-codecs/src/codecs/jpeg.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/image-codecs/src/codecs/jpeg.rs b/crates/image-codecs/src/codecs/jpeg.rs index f7df97e4a..173b4b740 100644 --- a/crates/image-codecs/src/codecs/jpeg.rs +++ b/crates/image-codecs/src/codecs/jpeg.rs @@ -1054,6 +1054,30 @@ mod tests { assert_eq!(u16_samples, [65535, 57343, 32767, 0], "marked samples keep craft-jpeg polarity"); } + #[test] + fn twelve_bit_cmyk_without_app14_keeps_direct_ink_polarity_on_craft_backend() { + let image = Image::from_u8(16, 16, ChannelLayout::Cmyk, [10u8, 100, 200, 50].repeat(256)).unwrap(); + let mut marked = crate::encode(&image, Format::Jpeg, &EncodeOptions { jpeg_quality: 95, ..Default::default() }).unwrap(); + let sof = marked.windows(2).position(|pair| pair == [0xFF, 0xC0]).expect("baseline SOF0"); + marked[sof + 1] = 0xC1; + marked[sof + 4] = 12; + assert!(uses_craft_jpeg(&marked), "12-bit SOF1 routes through craft-jpeg"); + + let app14 = b"\xFF\xEE\x00\x0EAdobe"; + let start = marked.windows(app14.len()).position(|part| part == app14).expect("Adobe APP14 marker"); + let mut unmarked = Vec::with_capacity(marked.len() - 16); + unmarked.extend_from_slice(&marked[..start]); + unmarked.extend_from_slice(&marked[start + 16..]); + assert!(uses_craft_jpeg(&unmarked)); + + let adobe = crate::decode(&marked).unwrap().to_u16_samples().unwrap(); + let direct_inks = crate::decode(&unmarked).unwrap().to_u16_samples().unwrap(); + assert_eq!(adobe.len(), direct_inks.len()); + for (&stored_adobe, &ink) in adobe.iter().zip(&direct_inks) { + assert_eq!(ink, 65535 - stored_adobe, "unmarked samples remain direct inks"); + } + } + #[test] fn twelve_bit_jpeg_keeps_its_photoshop_resources() { // An APP13 `8BIM` block (paths, Photoshop settings) right after SOI, as on the zune-jpeg path.