Merge pull request #4765 from dotCooCoo/fix/pdf-colorize-mask-failure

PDF export: leave out an object whose opacity mask lost an image
This commit is contained in:
BFlat
2026-10-31 06:09:22 +07:00
committed by GitHub
5 changed files with 112 additions and 6 deletions
+2
View File
@@ -55,6 +55,8 @@ pub(crate) struct Exporter<'a> {
symbols: SymbolBoxes<'a>,
warnings: Vec<String>,
images: HashMap<String, Option<Image>>,
/// `Some` while opacity mask art is written: whether an image in it was left out.
image_left_out: Option<bool>,
/// The brush library, parsed when the first brushed stroke is written.
brushes: Option<Vec<vectorcraft_brush::Brush>>,
/// Whether the group being written is a knockout group (what its neutral children inherit).
+4
View File
@@ -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![],
+16 -3
View File
@@ -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<Image>, 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();
+19 -3
View File
@@ -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<krilla::mask::Mask> {
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),
+71
View File
@@ -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<String>, 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);