From 44bac0d7cfb47ec11332d555753e93ad5c0092d0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 16:44:48 -0700 Subject: [PATCH] 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. --- crates/render/src/fx/entries.rs | 13 +- crates/render/src/fx/pixels.rs | 29 +++- crates/render/src/fx/raster.rs | 5 +- crates/render/src/fx/renderer.rs | 15 +- crates/render/src/lib.rs | 2 + crates/render/src/pattern.rs | 33 +++- crates/render/src/renderer.rs | 8 +- crates/render/src/renderer/images.rs | 2 + crates/render/src/renderer/nodes.rs | 20 ++- crates/render/src/tests.rs | 223 +++++++++++++++++++++++++++ 10 files changed, 328 insertions(+), 22 deletions(-) diff --git a/crates/render/src/fx/entries.rs b/crates/render/src/fx/entries.rs index cd9acb109..ec223173b 100644 --- a/crates/render/src/fx/entries.rs +++ b/crates/render/src/fx/entries.rs @@ -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, usize, usize)>, u64), + /// The last value records whether an image was left out while making the grid. + Grid(std::rc::Rc<(Vec, 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, Affine), + /// document space. The remaining values record decode failures and whether an image was left out. + Raster(Arc, 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, } diff --git a/crates/render/src/fx/pixels.rs b/crates/render/src/fx/pixels.rs index 900e435bc..eb5c69801 100644 --- a/crates/render/src/fx/pixels.rs +++ b/crates/render/src/fx/pixels.rs @@ -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), diff --git a/crates/render/src/fx/raster.rs b/crates/render/src/fx/raster.rs index ca6a2fd37..70bbafec5 100644 --- a/crates/render/src/fx/raster.rs +++ b/crates/render/src/fx/raster.rs @@ -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, + pub(crate) image: std::sync::Arc, /// 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, } diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 949732c52..9512d89d6 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -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) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9343d1c2f..d5c601dc1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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>, + /// Whether an image failed to draw while opacity-mask art is being drawn. + image_left_out: Option, /// 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. diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index c8f202cbe..ab1270317 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -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 = RefCell::new(PatternCache::default()); + static IMAGES_LEFT_OUT: std::cell::Cell = 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], b: &[Arc]) -> 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::() + 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 Vec { + pub(super) fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Option> { 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. diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index df65005ed..6b6ec3e79 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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.