Render: leave out an object whose opacity mask lost an image

With undecoded-image marks off, an image in an opacity mask's art that
failed to draw left a hole in the mask. Without Clip, the area outside
the mask art counts as white, so the masked object showed in full.

The renderer now notes an image that fails to draw while mask art is
drawn and leaves the masked object out, as PDF export does since #4765.
This covers images in pattern tiles and in cached effect and shadow
rasters, which remember the failure and report it again when they are
reused. Nested masks pass the failure outward, and View Opacity Mask
shows no coverage for such a mask. An image still waiting to be decoded,
or drawn with the undecoded-image mark, keeps today's behavior.
This commit is contained in:
dotCooCoo
2026-10-31 07:33:49 +07:00
committed by bflatastic
parent b0ab477272
commit 44bac0d7cf
10 changed files with 328 additions and 22 deletions
+10 -3
View File
@@ -43,11 +43,12 @@ pub(super) enum KeptImage {
/// The image on its own grid, as [`Renderer::rasterize_image`] makes it, and how many images
/// in the content failed to decode while it was made ([`crate::paint::decode_failures`]):
/// each draw of the image from here counts them again ([`crate::paint::add_decode_failures`]).
Grid(std::rc::Rc<(Vec<u8>, usize, usize)>, u64),
/// The last value records whether an image was left out while making the grid.
Grid(std::rc::Rc<(Vec<u8>, usize, usize)>, u64, bool),
/// An image over [`Renderer::rasterize_image`]'s limits: the content's raster with the
/// Rasterize applied, as the first draw made it, and the transform from its pixels to
/// document space.
Raster(Arc<vello_cpu::Pixmap>, Affine),
/// document space. The remaining values record decode failures and whether an image was left out.
Raster(Arc<vello_cpu::Pixmap>, Affine, u64, bool),
}
/// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to
@@ -71,6 +72,12 @@ pub(crate) struct ShadowEntry {
/// Made for the object before it moved ([`Renderer::move_shadow`]), or made over rasters
/// made before their objects moved: only a frame rendered while interacting draws it.
pub(super) moved: bool,
/// How many images in the silhouette failed to decode while the raster was made, as
/// [`PixelEntry::failed`].
pub(super) failed: u64,
/// Whether an image in the silhouette failed to draw while the raster was made, as
/// [`PixelEntry::image_left_out`].
pub(super) image_left_out: bool,
pub(crate) stamp: u64,
}
+24 -5
View File
@@ -40,7 +40,11 @@ impl Renderer {
) {
let chain: Vec<&RasterFx> = rfx.iter().filter(content_changing).collect();
// When the first draw of this content kept its raster, that raster is drawn.
if let Some(KeptImage::Raster(image, place)) = content.kept.and_then(|k| k.image.borrow().clone()) {
if let Some(KeptImage::Raster(image, place, failed, image_left_out)) = content.kept.and_then(|k| k.image.borrow().clone()) {
crate::paint::add_decode_failures(failed);
if image_left_out {
self.image_left_out = self.image_left_out.map(|_| true);
}
draw_placed(ctx, image, f.view * place);
return;
}
@@ -131,6 +135,9 @@ impl Renderer {
let p = f.view * e.anchor;
let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k);
crate::paint::add_decode_failures(e.failed);
if e.image_left_out {
self.image_left_out = self.image_left_out.map(|_| true);
}
draw_scaled(ctx, image, at, k);
return;
}
@@ -151,23 +158,32 @@ impl Renderer {
let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f };
let line = 72.0 / ppi;
let (before, decoded) = (self.stats.moved, crate::paint::decode_failures());
let outer_image_left_out = self.image_left_out.replace(false);
let pattern_images_left_out = crate::pattern::images_left_out();
// A Rasterize first in the chain draws the content on its image's own grid instead, unless
// that image is too large: the content is drawn once.
let mut rasterized = match chain.first() {
Some(RasterFx::Rasterize(r)) => {
let rect = r.image(content.bounds.unwrap_or(content.reach), line);
let kept = content.kept.and_then(|k| match &*k.image.borrow() {
Some(KeptImage::Grid(image, failed)) => {
Some(KeptImage::Grid(image, failed, image_left_out)) => {
crate::paint::add_decode_failures(*failed);
if *image_left_out {
self.image_left_out = self.image_left_out.map(|_| true);
}
Some(image.clone())
}
_ => None,
});
let image = kept.or_else(|| {
let failures = crate::paint::decode_failures();
let outer_image_left_out = self.image_left_out.replace(false);
let pattern_images_left_out = crate::pattern::images_left_out();
let image = self.rasterize_image(f, r, rect, paint).map(std::rc::Rc::new);
let left_out = self.image_left_out.unwrap_or(false) || crate::pattern::images_left_out() > pattern_images_left_out;
self.image_left_out = outer_image_left_out.map(|outer| outer || left_out);
if let (Some(k), Some(image)) = (content.kept, &image) {
*k.image.borrow_mut() = Some(KeptImage::Grid(image.clone(), crate::paint::decode_failures() - failures));
*k.image.borrow_mut() = Some(KeptImage::Grid(image.clone(), crate::paint::decode_failures() - failures, left_out));
}
image
});
@@ -190,6 +206,8 @@ impl Renderer {
off.render(&mut pm, &mut self.resources);
}
let failed = crate::paint::decode_failures() - decoded;
let image_left_out = self.image_left_out.unwrap_or(false) || crate::pattern::images_left_out() > pattern_images_left_out;
self.image_left_out = outer_image_left_out.map(|outer| outer || image_left_out);
// Content drawn over moved rasters makes a moved raster.
let moved = self.stats.moved > before;
// What the raster is made from, for its object once it moves (`move_pixel_content`): a
@@ -258,7 +276,8 @@ impl Renderer {
let rel = (x0 - p.x, y0 - p.y);
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
let node = Drawn::of(content.node, owner.as_ref());
let entry = PixelEntry { node, linear, anchor, rel, k, image: image.clone(), view: in_view, made, inputs, moved, failed, stamp };
let entry =
PixelEntry { node, linear, anchor, rel, k, image: image.clone(), view: in_view, made, inputs, moved, failed, image_left_out, stamp };
self.pixel_fx.insert(key, entry);
self.trim_fx_rasters(FX_RASTER_BUDGET);
}
@@ -266,7 +285,7 @@ impl Renderer {
match content.kept.filter(|kept| kept.image.borrow().is_none()) {
Some(kept) => {
let place = f.view.inverse() * Affine::translate((x0, y0)) * Affine::scale(1.0 / k);
*kept.image.borrow_mut() = Some(KeptImage::Raster(image.clone(), place));
*kept.image.borrow_mut() = Some(KeptImage::Raster(image.clone(), place, failed, image_left_out));
if f.view == call { draw_scaled(ctx, image, (x0, y0), k) } else { draw_placed(ctx, image, call * place) }
}
None => draw_scaled(ctx, image, (x0, y0), k),
+4 -1
View File
@@ -89,7 +89,7 @@ pub(crate) struct PixelEntry {
pub(super) anchor: vectorcraft_geom::Point,
pub(super) rel: (f64, f64),
pub(super) k: f64,
pub(super) image: std::sync::Arc<vello_cpu::Pixmap>,
pub(crate) image: std::sync::Arc<vello_cpu::Pixmap>,
/// For a raster of the content's whole reach at full size, `None`: it draws after a pan too.
/// For one cropped to the view, or made at a lower resolution, the view and the rect it was
/// made for: it draws only there, so dragging other art over the object keeps it (a wide blur,
@@ -107,6 +107,9 @@ pub(crate) struct PixelEntry {
/// ([`crate::paint::decode_failures`]): each frame that draws the raster counts them
/// again ([`crate::paint::add_decode_failures`]).
pub(super) failed: u64,
/// Whether an image in the content failed to draw while the raster was made: each frame that
/// draws the raster inside opacity-mask art notes it again.
pub(crate) image_left_out: bool,
pub(crate) stamp: u64,
}
+14 -1
View File
@@ -62,6 +62,10 @@ impl Renderer {
Some(e) => {
e.stamp = stamp;
self.stats.moved += usize::from(e.moved);
crate::paint::add_decode_failures(e.failed);
if e.image_left_out {
self.image_left_out = self.image_left_out.map(|_| true);
}
let p = f.view * e.anchor;
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved, e.view.map(|v| v.served(f.view)))
}
@@ -118,7 +122,13 @@ impl Renderer {
let ink = darkness.map_or(f.ink, |d| f.ink.darkened(d));
let crop = Frame { mt: false, view, visible, ink, ..*f };
let before = self.stats.moved;
let decoded = crate::paint::decode_failures();
let outer_image_left_out = self.image_left_out.replace(false);
let pattern_images_left_out = crate::pattern::images_left_out();
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
let failed = crate::paint::decode_failures() - decoded;
let image_left_out = self.image_left_out.unwrap_or(false) || crate::pattern::images_left_out() > pattern_images_left_out;
self.image_left_out = outer_image_left_out.map(|outer| outer || image_left_out);
// A silhouette drawn over moved rasters makes a moved shadow.
let moved = self.stats.moved > before;
off.flush();
@@ -165,7 +175,10 @@ impl Renderer {
let node = Drawn::of(n, owner);
let rel = (x0 - p.x, y0 - p.y);
let view = cropped.map(|room| ViewCrop::of(f.view, room));
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, view, made, inputs, moved, stamp });
self.shadows.insert(
key,
ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, view, made, inputs, moved, failed, image_left_out, stamp },
);
self.trim_fx_rasters(FX_RASTER_BUDGET);
(image, x0, y0, scale, moved, cropped)
}
+2
View File
@@ -147,6 +147,8 @@ pub struct Renderer {
/// The picture [`Self::flatten`] started the current context again from, with 8-bit
/// compositing: [`Self::finish`] renders the context over it.
picture: Option<Arc<Pixmap>>,
/// Whether an image failed to draw while opacity-mask art is being drawn.
image_left_out: Option<bool>,
/// Pixels of the groups being drawn offscreen (contexts and backdrop copies), and the most
/// they may hold (set from the frame when the first opens, see [`Self::group`]); both reset
/// at the start of every frame.
+26 -7
View File
@@ -93,6 +93,9 @@ struct TileEntry {
/// ([`crate::paint::decode_failures`]): each frame that draws from the tile counts them
/// again ([`crate::paint::add_decode_failures`]).
failed: u64,
/// Whether an image in the art failed to draw while the tile was drawn: each frame that draws
/// from the tile counts it again ([`images_left_out`]).
image_left_out: bool,
}
struct PatternCache {
@@ -129,6 +132,13 @@ impl PatternCache {
thread_local! {
static CACHE: RefCell<PatternCache> = RefCell::new(PatternCache::default());
static IMAGES_LEFT_OUT: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// How many pattern tiles drawn on this thread so far left out an image of their art: opacity-mask
/// art compares the count before and after it draws ([`Renderer::mask_values`]).
pub(crate) fn images_left_out() -> u64 {
IMAGES_LEFT_OUT.with(std::cell::Cell::get)
}
fn same_art(a: &[Arc<Node>], b: &[Arc<Node>]) -> bool {
@@ -159,12 +169,14 @@ pub(crate) fn raster_scale(s: f64, period: (f64, f64)) -> f64 {
/// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap, colours as
/// `ink` paints them.
fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16, ink: Ink) -> Pixmap {
fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16, ink: Ink) -> (Pixmap, bool) {
let (w, h) = (w.max(1), h.max(1));
let mut ctx = crate::single_threaded_context(w, h);
let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9))
* Affine::translate(-region.origin().to_vec2());
let opts = RenderOptions::default();
r.image_left_out = Some(false);
let pattern_images_left_out = images_left_out();
r.check_plugins();
r.check_symbols(doc);
let symbols = doc.symbol_boxes().quick();
@@ -187,7 +199,7 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w
r.draw_arc(&mut ctx, &frame, a);
}
}
r.finish(&mut ctx, r.raster)
(r.finish(&mut ctx, r.raster), r.image_left_out.take().unwrap_or(false) || images_left_out() > pattern_images_left_out)
}
/// Run `f` with the shared off-screen renderer (None when nested too deep: a pattern whose art
@@ -257,22 +269,28 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
&& std::ptr::eq(e.cms.as_ptr(), Arc::as_ptr(&cms))
&& e.deps.as_ref().is_none_or(|d| d.current(doc))
})
.map(|e| (e.pixmap.clone(), e.sx, e.sy, e.failed))
.map(|e| (e.pixmap.clone(), e.sx, e.sy, e.failed, e.image_left_out))
});
let (pm, sx, sy) = match hit {
Some((pm, sx, sy, failed)) => {
Some((pm, sx, sy, failed, image_left_out)) => {
crate::paint::add_decode_failures(failed);
if image_left_out {
IMAGES_LEFT_OUT.with(|n| n.set(n.get().saturating_add(1)));
}
(pm, sx, sy)
}
None => {
let w = (pw * scale).ceil().clamp(1.0, 4096.0) as u16;
let h = (ph * scale).ceil().clamp(1.0, 4096.0) as u16;
let before = crate::paint::decode_failures();
let Some(pm) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h, ink)) else {
let Some((pm, image_left_out)) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h, ink)) else {
ctx.set_paint(missing());
return true;
};
let failed = crate::paint::decode_failures() - before;
if image_left_out {
IMAGES_LEFT_OUT.with(|n| n.set(n.get().saturating_add(1)));
}
let pm = Arc::new(pm);
let (sx, sy) = (w as f64 / pw, h as f64 / ph);
let deps = crate::fx::Reads::of_all(def.art.iter().map(|a| &**a)).any().then(|| Deps::of(doc));
@@ -284,7 +302,8 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
let mut c = c.borrow_mut();
let tiles = &mut c.tiles[ink.index()];
let (t, bytes) = (tiles.tick(), size_of::<TileEntry>() + w as usize * h as usize * 4);
let entry = TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone(), failed };
let entry =
TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone(), failed, image_left_out };
tiles.insert(key, entry, bytes, t);
});
(pm, sx, sy)
@@ -303,7 +322,7 @@ pub fn render_pattern_swatch(doc: &Document, name: &str, size: u32) -> Option<Re
let side = w.max(h);
let region = Rect::from_center_size((w / 2.0, h / 2.0), (side, side));
let px = size.clamp(1, 1024) as u16;
let pm = with_tile_renderer(|r| rasterize(r, doc, def, region, px, px, Ink::Display))?;
let (pm, _) = with_tile_renderer(|r| rasterize(r, doc, def, region, px, px, Ink::Display))?;
Some(Rendered { width: px as u32, height: px as u32, pixels: pm.data_as_u8_slice().to_vec() })
}
+7 -1
View File
@@ -61,6 +61,7 @@ impl Renderer {
blends: PtrMap::default(),
clip_paths: vec![],
picture: None,
image_left_out: None,
offscreen_live: 0,
offscreen_budget: 0,
symbol_nodes: 0,
@@ -120,7 +121,12 @@ impl Renderer {
self.begin_frame();
// View Opacity Mask: the mask's coverage in greyscale instead of the artwork.
if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) {
let pixels = self.mask_values(&frame, m, w, h).into_iter().flat_map(|v| [v, v, v, 255]).collect();
let pixels = self
.mask_values(&frame, m, w, h)
.unwrap_or_else(|| vec![0; w as usize * h as usize])
.into_iter()
.flat_map(|v| [v, v, v, 255])
.collect();
self.end_frame();
self.stats.symbol_art = symbols.art_measured();
self.stats.micros = now().saturating_sub(start);
+2
View File
@@ -121,6 +121,8 @@ impl Renderer {
}
} else if f.opts.mark_undecoded_images && !pending && (!outline || f.doc.setup.outline_images) {
self.draw_undecoded_mark(ctx, f, im, area);
} else if !pending && !outline {
self.image_left_out = self.image_left_out.map(|_| true);
}
if outline {
let mut p = rect.to_path(0.1);
+16 -4
View File
@@ -166,9 +166,9 @@ impl Renderer {
// mask is rendered over those only.
let bounds = self.bounds_of(f.symbols, a);
let Some((r, sub)) = group::area(ctx, f, bounds) else { return };
let mask = Some(self.mask_values(&sub, m, r.w, r.h));
let Some(mask) = self.mask_values(&sub, m, r.w, r.h) else { return };
let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a));
self.group(ctx, f, Composite { mask, blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a));
self.group(ctx, f, Composite { mask: Some(mask), blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a));
return;
}
self.draw_arc_body(ctx, f, a);
@@ -176,7 +176,7 @@ impl Renderer {
/// The coverage of opacity mask `m` per output pixel (row-major): its art rendered offscreen,
/// as luminance ([`mask_value`]).
pub(super) fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Vec<u8> {
pub(super) fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Option<Vec<u8>> {
let mut mctx = f.offscreen_context(w, h);
// Mask art is a picture of its own: it takes no part in a knockout group around the object,
// and its luminance is that of its screen colours.
@@ -187,10 +187,22 @@ impl Renderer {
std::mem::take(&mut self.clip_paths),
self.picture.take(),
);
let image_left_out = self.image_left_out.replace(false);
let failures = crate::paint::decode_failures();
let pattern_images_left_out = crate::pattern::images_left_out();
self.draw_kept_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art);
// Pattern tiles are drawn without undecoded-image marks, so an image a tile left out counts
// with the marks on too.
let incomplete = self.image_left_out.unwrap_or(false)
|| crate::pattern::images_left_out() > pattern_images_left_out
|| (!f.opts.mark_undecoded_images && crate::paint::decode_failures() > failures);
self.image_left_out = image_left_out.map(|outer| outer || incomplete);
let pm = self.finish(&mut mctx, self.raster);
(self.knockout, self.nested, self.backdrop, self.clip_paths, self.picture) = outer;
pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect()
if incomplete {
return None;
}
Some(pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect())
}
/// Everything [`Self::draw_arc`] does after culling.
+223
View File
@@ -747,6 +747,229 @@ fn opacity_mask_without_clip_and_inverted() {
assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "inverted clip background → visible");
}
#[test]
fn opacity_mask_with_undecodable_image_leaves_out_object() {
use vectorcraft_doc::{Effect, ImageBlob, ImageObject, NodeKind, OpacityMask};
let mut d = Document::new(100.0, 100.0);
d.images.insert("broken-mask-image".into(), ImageBlob::new("image/png", b"not a PNG".to_vec()));
let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let image = ImageObject {
key: "broken-mask-image".into(),
width: 20,
height: 20,
xf: Affine::translate((30.0, 30.0)),
link: None,
placement: Default::default(),
pixels: None,
};
let mut image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
image.appearance.effects.push(Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 4.0}), visible: true });
let white = rect_node(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
let art = Node::group(d.alloc_id(), vec![Arc::new(white), Arc::new(image)]);
object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
let object_id = object.id;
d.insert(Some(d.layers[0].id), 0, object).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let pending = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { kept_images_only: true, ..opts.clone() });
assert_eq!(pending.pixel(70, 70), [255, 0, 0, 255], "an image awaiting decoding does not invalidate the mask");
let mut renderer = Renderer::new();
let img = renderer.render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "missing mask image leaves out the object");
let again = renderer.render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!(again.pixel(70, 70), [255, 255, 255, 255], "the cached raster keeps the mask incomplete");
let mask_view = renderer.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mask_view: Some(object_id), ..opts.clone() });
assert_eq!(mask_view.pixel(70, 70), [0, 0, 0, 255], "an incomplete mask view has no coverage");
let marked = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mark_undecoded_images: true, ..opts.clone() });
assert_eq!(marked.pixel(70, 70), [255, 0, 0, 255], "the undecoded-image mark remains part of the mask");
let kept = RenderOptions { kept_images_only: true, ..opts.clone() };
for frame in 0..2 {
let img = renderer.render(&d, 100, 100, Affine::IDENTITY, &kept);
assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "an image already known undecodable leaves out the object on frame {frame}");
}
let working = Renderer::new().render(&masked_doc(false, false), 100, 100, Affine::IDENTITY, &opts);
assert_eq!(working.pixel(80, 50), [255, 0, 0, 255], "a complete mask still draws the object");
}
#[test]
fn opacity_mask_with_missing_image_in_pattern_leaves_out_object() {
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
use vectorcraft_doc::{ImageObject, NodeKind, OpacityMask};
let mut d = Document::new(100.0, 100.0);
let image = ImageObject {
key: "missing-pattern-image".into(),
width: 20,
height: 20,
xf: Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
};
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
let mut pattern = PatternDef::new("Missing image", vec![Arc::new(image)]);
pattern.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
d.patterns.push(pattern);
let art = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), pattern_paint("Missing image"), Paint::None, 0.0);
let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
d.insert(Some(d.layers[0].id), 0, object).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
for frame in 0..2 {
let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!(img.pixel(70, 50), [255, 255, 255, 255], "frame {frame}: incomplete pattern mask leaves out the object");
}
// Pattern tiles draw no undecoded-image mark, so the object stays out with the marks on too.
let marked = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mark_undecoded_images: true, ..opts.clone() });
assert_eq!(marked.pixel(70, 50), [255, 255, 255, 255], "a tile without the mark leaves out the object");
}
#[test]
fn complete_image_mask_with_blur_reuses_cached_raster() {
use vectorcraft_doc::{Effect, ImageBlob, ImageObject, NodeKind, OpacityMask};
let mut png = Vec::new();
image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(20, 20, image::Rgba([255, 255, 255, 255])))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let mut d = Document::new(100.0, 100.0);
d.images.insert("complete-mask-image".into(), ImageBlob::new("image/png", png));
let image = ImageObject {
key: "complete-mask-image".into(),
width: 20,
height: 20,
xf: Affine::translate((30.0, 30.0)),
link: None,
placement: Default::default(),
pixels: None,
};
let mut image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
image.appearance.effects.push(Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 4.0}), visible: true });
let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let white = rect_node(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
let art = Node::group(d.alloc_id(), vec![Arc::new(white), Arc::new(image)]);
object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
d.insert(Some(d.layers[0].id), 0, object).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let first = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!(first.pixel(70, 70), [255, 0, 0, 255]);
let raster = r.pixel_fx.values().next().unwrap().image.clone();
let second = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!(second.pixels, first.pixels);
assert!(Arc::ptr_eq(&raster, &r.pixel_fx.values().next().unwrap().image), "the second frame draws the image mask from its cached effect raster");
}
#[test]
fn missing_image_mask_with_blur_stays_incomplete_from_cached_raster() {
use vectorcraft_doc::{Effect, ImageObject, NodeKind, OpacityMask};
let mut d = Document::new(100.0, 100.0);
let image = ImageObject {
key: "missing-blurred-mask-image".into(),
width: 20,
height: 20,
xf: Affine::translate((30.0, 30.0)),
link: None,
placement: Default::default(),
pixels: None,
};
let mut image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
image.appearance.effects.push(Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 4.0}), visible: true });
let white = rect_node(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
let art = Node::group(d.alloc_id(), vec![Arc::new(white), Arc::new(image)]);
let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
d.insert(Some(d.layers[0].id), 0, object).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let first = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!(first.pixel(70, 70), [255, 255, 255, 255]);
let raster = r.pixel_fx.values().next().unwrap().image.clone();
let second = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!(second.pixel(70, 70), [255, 255, 255, 255]);
assert!(Arc::ptr_eq(&raster, &r.pixel_fx.values().next().unwrap().image), "the incomplete raster is reused");
}
#[test]
fn missing_image_mask_with_rasterize_and_shadow_keeps_incomplete_content_raster() {
use vectorcraft_doc::{Effect, ImageObject, NodeKind, OpacityMask};
let mut d = Document::new(100.0, 100.0);
let image = ImageObject {
key: "missing-rasterized-mask-image".into(),
width: 20,
height: 20,
xf: Affine::translate((30.0, 30.0)),
link: None,
placement: Default::default(),
pixels: None,
};
let mut image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
image.appearance.effects = [
("rasterize", serde_json::json!({"background": "white"})),
("stylize.dropShadow", serde_json::json!({"mode": "normal", "opacity": 100, "x": 2, "y": 2, "blur": 1, "color": "#0000ff"})),
("blur.gaussian", serde_json::json!({"radius": 4.0})),
]
.into_iter()
.map(|(id, params)| Effect { id: id.into(), params, visible: true })
.collect();
let white = rect_node(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
let art = Node::group(d.alloc_id(), vec![Arc::new(white), Arc::new(image)]);
let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
d.insert(Some(d.layers[0].id), 0, object).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let mut first_raster = None;
for frame in 0..2 {
let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "frame {frame}: the incomplete mask leaves out the object");
let raster = r.pixel_fx.values().next().unwrap();
assert!(raster.image_left_out, "frame {frame}: the content raster records the missing image");
if let Some(first) = &first_raster {
assert!(Arc::ptr_eq(first, &raster.image), "the second frame reuses the incomplete content raster");
} else {
first_raster = Some(raster.image.clone());
}
}
}
#[test]
fn undecodable_image_in_nested_opacity_mask_leaves_out_outer_object() {
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, OpacityMask};
let mut d = Document::new(100.0, 100.0);
d.images.insert("broken-nested-mask-image".into(), ImageBlob::new("image/png", b"not a nested PNG".to_vec()));
let mut outer = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let mut inner = rect_node(&mut d, Rect::new(20.0, 20.0, 80.0, 80.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
let image = ImageObject {
key: "broken-nested-mask-image".into(),
width: 20,
height: 20,
xf: Affine::translate((30.0, 30.0)),
link: None,
placement: Default::default(),
pixels: None,
};
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
inner.extras_mut().mask = Some(Box::new(OpacityMask::new(image, false)));
let art = Node::group(d.alloc_id(), vec![Arc::new(inner)]);
outer.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
d.insert(Some(d.layers[0].id), 0, outer).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "the incomplete inner mask invalidates the outer mask");
}
/// #3580: each opacity mask is rendered over its object's pixels, not over the whole frame. 300
/// masked 4 × 4 pt squares on a 1000 × 1000 page draw offscreen in proportion to their bounds, and
/// still draw where their masks show them.