From fa895463973000cf98bcc103ee950120e56ef27b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 15:31:24 -0700 Subject: [PATCH] PDF export: leave out an object whose opacity mask lost an image An unclipped opacity mask is drawn on a white backdrop. When an image in the mask art could not be written (a colorized image whose color channel is over the size limit, an image that can't be decoded, or one placed beyond the coordinates a file holds), the backdrop stayed white and the masked object showed in full where the image should hide it. The exporter now records an image left out while it writes mask art, and leaves the masked object out instead of applying the incomplete mask, with a warning. A failure in a nested mask makes the outer mask incomplete too. --- crates/pdf/src/export.rs | 2 + crates/pdf/src/export/exporter.rs | 4 ++ crates/pdf/src/export/images.rs | 19 +++++++-- crates/pdf/src/export/nodes.rs | 22 ++++++++-- crates/pdf/src/tests.rs | 71 +++++++++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 6 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d94ade86b..529396fce 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -55,6 +55,8 @@ pub(crate) struct Exporter<'a> { symbols: SymbolBoxes<'a>, warnings: Vec, images: HashMap>, + /// `Some` while opacity mask art is written: whether an image in it was left out. + image_left_out: Option, /// The brush library, parsed when the first brushed stroke is written. brushes: Option>, /// Whether the group being written is a knockout group (what its neutral children inherit). diff --git a/crates/pdf/src/export/exporter.rs b/crates/pdf/src/export/exporter.rs index c7d21793d..ac53090f5 100644 --- a/crates/pdf/src/export/exporter.rs +++ b/crates/pdf/src/export/exporter.rs @@ -14,6 +14,9 @@ pub(super) const MAX_PATTERN_DEPTH: u32 = 4; /// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). pub(super) const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; +/// The warning for an image whose placement cannot be written. +pub(super) const UNWRITABLE_IMAGE: &str = "an image couldn't be written and is missing from the picture"; + impl<'a> Exporter<'a> { /// An exporter of `doc` writing images as `set` says; layers whose Print option is off are /// left out. @@ -23,6 +26,7 @@ impl<'a> Exporter<'a> { symbols: doc.symbol_boxes().quick().with_brush_art(), warnings: vec![], images: HashMap::new(), + image_left_out: None, brushes: None, knockout: doc.page_knockout, lab_spots: vec![], diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index d6d3e5f40..79169dc14 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -1,4 +1,4 @@ -use super::exporter::{UNDECODED, q, writable, xf}; +use super::exporter::{UNDECODED, UNWRITABLE_IMAGE, q, writable, xf}; use super::{Arc, BlendMode, Color, Exporter, Image, NodeKind, Paint, Rect, Size, Surface, cmyk, krilla}; use krilla::color::device_n::{Colorant, DeviceNSpace, Process}; use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace}; @@ -197,10 +197,23 @@ impl Exporter<'_> { pub(super) fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject, img: Option, pixelated: bool) { // An image placed or sized beyond [`MAX_COORD`] is left out. if !writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) { + if self.image_left_out.is_some() { + self.image_left_out = Some(true); + self.warn(UNWRITABLE_IMAGE); + } return; } - let Some(img) = img.or_else(|| self.load_image(im, placed_size(im), !pixelated)) else { return }; - let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { return }; + let Some(img) = img.or_else(|| self.load_image(im, placed_size(im), !pixelated)) else { + self.image_left_out = self.image_left_out.map(|_| true); + return; + }; + let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { + if self.image_left_out.is_some() { + self.image_left_out = Some(true); + self.warn(UNWRITABLE_IMAGE); + } + return; + }; s.push_transform(&xf(im.xf)); s.draw_image(img, size); s.pop(); diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index f2edf9468..9740362ec 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -7,7 +7,7 @@ use super::{ impl Exporter<'_> { /// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white; /// invert is a white Difference rect on top (luminance is linear, so luma(1 − c) = 1 − luma(c)). - fn soft_mask(&mut self, s: &mut Surface, m: &vectorcraft_doc::OpacityMask, page: Rect) -> krilla::mask::Mask { + fn soft_mask(&mut self, s: &mut Surface, m: &vectorcraft_doc::OpacityMask, page: Rect) -> Option { let mut sb = s.stream_builder(); let mut ms = sb.surface(); // An opaque backdrop when it matters: white outside the art (no clip), or black for the @@ -19,15 +19,21 @@ impl Exporter<'_> { // and has no inks to overprint. let knockout = std::mem::take(&mut self.knockout); let overprint = std::mem::take(&mut self.overprint); + let image_left_out = self.image_left_out.replace(false); self.node(&mut ms, &m.art, page, true); + let incomplete = self.image_left_out.unwrap_or(false); + self.image_left_out = image_left_out.map(|outer| outer || incomplete); (self.knockout, self.overprint) = (knockout, overprint); + if incomplete { + return None; + } if m.invert { ms.push_blend_mode(krilla::blend::BlendMode::Difference); cover(&mut ms, page, 255, 1.0); ms.pop(); } ms.finish(); - krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) + Some(krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)) } /// The children of a group, as the elements of a knockout group when it is one. @@ -143,7 +149,13 @@ impl Exporter<'_> { if let Some(m) = mask && alpha.is_none() { - let mask = self.soft_mask(s, m, page); + let Some(mask) = self.soft_mask(s, m, page) else { + self.knockout = enclosing; + for _ in 0..pushes { + s.pop(); + } + return; + }; s.push_mask(mask); pushes += 1; } @@ -188,8 +200,12 @@ impl Exporter<'_> { if let Some(colorized) = self.colorized_image(im, p, !n.extras().pixelated) { self.image(s, im, Some(colorized), n.extras().pixelated); } else { + self.image_left_out = self.image_left_out.map(|_| true); self.warn(UNDECODED); } + } else if self.image_left_out.is_some() { + self.image_left_out = Some(true); + self.warn(super::exporter::UNWRITABLE_IMAGE); } } _ => self.image(s, im, alpha, n.extras().pixelated), diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e442c02d5..0b7e3acea 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -973,6 +973,77 @@ fn opacity_mask_exports_as_luminosity_soft_mask() { assert!(text.contains("/Luminosity")); } +/// The warnings and path count after exporting an image mask and a separate rectangle. +fn image_mask_report(colorized: bool, available: bool, nested: bool, unwritable: bool) -> (Vec, usize) { + let mut d = doc(100.0, 100.0); + if available { + d.images.insert("mask-image".into(), ImageBlob::png(tiny_png())); + } + let im = ImageObject { + key: "mask-image".into(), + width: 4, + height: 2, + xf: Affine::translate(if unwritable { (1e10, 1e10) } else { (10.0, 10.0) }) * Affine::scale(10.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut art = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); + if colorized { + art.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)))); + } + if nested { + let mut outer_art = rect_node(Rect::new(10.0, 10.0, 50.0, 30.0), Color::WHITE); + outer_art.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, false))); + art = outer_art; + } + let mut masked = rect_node(Rect::new(10.0, 10.0, 50.0, 30.0), Color::rgb(1.0, 0.0, 0.0)); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, false))); + add(&mut d, masked); + add(&mut d, rect_node(Rect::new(60.0, 60.0, 80.0, 80.0), Color::BLACK)); + let report = export_with_report(&d, &PdfOptions::uncompressed()).expect("export"); + let imported = import(&report.bytes).expect("import"); + let paths = leaves(&imported).into_iter().filter(|n| n.path_data().is_some()).count(); + (report.warnings, paths) +} + +#[test] +fn missing_colorized_image_in_unclipped_mask_omits_masked_object() { + let (warnings, paths) = image_mask_report(true, false, false, false); + assert!(warnings.iter().any(|w| w == "an image couldn't be decoded and is missing from the picture"), "{warnings:?}"); + assert_eq!(paths, 1); +} + +#[test] +fn missing_plain_image_in_unclipped_mask_omits_masked_object() { + let (warnings, paths) = image_mask_report(false, false, false, false); + assert!(warnings.iter().any(|w| w == "an image couldn't be decoded and is missing from the picture"), "{warnings:?}"); + assert_eq!(paths, 1); +} + +#[test] +fn missing_image_in_nested_mask_omits_outer_masked_object() { + let (warnings, paths) = image_mask_report(false, false, true, false); + assert!(warnings.iter().any(|w| w == "an image couldn't be decoded and is missing from the picture"), "{warnings:?}"); + assert_eq!(paths, 1); +} + +#[test] +fn writable_colorized_image_in_unclipped_mask_keeps_masked_object() { + let (warnings, paths) = image_mask_report(true, true, false, false); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(paths, 2); +} + +#[test] +fn unwritable_images_in_unclipped_masks_omit_masked_objects() { + for colorized in [false, true] { + let (warnings, paths) = image_mask_report(colorized, true, false, true); + assert!(warnings.iter().any(|w| w == "an image couldn't be written and is missing from the picture"), "{warnings:?}"); + assert_eq!(paths, 1, "colorized: {colorized}"); + } +} + #[test] fn empty_and_populated_file_info_preserve_key_presence() { let mut d = doc(100.0, 100.0);