Export all images: CMYK JPEGs keep their colours instead of coming out as negatives (fixes #1640) (#1642)

This commit is contained in:
Marco Arnulfo authored and GitHub committed 2026-10-13 00:16:45 -04:00
1 parent a6aa05cd7b
commit a0bb1f08a4
4 files changed
+129 -16

No files matched your search

Generated
+1
View File
@@ -3686,6 +3686,7 @@ dependencies = [
"png 0.18.1",
"thiserror 2.0.21",
"tiff",
"zune-jpeg",
]
[[package]]
+2
View File
@@ -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"
+51 -16
View File
@@ -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<u8>), 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<u8>), 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<u8>, 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<u
})
}
/// A four-component JPEG's CMYK samples as the page reads them: as stored, not inverted the way
/// a decoder converting to RGB inverts them, with YCCK converted to CMYK.
fn cmyk_jpeg_samples(data: &[u8], w: usize, h: usize) -> Option<Vec<u8>> {
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<Vec<(u32, u32)>> {
let m = doc.resolve(s.dict.get(b"Mask")?);
+75
View File
@@ -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<u8> {
fn put(bits: &mut Vec<bool>, 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}");
}
}
}