From acc5e53c91883e4ab43aedf696060a668b844a1c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 04:12:51 -0700 Subject: [PATCH] Envelopes: a pixelated CMYK image with its own grid mask is written without /Interpolate PDF export writes a CMYK image whose opacity mask is a gray image on its own pixel grid with the mask as the image's soft mask. That path always asked viewers to smooth the image, so a pixelated piece of an envelope with Anti-Alias off was still written with /Interpolate when it had such a mask. It now takes the pixelated flag as images without a mask do, and caches the smoothed and pixelated versions apart. --- crates/pdf/src/export/images.rs | 12 +++++++---- crates/pdf/src/export/nodes.rs | 2 +- crates/pdf/src/tests/cmykimages.rs | 32 ++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index a226a4732..4b6560a85 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -47,8 +47,8 @@ impl Exporter<'_> { /// on the image's own pixel grid (an image's soft mask as it opens): the alpha is written as the /// image's soft mask, as an RGBA image's is, rather than as a luminosity soft mask over it, /// through which CoreGraphics draws CMYK images on black boxes (#2159). `None` for other - /// images and masks; cached like [`Self::load_image`]. - pub(super) fn masked_image(&mut self, im: &vectorcraft_doc::ImageObject, m: &vectorcraft_doc::OpacityMask) -> Option { + /// images and masks; cached like [`Self::load_image`], `interpolate` as there. + pub(super) fn masked_image(&mut self, im: &vectorcraft_doc::ImageObject, m: &vectorcraft_doc::OpacityMask, interpolate: bool) -> Option { let art = &m.art; let NodeKind::Image(mi) = &art.kind else { return None }; let plain = @@ -57,14 +57,18 @@ impl Exporter<'_> { return None; } let size = placed_size(im); - let cache = format!("{}\u{0}{}\u{0}{}\u{0}{:.3}x{:.3}", im.key, mi.key, m.clip, size.0, size.1); + let interpolate = interpolate && self.interpolate; + let mut cache = format!("{}\u{0}{}\u{0}{}\u{0}{:.3}x{:.3}", im.key, mi.key, m.clip, size.0, size.1); + if !interpolate && self.interpolate { + cache.push_str("\u{0}pixelated"); + } if let Some(i) = self.images.get(&cache) { return i.clone(); } let doc = self.doc; let img = doc.image_blob(im).filter(|b| b.is_cmyk()).and_then(|blob| { let alpha = crate::images::mask_alpha(doc.image_blob(mi)?, im.width, im.height, m.clip)?; - let r = crate::images::recode_cmyk(blob, size, self.compression, self.interpolate, self.out.cmyk_pixels(), Some(alpha)); + let r = crate::images::recode_cmyk(blob, size, self.compression, interpolate, self.out.cmyk_pixels(), Some(alpha)); if let (Some(w), Some(_)) = (r.warning, &r.image) { self.warn(w); } diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 0dfb2c6e8..732ac9f80 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -137,7 +137,7 @@ impl Exporter<'_> { } let mask = n.extras().mask.as_deref().filter(|m| !m.disabled); let alpha = match (&n.kind, mask) { - (NodeKind::Image(im), Some(m)) => self.masked_image(im, m), + (NodeKind::Image(im), Some(m)) => self.masked_image(im, m, !n.extras().pixelated), _ => None, }; if let Some(m) = mask diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index e7c08f5aa..8dc694e48 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -439,6 +439,38 @@ fn a_cmyk_image_masked_by_a_grey_image_of_its_grid_writes_it_as_its_smask() { assert!(String::from_utf8_lossy(&bytes).contains("/S/Luminosity"), "a luminosity soft mask"); } +/// A pixelated CMYK image (an envelope's with Anti-Alias off) masked by a grey image of its grid +/// is written without /Interpolate, as one without a mask is. +#[test] +fn a_pixelated_cmyk_image_with_its_grid_mask_asks_viewers_not_to_smooth_it() { + for pixelated in [false, true] { + let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); + d.images.insert("img".into(), ImageBlob::cmyk_tiff(&Inks::new(2, 2, flat([0u8, 102, 255, 0], 4)).unwrap()).unwrap()); + d.images.insert("alpha".into(), ImageBlob::new("image/png", png(image::GrayImage::from_raw(2, 2, vec![255, 128, 0, 64]).unwrap()))); + let image = |key: &str| { + let im = ImageObject { + key: key.into(), + width: 2, + height: 2, + xf: Affine::scale(20.0), + link: None, + placement: Default::default(), + pixels: None, + }; + NodeKind::Image(Box::new(im)) + }; + let mask = vectorcraft_doc::OpacityMask::new(Node::new(d.alloc_id(), image("alpha")), true); + let mut n = Node::new(d.alloc_id(), image("img")).with_mask(Some(Box::new(mask))); + n.extras_mut().pixelated = pixelated; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let out = export_with_report(&d, &PdfOptions { created: Some(1_700_000_000), ..PdfOptions::uncompressed() }).unwrap().bytes; + let text = String::from_utf8_lossy(&out); + assert!(!text.contains("/S/Luminosity"), "the mask is the image's /SMask"); + assert_eq!(text.contains("/Interpolate"), !pixelated, "pixelated: {pixelated}"); + } +} + /// #2950: Press Quality converts to the document's profile with its numbers kept, so in a CMYK /// document an RGB image comes out CMYK, as the press needs; with no conversion (the default /// preset) it stays RGB, as before.