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);