Merge pull request #4780 from dotCooCoo/fix/render-mask-image-failure

Render: leave out an object whose opacity mask lost an image
This commit is contained in:
BFlat
2026-10-31 07:46:56 +07:00
committed by GitHub
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.