Merge pull request #1317 from storytold/codex/jpeg-cmyk-app-compat-official

Preserve unmarked CMYK JPEG sample polarity
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-14 09:38:51 -05:00
commit bfea7d9800
2 files changed
+97 -8

No files matched your search

+77 -8
View File
@@ -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<Vec<u8>>,
/// Whether an Adobe APP14 marker identifies the four-component samples as inverted CMYK.
pub adobe_marker: bool,
pub adobe_transform: Option<u8>,
/// (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]);
}
}
_ => {}
}
@@ -312,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<u8>) -> Result<(Image, bool), CodecError> {
fn decode_craft_jpeg(bytes: &[u8], limits: &Limits, adobe_transform: Option<u8>, 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;
@@ -325,9 +342,19 @@ fn decode_craft_jpeg(bytes: &[u8], limits: &Limits, adobe_transform: Option<u8>)
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))
}
@@ -359,7 +386,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
{
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 {
@@ -398,8 +425,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
}
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);
@@ -1009,6 +1037,47 @@ 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_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.
+20
View File
@@ -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);