From 8117d5b710071a61fcc19b9d4d266ffe2c1df98b Mon Sep 17 00:00:00 2001 From: Katacc Date: Wed, 21 Oct 2026 17:25:19 +0300 Subject: [PATCH] arrange.group: nest groups at most 32 deep Grouping a group again and again had no limit, but a .deckcraft saved with 39 or more nested groups could not be opened again (the JSON reader's recursion limit), and past 64 levels shape walks such as format.languageAll stop. arrange.group now refuses to nest past 32, the depth the PPTX reader reads and the renderer draws, and leaves the slide unchanged. The test builds the deepest allowed deck (a table at the bottom) and checks it saves and reopens. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/arrange.rs | 19 +++++++++++++++++++ crates/engine/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index d54c160f..c95bb105 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -145,6 +145,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) | | 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) | | 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide | +| 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again | | 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) | diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index a043d987..2abb9609 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -171,6 +171,22 @@ pub(crate) fn union_bounds(xs: &[Xfrm]) -> Option { Some(it.fold(first, |a, b| a.union(b))) } +/// How deep groups may nest: as deep as the PPTX reader reads and the renderer draws. A saved +/// .deckcraft file stops opening at about 39 (the JSON reader's recursion limit), and walks over +/// the shape tree stop at 64. +pub const MAX_GROUP_DEPTH: usize = 32; + +/// How many groups deep `shapes` nest (0 without groups). +fn group_depth(shapes: &[Shape]) -> usize { + let mut d = 0; + deckcraft_model::walk(shapes, &mut |sh, depth| { + if sh.is_group() { + d = d.max(depth + 1); + } + }); + d +} + fn group(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; s.edit(|doc, sel| { @@ -179,6 +195,9 @@ fn group(s: &mut Session, p: &Value) -> Result { if members.len() < 2 { return Err(bad("arrange.group", "select two or more objects on the slide")); } + if group_depth(&members) + 1 > MAX_GROUP_DEPTH { + return Err(bad("arrange.group", format!("groups can be nested at most {MAX_GROUP_DEPTH} deep"))); + } for m in &mut members { if m.xfrm.is_none() { m.xfrm = Some(xfrm_of(doc, sel, m)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 35b0ab39..d6d0fecb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -322,6 +322,36 @@ fn group_ungroup_roundtrip_positions() { assert!((v["box"]["x"].as_f64().unwrap() - 310.0).abs() < 1e-6, "{v}"); } +/// Grouping a group again and again stops at `MAX_GROUP_DEPTH`: past about 39 levels a saved +/// .deckcraft file no longer opens, and past 64 commands such as format.languageAll miss the text. +#[test] +fn groups_nest_only_as_deep_as_a_saved_file_reopens() { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + // A table (deeper in the file than a text box) at the bottom of the nesting. + let mut g = s.execute("insert.table", &json!({"rows": 2, "cols": 2})).unwrap()["id"].as_u64().unwrap(); + for _ in 0..cmd::arrange::MAX_GROUP_DEPTH { + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 20, 20], "text": "deep"})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [g, r]})).unwrap(); + g = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + } + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 20, 20]})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [g, r]})).unwrap(); + let before = s.doc().unwrap().revision; + let err = s.execute("arrange.group", &json!({})).unwrap_err().to_string(); + assert!(err.contains(&format!("nested at most {}", cmd::arrange::MAX_GROUP_DEPTH)), "{err}"); + assert_eq!(s.doc().unwrap().revision, before, "a refused group leaves the slide as it was"); + // The deepest deck the command allows saves and opens again. + let bytes = deckcraft_format::save(&s.doc().unwrap().doc).unwrap(); + let back = deckcraft_format::load(&bytes).unwrap(); + let depth = |shapes: &[deckcraft_model::Shape]| { + let mut d = 0; + deckcraft_model::walk(shapes, &mut |sh, depth| d = d.max(depth + usize::from(sh.is_group()))); + d + }; + assert_eq!(depth(&back.slides.last().unwrap().shapes), cmd::arrange::MAX_GROUP_DEPTH); +} + #[test] fn align_and_distribute() { let mut s = session();