From a0bb1f08a41af92f5bf6107811df10292ea1970e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:16:45 +0200 Subject: [PATCH] Export all images: CMYK JPEGs keep their colours instead of coming out as negatives (fixes #1640) (#1642) --- Cargo.lock | 1 + crates/create/Cargo.toml | 2 + crates/create/src/extract.rs | 67 ++++++++++++++++++++++++-------- crates/create/src/tests.rs | 75 ++++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bb5d87f0d..0821810f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3686,6 +3686,7 @@ dependencies = [ "png 0.18.1", "thiserror 2.0.21", "tiff", + "zune-jpeg", ] [[package]] diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml index 7c915ae93..fa649de47 100644 --- a/crates/create/Cargo.toml +++ b/crates/create/Cargo.toml @@ -15,6 +15,8 @@ pdfcraft-fonts = { workspace = true } png = "0.18" tiff = "0.11" image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] } +# Already in the graph through image; read directly for CMYK JPEG samples, which image converts to RGB. +zune-jpeg = "0.5" thiserror = { workspace = true } # Already in the graph through lopdf; used only to read plain-text files (decode_text). encoding_rs = "0.8" diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 199dbd818..d9a96181a 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -1,6 +1,6 @@ //! Export a PDF ▸ Image ▸ Export all images, and Edit ▸ Save image as: the images that pages //! use, as files. JPEG images are written unchanged; other images, and JPEG images with -//! transparency, are decoded and written as PNG with their soft mask, stencil mask or colour +//! transparency or with CMYK samples a viewer would read inverted, are decoded and written as PNG with their soft mask, stencil mask or colour //! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, JBIG2, CCITT, separations) are //! reported, never silently left out. @@ -126,13 +126,24 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> // A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2). let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) }; let key = if mask { None } else { colour_key(doc, s) }; - if jpeg && alpha.is_none() && key.is_none() { + // Decode arrays: only inversion ([1 0] per component) is honoured, the common case. + let inverted = s + .dict + .get(b"Decode") + .map(|d| doc.resolve(d)) + .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))) + .unwrap_or(false); + // In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image + // viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as + // a negative. + let as_is_negative = jpeg && !inverted && matches!(space(doc, s.dict.get(b"ColorSpace")), Ok(Space::Cmyk)); + if jpeg && alpha.is_none() && key.is_none() && !as_is_negative { return as_is(); } let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? }; let (samples, bpc) = if jpeg { - // A JPEG decoder gives grey or RGB (CMYK and YCCK are converted); a JPEG that doesn't - // decode, or an indexed one (not valid), is written as is, without its transparency. + // Grey, RGB or CMYK samples (YCCK is converted); a JPEG that doesn't decode, or an + // indexed one (not valid), is written as is, without its transparency. let Some((samples, decoded)) = jpeg_samples(&s.raw, &space, w, h) else { return as_is() }; space = decoded; (samples, 8) @@ -153,17 +164,10 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> if alpha.is_none() { alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k)); } - if jpeg && alpha.is_none() { + if jpeg && alpha.is_none() && !as_is_negative { return as_is(); } - // Decode arrays: only inversion ([1 0] per component) is honoured, the common case. A JPEG - // decoder has already applied an Adobe JPEG's own inversion. - let invert = s - .dict - .get(b"Decode") - .map(|d| doc.resolve(d)) - .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))); - let invert = (invert.unwrap_or(false) && !jpeg) ^ mask; // a stencil mask paints its 0 samples + let invert = inverted ^ mask; // a stencil mask paints its 0 samples let max = (1u32 << bpc.min(8)) - 1; let mut rgb = Vec::with_capacity(w * h * 3); for px in samples.chunks_exact(n) { @@ -200,10 +204,13 @@ fn size(w: i64, h: i64) -> Option<(usize, usize)> { (w > 0 && h > 0 && pixels <= MAX_PIXELS).then_some((w, h)) } -/// A JPEG's samples (8 bits): grey for a grey image, else RGB, with the space they are in. +/// A JPEG's samples (8 bits): grey for a grey image, CMYK as stored for a CMYK one, else RGB, +/// with the space they are in. fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec, Space)> { - if matches!(space, Space::Indexed(..)) { - return None; + match space { + Space::Indexed(..) => return None, + Space::Cmyk => return cmyk_jpeg_samples(data, w, h).map(|px| (px, Space::Cmyk)), + _ => {} } let img = image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok()?; if img.width() as usize != w || img.height() as usize != h { @@ -215,6 +222,34 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec Option> { + use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX)); + let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options); + dec.decode_headers().ok()?; + let input = dec.input_colorspace()?; + if !matches!(input, ColorSpace::CMYK | ColorSpace::YCCK) || dec.dimensions()? != (w, h) { + return None; + } + dec.set_options(options.jpeg_set_out_colorspace(input)); + let mut px = dec.decode().ok()?; + if px.len() != w.checked_mul(h)?.checked_mul(4)? { + return None; + } + if input == ColorSpace::YCCK { + for p in px.as_chunks_mut::<4>().0 { + let (y, cb, cr) = (f32::from(p[0]), f32::from(p[1]) - 128.0, f32::from(p[2]) - 128.0); + let ink = |rgb: f32| (255.0 - rgb).round().clamp(0.0, 255.0) as u8; + p[0] = ink(y + 1.402 * cr); + p[1] = ink(y - 0.344_136 * cb - 0.714_136 * cr); + p[2] = ink(y + 1.772 * cb); + } + } + Some(px) +} + /// The colour-key `/Mask` (8.9.6.4): a [min max] pair per colour component, in sample values. fn colour_key(doc: &Document, s: &Stream) -> Option> { let m = doc.resolve(s.dict.get(b"Mask")?); diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 88ac114ac..493f60547 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -721,3 +721,78 @@ fn damaged_masks_and_sizes_never_panic() { let _ = image_file(&doc, r); } } + +/// An 8×8 baseline JPEG of one flat colour with four components, stored as `samples`, and an +/// Adobe APP14 segment with `transform` (0: CMYK, 2: YCCK). +fn flat_four_component_jpeg(samples: [u8; 4], transform: u8) -> Vec { + fn put(bits: &mut Vec, value: u32, n: u32) { + bits.extend((0..n).rev().map(|i| value >> i & 1 == 1)); + } + let mut v = vec![0xFF, 0xD8]; + v.extend_from_slice(&[0xFF, 0xEE, 0, 14, b'A', b'd', b'o', b'b', b'e', 0, 100, 0, 0, 0, 0, transform]); + v.extend_from_slice(&[0xFF, 0xDB, 0, 67, 0]); + v.extend_from_slice(&[1; 64]); + v.extend_from_slice(&[0xFF, 0xC0, 0, 20, 8, 0, 8, 0, 8, 4]); + for id in 1..=4 { + v.extend_from_slice(&[id, 0x11, 0]); + } + // DC table: categories 0 to 11 with four-bit codes equal to the category. AC table: EOB, "0". + v.extend_from_slice(&[0xFF, 0xC4, 0, 31, 0x00, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + v.extend(0..12u8); + v.extend_from_slice(&[0xFF, 0xC4, 0, 20, 0x10, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + v.extend_from_slice(&[0xFF, 0xDA, 0, 14, 4, 1, 0, 2, 0, 3, 0, 4, 0, 0, 63, 0]); + let mut bits = Vec::new(); + for s in samples { + // With every quantiser 1, a flat block's DC coefficient is 8 × (sample − 128). + let dc = (i32::from(s) - 128) * 8; + let category = 32 - dc.unsigned_abs().leading_zeros(); + put(&mut bits, category, 4); + put(&mut bits, if dc < 0 { (dc + (1 << category) - 1) as u32 } else { dc as u32 }, category); + put(&mut bits, 0, 1); + } + bits.resize(bits.len().div_ceil(8) * 8, true); + for byte in bits.chunks(8).map(|b| b.iter().fold(0u8, |a, &x| a << 1 | u8::from(x))) { + v.push(byte); + if byte == 0xFF { + v.push(0); + } + } + v.extend_from_slice(&[0xFF, 0xD9]); + v +} + +#[test] +fn cmyk_and_ycck_jpegs_export_in_the_colours_the_page_shows() { + // Issue #1640: in a PDF a CMYK JPEG's samples are ink amounts, inverted only by its /Decode, + // while JPEG decoders and image viewers read them inverted (Adobe's convention for files). + // Exported cyan came out red. YCCK samples (Y 179, Cb 171, Cr 1) are cyan ink too. + let inverting = Object::Array([1, 0, 1, 0, 1, 0, 1, 0].map(Object::Int).to_vec()); + for (what, samples, transform, decode, masked) in [ + ("CMYK", [255, 0, 0, 0], 0, None, true), + ("YCCK", [179, 171, 1, 0], 2, None, true), + ("inverted CMYK with an inverting /Decode", [0, 255, 255, 255], 0, Some(&inverting), true), + ("CMYK without a mask", [255, 0, 0, 0], 0, None, false), + ("inverted CMYK with an inverting /Decode, without a mask", [0, 255, 255, 255], 0, Some(&inverting), false), + ] { + let jpeg = flat_four_component_jpeg(samples, transform); + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut d = image_dict(8, 8, "DeviceCMYK", 8); + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + if let Some(decode) = decode { + d.set(b"Decode".to_vec(), decode.clone()); + } + if masked { + let m = doc.add(Object::Stream(Stream::from_raw(image_dict(8, 8, "DeviceGray", 8), vec![255; 64]))); + d.set(b"SMask".to_vec(), Object::Ref(m)); + } + Stream::from_raw(d, jpeg.clone()) + }); + let (ext, rgba) = exported_rgba(&doc); + assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[0] <= 6 && p[1] >= 249 && p[2] >= 249), "{what}: {:?}", &rgba[..4]); + if decode.is_some() && !masked { + // Inverted like an image file expects: still written as is. + assert_eq!((ext, &extract_images(&doc, &[0], 0).images[0].data), ("jpg", &jpeg), "{what}"); + } + } +}