diff --git a/crates/engine/src/tests_pattern.rs b/crates/engine/src/tests_pattern.rs index d19e2d8c2..1c5abb2d9 100644 --- a/crates/engine/src/tests_pattern.rs +++ b/crates/engine/src/tests_pattern.rs @@ -336,6 +336,35 @@ fn pdf_export_expands_pattern_fills() { assert!(rep.bytes.len() > 500); } +#[test] +fn every_raster_export_says_when_a_pattern_holds_an_image_that_could_not_be_decoded() { + // A PNG whose header reads (so it places at its size) but whose pixel data is cut short, made + // into a pattern and then cleared from the page: only the pattern's tile draws it. + let mut s = session(); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(20, 20, image::Rgba([200, 30, 30, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let idat = png.windows(4).position(|w| w == b"IDAT").unwrap(); + png.truncate(idat + 8); + let placed = s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + let placed = NodeId(placed["ids"][0].as_u64().unwrap()); + sel(&mut s, &[placed]); + s.execute("object.pattern.make", &json!({"name": "Cut", "width": 20, "height": 20})).unwrap(); + s.execute("object.pattern.done", &json!({})).unwrap(); + sel(&mut s, &[placed]); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(doc(&s).node(placed).is_none()); + let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Cut"})).unwrap(); + // The first export draws the pattern's tile, and the later ones draw it from the tile kept. + for n in 0..3 { + let r = s.execute("document.export", &json!({"format": "png", "background": "transparent"})).unwrap(); + let warnings = r["warnings"].as_array().cloned().unwrap_or_default(); + assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "export {n}: {r}"); + } +} + // ---------- repeat ---------- #[test] diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 1fc0b5a03..a2182d9d3 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -384,6 +384,10 @@ pub(crate) struct PixelEntry { /// rasters made before their objects moved: only a frame rendered while interacting draws /// it. moved: bool, + /// How many images in the content failed to decode while the raster was made + /// ([`crate::paint::decode_failures`]): each frame that draws the raster counts them + /// again ([`crate::paint::add_decode_failures`]). + failed: u64, pub(crate) stamp: u64, } @@ -1243,6 +1247,7 @@ impl Renderer { self.stats.moved += usize::from(e.moved); 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); draw_scaled(ctx, image, at, k); return; } @@ -1252,8 +1257,9 @@ impl Renderer { let px = f.px / k; let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f }; let mut off = f.offscreen_context(wu, hu); - let before = self.stats.moved; + let (before, decoded) = (self.stats.moved, crate::paint::decode_failures()); self.inside_layer(|r| paint(r, &mut off, &frame)); + let failed = crate::paint::decode_failures() - decoded; // Content drawn over moved rasters makes a moved raster. let moved = self.stats.moved > before; off.flush(); @@ -1293,7 +1299,7 @@ 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()); - self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, inputs, moved, stamp }); + self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, inputs, moved, failed, stamp }); self.trim_fx_rasters(FX_RASTER_BUDGET); } draw_scaled(ctx, image, (x0, y0), k); @@ -2010,6 +2016,29 @@ mod tests { assert_eq!(b.pixels, c.pixels); } + /// A blurred image that can't be decoded: every frame that draws it counts the failure + /// ([`crate::paint::decode_failures`]), from a new raster or from the raster kept. + #[test] + fn cached_effect_rasters_count_the_images_they_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 = vec![Effect { id: "blur.gaussian".into(), params: json!({"radius": 4.0}), visible: true }]; + 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.pixel_fx.len(), 1, "the later frames draw from the raster 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 diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 7a375462b..2c491459a 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -74,7 +74,7 @@ pub fn decode_failures() -> u64 { fn decode(bytes: &[u8]) -> Option { let img = decode_within_allowance(bytes).ok(); if img.is_none() { - DECODE_FAILURES.with(|n| n.set(n.get() + 1)); + add_decode_failures(1); } img } @@ -125,6 +125,13 @@ pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option Option { let (w, h) = img.dimensions(); diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index 0f31a9e32..d1605377a 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -89,6 +89,10 @@ struct TileEntry { sx: f64, sy: f64, pixmap: Arc, + /// How many images in the art failed to decode while the tile was drawn + /// ([`crate::paint::decode_failures`]): each frame that draws from the tile counts them + /// again ([`crate::paint::add_decode_failures`]). + failed: u64, } struct PatternCache { @@ -244,17 +248,22 @@ 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)) + .map(|e| (e.pixmap.clone(), e.sx, e.sy, e.failed)) }); let (pm, sx, sy) = match hit { - Some(h) => h, + Some((pm, sx, sy, failed)) => { + crate::paint::add_decode_failures(failed); + (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 { ctx.set_paint(missing()); return true; }; + let failed = crate::paint::decode_failures() - before; 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)); @@ -266,7 +275,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 as usize]; let (t, bytes) = (tiles.tick(), size_of::() + w as usize * h as usize * 4); - tiles.insert(key, TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone() }, bytes, t); + let entry = TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone(), failed }; + tiles.insert(key, entry, bytes, t); }); (pm, sx, sy) } @@ -577,6 +587,27 @@ mod tests { assert!(kept().1 <= budget, "{:?}", kept()); } + /// A pattern whose art is an image that can't be decoded: every frame that draws the pattern + /// counts the failure ([`crate::paint::decode_failures`]), from a new tile or from the + /// tile kept. + #[test] + fn kept_tiles_count_the_images_they_could_not_decode() { + use vectorcraft_doc::{ImageBlob, ImageObject}; + let mut d = doc_with_pattern(TileType::Grid); + // 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: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))]; + let mut r = Renderer::new(); + let start = tiles_rasterized(); + for n in 0..3 { + let before = crate::paint::decode_failures(); + frame(&mut r, &d, 1.0); + assert!(crate::paint::decode_failures() > before, "frame {n}"); + } + assert_eq!(tiles_rasterized() - start, 1, "the later frames draw from the tile kept"); + } + #[test] fn missing_pattern_is_grey() { let mut d = doc_with_pattern(TileType::Grid);