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);