From b9d370aefff22d326a4304a36025bcce35689f62 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 20:51:55 -0700 Subject: [PATCH] Render: every raster export warns about an undecodable image inside a pattern A raster export warns that "an image couldn't be decoded and is missing from the picture" when the count of failed decodes rose while it drew. A pattern fill draws from a tile kept for each thread, and a tile taken from that cache draws none of its art. Only the export that made the tile counted the image, and later exports of the document gave no warning although their pictures lacked the image too. The raster kept for a blurred or filtered object had the same fault: the renderer that draws pattern tiles keeps those rasters from one tile to the next, so a tile drawn again took such an object from its raster and counted nothing. A kept tile and a kept effect raster now store how many decodes failed on their thread while they were made, and each draw from them adds that number to the count again. --- crates/engine/src/tests_pattern.rs | 29 +++++++++++++++++++++++ crates/render/src/fx.rs | 33 ++++++++++++++++++++++++-- crates/render/src/paint.rs | 9 +++++++- crates/render/src/pattern.rs | 37 +++++++++++++++++++++++++++--- 4 files changed, 102 insertions(+), 6 deletions(-) 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);