From 8b249f8275dd5f78ffd87197a085817bbecc376b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 05:06:38 -0800 Subject: [PATCH 1/3] Document Info: Objects counts each object once An object with a gradient or a pattern on both its fill and its stroke counted twice in Objects. It now counts once for each paint kind it uses. With Selection Only, a selection that holds a group and an object inside it walked that object twice, so the object counts, the paths' total length and Repeat Objects counted it twice. The walk now visits each object once. --- crates/engine/src/cmd/docinfo.rs | 47 +++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 7131dd9d4..776b4af97 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -293,8 +293,13 @@ fn info(s: &mut Session, p: &Value) -> Result { // `repeats` and `repeat_rows` hold the `repeatObjects` entries and the rows of the Repeat objects that the walk visits, in walk order. let mut repeats: Vec = vec![]; let mut repeat_rows: Vec<(String, String)> = vec![]; + // The objects the walk has visited: a selection can hold a group and objects inside it, and each object counts once. + let mut seen: BTreeSet = BTreeSet::new(); for r in roots { r.walk(&mut |n| { + if !seen.insert(n.id) { + return; + } let k = match &n.kind { NodeKind::Layer { .. } => None, NodeKind::Group { clip: true, .. } => Some("clipGroups"), @@ -337,13 +342,15 @@ fn info(s: &mut Session, p: &Value) -> Result { if let Some(k) = k { *counts.entry(k).or_default() += 1; } + // The paint kinds this object's fills and strokes use, each once. + let mut kinds = BTreeSet::new(); for item in &n.appearance.items { let paint = match item { vectorcraft_doc::AppearanceItem::Fill(f) => &f.paint, vectorcraft_doc::AppearanceItem::Stroke(s) => &s.paint, }; if let Some(k) = paint_kind(paint) { - *counts.entry(k).or_default() += 1; + kinds.insert(k); } if let Paint::Pattern { pattern, .. } = paint { patterns.insert(pattern.clone()); @@ -354,6 +361,9 @@ fn info(s: &mut Session, p: &Value) -> Result { spot.insert(name.clone()); } } + for k in kinds { + *counts.entry(k).or_default() += 1; + } if n.extras().mask.is_some() { *counts.entry("opacityMasks").or_default() += 1; } @@ -618,6 +628,41 @@ mod tests { assert_eq!(i["sections"][0]["rows"][0], json!(["", "Grid Repeat, 500 rows × 500 columns, 125000 instances"])); } + /// Document Info › Objects counts an object with a gradient or a pattern on both its fill and + /// its stroke once, and with Selection Only a selection that holds a group and an object inside + /// it counts that object once. + #[test] + fn info_counts_each_object_once() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let square = + |s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + let tile = square(&mut s, 0.0); + s.execute("object.pattern.make", &json!({"ids": [tile], "name": "Dots", "edit": false})).unwrap(); + let (g, p) = (square(&mut s, 100.0), square(&mut s, 200.0)); + let gradient = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}); + s.execute("paint.setFill", &json!({"ids": [g], "gradient": gradient})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [g], "gradient": gradient})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [p], "swatch": "Dots"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [p], "swatch": "Dots"})).unwrap(); + let i = s.execute("document.info", &json!({})).unwrap(); + assert_eq!((i["objects"]["gradients"].as_u64(), i["objects"]["patterns"].as_u64()), (Some(1), Some(1)), "{}", i["objects"]); + // A group of the two squares, selected together with one of them. + s.execute("select.set", &json!({"ids": [g, p]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].clone(); + for ids in [json!([group, p]), json!([p, group])] { + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap(); + let o = &i["objects"]; + assert_eq!( + (o["paths"].as_u64(), o["groups"].as_u64(), o["gradients"].as_u64(), o["patterns"].as_u64()), + (Some(2), Some(1), Some(1), Some(1)), + "{ids}: {o}" + ); + assert!((i["pathLength"].as_f64().unwrap() - 80.0).abs() < 1e-6, "{ids}: {}", i["pathLength"]); + } + } + #[test] fn embedding_permissions_read_from_fs_type() { assert_eq!(embedding_label(0), "embedding allowed"); From 5f20bbc3e838aa13b07c89053ebbee53323a4233 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 08:25:02 -0800 Subject: [PATCH 2/3] Document Info: test that a Repeat object in a selected group is listed once With Selection Only, a selection that holds a group and the Repeat object inside it, in either order, lists that Repeat object once. --- crates/engine/src/cmd/docinfo.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 776b4af97..0fafdf7dc 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -562,7 +562,7 @@ mod tests { /// Document Info › Repeat Objects lists each Repeat object by name with its kind and the number /// of instances it makes (for a grid, its rows and columns, followed by the number it draws when /// that is fewer), one in a group included. With Selection Only it lists the Repeat objects in - /// the selection. + /// the selection, each once when the selection holds a group and a Repeat object inside it. #[test] fn info_lists_the_repeat_objects() { let mut s = Session::new(); @@ -600,6 +600,11 @@ mod tests { }; s.execute("select.set", &json!({"ids": [grid, group]})).unwrap(); assert_eq!(names(&mut s), ["Tiles", ""]); + // A selection that holds the group and the Repeat object inside it lists that object once. + for ids in [json!([group, mirror]), json!([mirror, group])] { + s.execute("select.set", &json!({"ids": ids})).unwrap(); + assert_eq!(names(&mut s), [""], "{ids}"); + } s.execute("select.set", &json!({"ids": [radial]})).unwrap(); assert_eq!(names(&mut s), [""]); s.execute("select.set", &json!({"ids": [d]})).unwrap(); From eaab849f42da45362c4b9c53ffb48aab790c11ae Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 17:01:29 -0800 Subject: [PATCH 3/3] Document Info: count objects that share an id as separate objects The walk that skips objects it has already visited keys them by address instead of id, so two objects that share an id in a damaged file are both counted. An object reached through a selected group and again on its own still counts once. --- crates/engine/src/cmd/docinfo.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 0fafdf7dc..260b981d2 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -1,7 +1,7 @@ //! Document Info panel data, its categories and the text report (Document Info › Save…, File → //! Package), and Object → Make Pixel Perfect. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use serde_json::{Value, json}; use vectorcraft_color::Paint; @@ -294,10 +294,11 @@ fn info(s: &mut Session, p: &Value) -> Result { let mut repeats: Vec = vec![]; let mut repeat_rows: Vec<(String, String)> = vec![]; // The objects the walk has visited: a selection can hold a group and objects inside it, and each object counts once. - let mut seen: BTreeSet = BTreeSet::new(); + // The set holds each object's address, so two objects that share an id (as a damaged file can hold) count as two. + let mut seen: HashSet<*const Node> = HashSet::new(); for r in roots { r.walk(&mut |n| { - if !seen.insert(n.id) { + if !seen.insert(std::ptr::from_ref(n)) { return; } let k = match &n.kind { @@ -668,6 +669,29 @@ mod tests { } } + /// Document Info › Objects counts two objects that share an id (as a damaged file can hold) as + /// two, in the whole document and in a selected group that holds both. + #[test] + fn info_counts_objects_that_share_an_id() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let square = |s: &mut Session, x: f64| { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap() + }; + let (a, b) = (square(&mut s, 0.0), square(&mut s, 100.0)); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [group]})).unwrap(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.node_mut(NodeId(b)).unwrap().id = NodeId(a); + for p in [json!({}), json!({"selectionOnly": true})] { + let i = s.execute("document.info", &p).unwrap(); + let o = &i["objects"]; + assert_eq!((o["paths"].as_u64(), o["groups"].as_u64()), (Some(2), Some(1)), "{p}: {o}"); + assert!((i["pathLength"].as_f64().unwrap() - 80.0).abs() < 1e-6, "{p}: {}", i["pathLength"]); + } + } + #[test] fn embedding_permissions_read_from_fs_type() { assert_eq!(embedding_label(0), "embedding allowed");