diff --git a/crates/engine/src/tests_pattern.rs b/crates/engine/src/tests_pattern.rs index fb97672d4..a65a115e6 100644 --- a/crates/engine/src/tests_pattern.rs +++ b/crates/engine/src/tests_pattern.rs @@ -6,6 +6,7 @@ use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; +use super::tests_menucmds::place_undecodable_png; use super::*; fn session() -> Session { @@ -400,6 +401,37 @@ fn every_raster_export_says_when_a_pattern_holds_an_image_that_could_not_be_deco } } +#[test] +fn a_raster_export_says_when_a_pattern_tile_draws_an_undecodable_image_from_its_kept_rasterize_image() { + // A PNG whose pixel data is cut short, with Rasterize, a drop shadow and a blur: the shadow + // draws the Rasterize image, and the blurred content draws the image kept from that draw. + let mut s = session(); + let placed = place_undecodable_png(&mut s); + for (effect, params) in [ + ("rasterize", json!({"background": "white"})), + ("stylize.dropShadow", json!({"x": 2, "y": 2, "blur": 1})), + ("blur.gaussian", json!({"radius": 4})), + ] { + s.execute("effect.apply", &json!({"effect": effect, "params": params, "ids": [placed.0]})).unwrap(); + } + sel(&mut s, &[placed]); + s.execute("object.pattern.make", &json!({"name": "Cut", "width": 80, "height": 40})).unwrap(); + s.execute("object.pattern.done", &json!({})).unwrap(); + sel(&mut s, &[placed]); + s.execute("edit.clear", &json!({})).unwrap(); + let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Cut"})).unwrap(); + let warns = |s: &mut Session| { + let r = s.execute("document.export", &json!({"format": "png", "background": "transparent"})).unwrap(); + r["warnings"].as_array().is_some_and(|w| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded")))) + }; + assert!(warns(&mut s), "the first export"); + // A new overlap draws a new tile of the same art, and the tile renderer draws its shadow and + // its blurred content from the rasters it kept. + s.execute("pattern.options", &json!({"name": "Cut", "overlap": {"h": "right"}})).unwrap(); + assert!(warns(&mut s), "the export after Pattern Options"); +} + // ---------- repeat ---------- #[test] diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 87ba4e1c2..05dffeef9 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -144,8 +144,10 @@ struct Kept { #[derive(Clone)] enum KeptImage { - /// The image on its own grid, as [`Renderer::rasterize_image`] makes it. - Grid(std::rc::Rc<(Vec, usize, usize)>), + /// 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), /// 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. @@ -1757,13 +1759,17 @@ impl Renderer { 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)) => Some(image.clone()), + Some(KeptImage::Grid(image, failed)) => { + crate::paint::add_decode_failures(*failed); + Some(image.clone()) + } _ => None, }); let image = kept.or_else(|| { + let failures = crate::paint::decode_failures(); let image = self.rasterize_image(f, r, rect, paint).map(std::rc::Rc::new); if let (Some(k), Some(image)) = (content.kept, &image) { - *k.image.borrow_mut() = Some(KeptImage::Grid(image.clone())); + *k.image.borrow_mut() = Some(KeptImage::Grid(image.clone(), crate::paint::decode_failures() - failures)); } image }); @@ -2757,6 +2763,38 @@ mod tests { assert_eq!(r.pixel_fx.len(), 1, "the later frames draw from the raster kept"); } + /// An image that can't be decoded, with Rasterize, a drop shadow and a blur: the shadow draws + /// the Rasterize image first, and the blurred content draws the image kept from that draw + /// ([`super::Kept`]). Every frame counts the failure, also when the shadow and the blurred + /// content are drawn from the rasters kept. + #[test] + fn rasters_drawn_from_a_kept_rasterize_image_count_the_images_it_could_not_decode() { + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind}; + let mut d = Document::new(100.0, 100.0); + // A PNG signature with nothing after it. + d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); + let im = + ImageObject { key: "cut".into(), width: 40, height: 40, xf: Affine::translate((30.0, 30.0)), link: None, placement: Default::default() }; + let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); + n.appearance.effects = [ + ("rasterize", json!({"background": "white"})), + ("stylize.dropShadow", json!({"mode": "normal", "opacity": 100, "x": 2, "y": 2, "blur": 1, "color": "#0000ff"})), + ("blur.gaussian", json!({"radius": 4.0})), + ] + .into_iter() + .map(|(id, params)| Effect { id: id.into(), params, visible: true }) + .collect(); + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + for frame in 0..3 { + let before = crate::paint::decode_failures(); + r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert!(crate::paint::decode_failures() > before, "frame {frame}"); + } + assert_eq!((r.shadows.len(), r.pixel_fx.len()), (1, 1), "the later frames draw from the rasters kept"); + } + /// While interacting, an object that only moved draws its drop shadow and filtered content /// from the rasters of an earlier frame, at the nearest whole pixel, without running the /// effects again (#1257). The frame after the interaction runs them again: it is what a fresh