From 4da6a11bad93a575c94e30058c4769212254059e Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Sat, 24 Oct 2026 11:26:27 +0200 Subject: [PATCH] M1: render bounds stop at the depth the renderer draws (#1062) world_render_bounds and children_render_bounds recursed once per level with no limit, so a crafted chain of ~20,000 nested groups overflowed the stack and aborted the app on PNG/JPG/WebP export, rasterize, copy as PNG and thumbnails. A stack overflow is an abort, not a panic, so the guard can't catch it. The walk now carries a depth and stops descending at 256, the depth draw_layer draws to (MAX_EXPORT_DEPTH): bounds match what is drawn and shallower trees are unchanged. Test: render_bounds_stop_where_drawing_stops_on_very_deep_chains (aborts on the old code). --- crates/doc/src/lib.rs | 18 +++++++++++++----- crates/doc/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 70797dd8..5cda05e5 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -44,6 +44,10 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::sync::Arc; +/// Deepest descendant [`Document::world_render_bounds`] follows: as deep as the renderer draws +/// (`figcraft_render::MAX_EXPORT_DEPTH`), so a crafted chain can't overflow the stack (#1062). +const MAX_RENDER_DEPTH: usize = 256; + /// Stable node identifier, unique within a document. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(transparent)] @@ -703,12 +707,16 @@ impl Document { /// a clipping frame's own area, else that and what its visible children show (masked /// children only within their mask). A group is what its children show, grown by its effects. pub fn world_render_bounds(&self, id: NodeId) -> Option { + self.render_bounds_at(id, 0) + } + + fn render_bounds_at(&self, id: NodeId, depth: usize) -> Option { let n = self.node(id)?; let own = n.own_paint_bounds(self.world_transform(id)); - if n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children() { + if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH { return Some(own); } - Some(match (self.children_render_bounds(n), &n.kind) { + Some(match (self.children_render_bounds(n, depth + 1), &n.kind) { (Some(kids), NodeKind::Group) => n.grow_by_effects(kids), (Some(kids), _) => own.union(kids), (None, _) => own, @@ -716,15 +724,15 @@ impl Document { } /// What `n`'s visible children show, together: each mask's run within the mask's bounds. - fn children_render_bounds(&self, n: &Node) -> Option { + fn children_render_bounds(&self, n: &Node, depth: usize) -> Option { let shown = |i: &NodeId| self.node(*i).is_some_and(|c| c.visible && !c.is_mask); let mut out: Option = None; for run in self.paint_runs(n) { let ids = n.children.get(run).unwrap_or(&[]); let mask = ids.first().copied().filter(|m| self.node(*m).is_some_and(|c| c.is_mask && c.visible)); - let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.world_render_bounds(*i)).reduce(|a, b| a.union(b)); + let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth)).reduce(|a, b| a.union(b)); let r = match mask { - Some(m) => r.zip(self.world_render_bounds(m)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0), + Some(m) => r.zip(self.render_bounds_at(m, depth)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0), None => r, }; out = match (out, r) { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 1a006c26..7f64ee74 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -559,3 +559,21 @@ fn is_ancestor_holds_for_very_deep_chains() { assert!(d.is_ancestor(root, leaf)); assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..)))); } + +#[test] +fn render_bounds_stop_where_drawing_stops_on_very_deep_chains() { + // A crafted file nests groups far deeper than anything draws (#1062). The leaf counts while + // it's within the renderer's depth and is left out past it, as the renderer leaves it out. + let chain = |depth: usize| { + let mut d = Document::new("x"); + let mut parent = page(&d); + for _ in 0..depth { + parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap(); + } + d.create(parent, NodeKind::Rectangle, "R", 100.0, 100.0, 10.0, 10.0).unwrap(); + let top = d.node(page(&d)).unwrap().children[0]; + d.world_render_bounds(top).unwrap() + }; + assert_eq!(chain(MAX_RENDER_DEPTH).x1, 110.0); + assert_eq!(chain(20_000).x1, 10.0); +}