diff --git a/ROADMAP.md b/ROADMAP.md index b0b59c04..1ef1ba76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -146,6 +146,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-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour | | 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 | 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 f8b48097..8ace6219 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();