Merge pull request #2621 from dotCooCoo/fix/pattern-cache-keeps-decode-warning

Render: every raster export warns about an undecodable image inside a pattern
This commit is contained in:
BFlat
2026-10-17 14:36:21 +07:00
committed by GitHub
4 changed files with 102 additions and 6 deletions
+29
View File
@@ -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]
+31 -2
View File
@@ -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
+8 -1
View File
@@ -74,7 +74,7 @@ pub fn decode_failures() -> u64 {
fn decode(bytes: &[u8]) -> Option<image::DynamicImage> {
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<vello_cpu::Pixm
pixmap_of(img.to_rgba8())
}
/// Count `n` images that couldn't be decoded on this thread ([`decode_failures`]). [`decode`]
/// counts each failure, and a raster kept in a cache counts the failures it was drawn with again
/// each time it is drawn.
pub(crate) fn add_decode_failures(n: u64) {
DECODE_FAILURES.with(|c| c.set(c.get() + n));
}
/// An RGBA image as a premultiplied pixmap.
fn pixmap_of(img: image::RgbaImage) -> Option<vello_cpu::Pixmap> {
let (w, h) = img.dimensions();
+34 -3
View File
@@ -89,6 +89,10 @@ struct TileEntry {
sx: f64,
sy: f64,
pixmap: Arc<Pixmap>,
/// 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::<TileEntry>() + 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);