From 0a4e9ea8c889e4086bcf4c2550e7a41e1bbf1699 Mon Sep 17 00:00:00 2001 From: EricKuck Date: Tue, 13 Oct 2026 18:03:25 -0400 Subject: [PATCH] SVG export: a chosen artboard's file leaves out the art of other artboards inside a shared group Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/export.rs | 24 ++++++++++++++------ crates/engine/src/cmd/fileio/tests_svgenc.rs | 17 ++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 71ee1a1f4..b41c79ffb 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -94,13 +94,14 @@ fn selected_part(n: &Node, keep: &HashSet) -> Option { } /// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left -/// out, at any depth of layers and sublayers, and objects that reach it stay whole (the file's -/// bounds clip them), as DXF's crop leaves them. Symbol instances are measured by their art, and -/// the box of one whose art holds brushed strokes grows by as far as their brush art can paint -/// ([`SymbolBoxes::with_brush_art`]). An object that holds a symbol instance also stays when what -/// the canvas paints for it reaches `rect` ([`vectorcraft_render::painted_bounds`]: the effects in -/// the symbols' art and its own effects). A clipping layer keeps every object, its clipping path -/// among them. An artboard's file then holds its own art, not a whole document of artboards. +/// out, at any depth of layers, sublayers and groups that don't clip, and objects that reach it +/// stay whole (the file's bounds clip them), as DXF's crop leaves them. Symbol instances are +/// measured by their art, and the box of one whose art holds brushed strokes grows by as far as +/// their brush art can paint ([`SymbolBoxes::with_brush_art`]). An object that holds a symbol +/// instance also stays when what the canvas paints for it reaches `rect` +/// ([`vectorcraft_render::painted_bounds`]: the effects in the symbols' art and its own effects). A +/// clipping layer keeps every object, its clipping path among them. An artboard's file then holds +/// its own art, not a whole document of artboards. pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document { fn kept(n: &Arc, doc: &Document, symbols: &SymbolBoxes, rect: vectorcraft_geom::Rect) -> Option> { if matches!(n.kind, NodeKind::Layer { clip: false, .. }) { @@ -109,6 +110,15 @@ pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document *children = children.iter().filter_map(|c| kept(c, doc, symbols, rect)).collect(); return Some(Arc::new(layer)); } + // A group that doesn't clip keeps only its members over the artboard, so art gathered in one + // group across several artboards splits by artboard. Not with effects of its own: a shadow + // can reach in from a member outside. + if matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.appearance.effects.is_empty() { + let mut group = Node::clone(n); + let children = group.children_mut()?; + *children = children.iter().filter_map(|c| kept(c, doc, symbols, rect)).collect(); + return (!children.is_empty()).then(|| Arc::new(group)); + } let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1; let mut holds_instance = false; n.walk(&mut |c| holds_instance |= matches!(c.kind, NodeKind::SymbolInstance { .. })); diff --git a/crates/engine/src/cmd/fileio/tests_svgenc.rs b/crates/engine/src/cmd/fileio/tests_svgenc.rs index e51f2b268..8e9661749 100644 --- a/crates/engine/src/cmd/fileio/tests_svgenc.rs +++ b/crates/engine/src/cmd/fileio/tests_svgenc.rs @@ -74,3 +74,20 @@ fn a_chosen_artboard_s_svg_holds_only_its_own_art() { let whole = text(&s.execute("document.serialize", &json!({"format": "svg"})).unwrap()); assert_eq!(shapes(&whole).0, 2, "nothing named: the whole document's art"); } + +/// Art grouped across artboards (one variant per artboard in one group) splits by artboard too. +#[test] +fn a_chosen_artboard_s_svg_leaves_out_its_neighbours_art_in_a_shared_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 3})).unwrap(); + let x = |s: &Session, b: usize| s.doc().unwrap().doc.artboards[b].rect.x0; + let (first, third) = (x(&s, 0), x(&s, 2)); + s.execute("shape.rectangle", &json!({"x": first + 10.0, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("shape.ellipse", &json!({"x": third + 10.0, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + let text = |v: &serde_json::Value| v["text"].as_str().unwrap().to_string(); + let third_only = text(&s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "3"})).unwrap()); + assert_eq!(third_only.matches("