From e45c6f518e295bca5d82619413948eb4fbbc0703 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 06:02:55 -0800 Subject: [PATCH] PDF export: PDF/X-1a writes tagged gray images in DeviceGray PDF/X-1a allows device colors only, but a gray TIFF that carried a gray ICC profile kept it when no color conversion applied, and the export then failed its PDF/X check. A gray or RGB PNG or JPEG with a profile went the same way through the PDF writer. In PDF/X-1a, images are now written without the profile they carry; the other standards and plain exports keep it. --- crates/pdf/src/export/images.rs | 28 +++++++++++++++++++--------- crates/pdf/src/export/text.rs | 2 +- crates/pdf/src/images.rs | 28 +++++++++++++++++++--------- crates/pdf/src/tests/compression.rs | 17 ++++++++++++++++- 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index a44aa52526..1aaf3c3e7d 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -138,6 +138,7 @@ impl Exporter<'_> { } let doc = self.doc; let out = self.out.clone(); + let profiles = !out.standard.cmyk_only(); let img = doc.image_blob(im).filter(|_| !self.left_out.contains(key)).and_then(|blob| { let cmyk_image = blob.is_cmyk(); if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { @@ -146,12 +147,12 @@ impl Exporter<'_> { let r = if cmyk_image { crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None) } else { - crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels()) + crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles) }; if let Some(w) = r.warning { self.warn(w); } - r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit)) + r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit, profiles)) }); if img.is_none() { self.warn(UNDECODED); @@ -318,10 +319,12 @@ pub(super) fn png_8_bit(bytes: &[u8]) -> Option> { const MAX_EMBED_PIXELS: u64 = 1 << 27; /// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), -/// other formats (a TIFF) decoded with the ICC profile they carry and their 16-bit samples -/// ([`crate::images::decoded_image`]). Without `sixteen_bit` (a file before PDF 1.5), a PNG or -/// another image of 16 bits a sample is written at 8 bits ([`png_8_bit`]). -pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool) -> Option { +/// other formats (a TIFF) decoded with their 16-bit samples and, when `profiles` allows it (not in +/// PDF/X-1a), the ICC profile they carry ([`crate::images::decoded_image`]). Without `profiles`, an +/// image of any format that carries a gray or RGB profile is decoded too, and written without it. +/// Without `sixteen_bit` (a file before PDF 1.5), a PNG or another image of 16 bits a sample is +/// written at 8 bits ([`png_8_bit`]). +pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool, profiles: bool) -> Option { // The header's size first: the writer's decoders allocate what it declares, and the encoded // bytes of an image that is far larger than any the app reads may be a few kilobytes. let size = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions(); @@ -329,7 +332,14 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee return None; } let bytes = blob.bytes.as_ref().clone(); - let direct = match blob.mime.as_str() { + // The PDF writer keeps a gray or RGB image's ICC profile: without `profiles`, such an image is + // decoded and written without it. + let tagged = || { + let space = crate::images::source_icc(&bytes).and_then(|icc| vectorcraft_doc::cmyk::icc_color_space(&icc)); + matches!(space.as_ref(), Some(b"GRAY" | b"RGB ")) + }; + let mime = if !profiles && tagged() { "" } else { blob.mime.as_str() }; + let direct = match mime { "image/png" if !sixteen_bit && png_16_bit(&bytes) => png_8_bit(&bytes).and_then(|png| Image::from_png(png.into(), interpolate).ok()), "image/png" => Image::from_png(bytes.clone().into(), interpolate).ok(), "image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), interpolate).ok(), @@ -337,7 +347,7 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee "image/webp" => Image::from_webp(bytes.clone().into(), interpolate).ok(), _ => None, }; - direct.or_else(|| crate::images::decoded_image(&bytes, interpolate, sixteen_bit)) + direct.or_else(|| crate::images::decoded_image(&bytes, interpolate, sixteen_bit, profiles)) } #[cfg(test)] @@ -356,7 +366,7 @@ mod tests { let crc = !png[12..29].iter().fold(!0u32, |c, b| (0..8).fold(c ^ u32::from(*b), |c, _| if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 })); png[29..33].copy_from_slice(&crc.to_be_bytes()); - assert!(super::embed(&ImageBlob::png(png), false, true).is_none()); + assert!(super::embed(&ImageBlob::png(png), false, true, true).is_none()); } #[test] diff --git a/crates/pdf/src/export/text.rs b/crates/pdf/src/export/text.rs index 4b0b3a047a..c9ef8203c7 100644 --- a/crates/pdf/src/export/text.rs +++ b/crates/pdf/src/export/text.rs @@ -325,7 +325,7 @@ impl Exporter<'_> { let img = match self.images.get(&cache) { Some(img) => img.clone(), None => { - let img = embed(&image.blob, self.interpolate, self.sixteen_bit); + let img = embed(&image.blob, self.interpolate, self.sixteen_bit, !self.out.standard.cmyk_only()); self.images.insert(cache, img.clone()); img } diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 85cb29f503..b32f1788be 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -78,7 +78,7 @@ fn icc_for(icc: Option>, space: ImageColorspace) -> Option>> /// The ICC profile image `bytes` carries: a TIFF's tag 34675 (read by the TIFF decoder itself, /// which reads tags of any size), another format's as its decoder reads it. `None` without one. -fn source_icc(bytes: &[u8]) -> Option> { +pub(crate) fn source_icc(bytes: &[u8]) -> Option> { let reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?; if reader.format() == Some(ImageFormat::Tiff) { return vectorcraft_doc::cmyk::tiff_icc_profile(bytes); @@ -140,10 +140,11 @@ pub(crate) fn from_decoded(img: DynamicImage, icc: Option>, interpolate: Image::from_custom(raw, interpolate).ok() } -/// Image `bytes` decoded within [`limits`] with the ICC profile it carries ([`source_icc`]), as -/// [`from_decoded`] writes it. `None` when it can't be decoded. -pub(crate) fn decoded_image(bytes: &[u8], interpolate: bool, sixteen_bit: bool) -> Option { - from_decoded(decoded(bytes)?, source_icc(bytes), interpolate, sixteen_bit) +/// Image `bytes` decoded within [`limits`], with the ICC profile it carries ([`source_icc`]) when +/// `profiles` allows it (not in PDF/X-1a, which has device colours only), as [`from_decoded`] +/// writes it. `None` when it can't be decoded. +pub(crate) fn decoded_image(bytes: &[u8], interpolate: bool, sixteen_bit: bool, profiles: bool) -> Option { + from_decoded(decoded(bytes)?, profiles.then(|| source_icc(bytes)).flatten(), interpolate, sixteen_bit) } /// Are all of the RGBA pixels `rgba` grey? @@ -304,9 +305,17 @@ fn resample(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage { /// Encoded image `bytes` placed `size` points wide and high, as the Compression settings `c` ask: /// resampled when its resolution is above the threshold, compressed with ZIP or JPEG, its colours -/// converted by `convert` (colour images). `interpolate` lets viewers smooth it. An image that -/// can't be decoded is kept as it is. -pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, interpolate: bool, convert: Option<&Pixels>) -> Recoded { +/// converted by `convert` (colour images). Pixels that keep their colours keep the ICC profile +/// they carry when `profiles` allows it (not in PDF/X-1a, which has device colours only). +/// `interpolate` lets viewers smooth it. An image that can't be decoded is kept as it is. +pub(crate) fn recode( + bytes: &[u8], + size: (f64, f64), + c: &CompressionSettings, + interpolate: bool, + convert: Option<&Pixels>, + profiles: bool, +) -> Recoded { let keep = Recoded { image: None, warning: None }; let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() else { return keep }; let jpeg = reader.format() == Some(ImageFormat::Jpeg); @@ -338,7 +347,8 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in // Pixels that keep their colours keep the profile they are in; converted ones are in the // destination's. let want = if kind == Kind::Color { *b"RGB " } else { *b"GRAY" }; - let icc = convert.is_none().then(|| source_icc(bytes)).flatten().filter(|icc| vectorcraft_doc::cmyk::icc_color_space(icc) == Some(want)); + let icc = + (profiles && convert.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| vectorcraft_doc::cmyk::icc_color_space(icc) == Some(want)); let rgb_icc = icc.is_some() && kind == Kind::Color; let mut rgba = resample(img, &rule, sized(&rule, dims, size)).into_rgba8(); if kind == Kind::Mono { diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 8adf91879a..4d36e8aced 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -234,7 +234,7 @@ fn images_declaring_more_than_the_allowance_are_not_decoded() { assert_eq!(crate::images::mask_alpha(&blob, 60_000, 60_000, false), None); assert_eq!(crate::images::mask_alpha(&blob, 2, 2, false), None, "another size is not decoded at all"); let c: CompressionSettings = serde_json::from_value(json!({"gray": {"downsample": "bicubic", "ppi": 72, "abovePpi": 100}})).unwrap(); - let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None); + let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None, true); assert!(r.image.is_none(), "kept as it is"); } @@ -391,4 +391,19 @@ fn tagged_tiffs_keep_their_icc_profile() { let spaces = export(&tiff(&colour(), &rgb), json!({"output": {"conversion": "destination", "destination": destination}})); assert!(spaces.len() == 1 && spaces[0].icc.as_deref() != Some(rgb.as_slice()), "converted to {destination}: {spaces:?}"); } + // PDF/X-1a has device colours only: a tagged gray TIFF or PNG is written in DeviceGray, kept as + // it is, resampled or compressed with JPEG. + let compressions = [json!({}), json!({"gray": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}), json!({"gray": {"compression": "jpeg"}})]; + let png = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Png), "image/png"); + for (format, d) in [("TIFF", tiff(&colour().grayscale(), &gray)), ("PNG", png)] { + for compression in &compressions { + let mut settings: PdfSettings = + serde_json::from_value(json!({"standard": "pdfX1a", "compatibility": "1.3", "compression": compression.clone()})).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&d, &PdfOptions { settings, ..Default::default() }); + let spaces = image_spaces(&r.unwrap_or_else(|e| panic!("PDF/X-1a, {format}, {compression}: {e}")).bytes); + let gray = spaces.len() == 1 && spaces[0].space == "DeviceGray" && spaces[0].icc.is_none(); + assert!(gray, "PDF/X-1a, {format}, {compression}: {spaces:?}"); + } + } }