mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-15 03:37:03 -04:00
Export all images: CMYK JPEGs keep their colours instead of coming out as negatives (fixes #1640) (#1642)
This commit is contained in:
1 parent
a6aa05cd7b
commit
a0bb1f08a4
4 files changed
+129
-16
No files matched your search
Generated
+1
@@ -3686,6 +3686,7 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")?);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user