mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
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:
@@ -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).
|
||||
|
||||
@@ -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![],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user