Part of #372: SVG import keeps wrapped layers and round-trips sublayers

Top-level groups already opened as layers named from data-name, else their
id. Now that also holds when one more id-less group wraps them (a transform
on all the art, or a <switch>'s chosen group), and hidden objects inside a
<switch> are kept (hidden) instead of dropped.

Nested groups stay groups unless something marks them as layers: the SVG
export now writes data-vc-layer="sublayer" on layers inside layers, and
import makes such a group (or an Inkscape layer group) a sublayer of the
layer holding it, in that layer's colour. A marked group anywhere else
stays a group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-09 07:36:21 +07:00
parent a469568c7d
commit d5e6be1565
4 changed files with 270 additions and 23 deletions

File diff suppressed because one or more lines are too long

View File

@ -68,6 +68,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
symbol_nest: 0,
fonts: Vec::new(),
shared_images: HashMap::new(),
layer_nest: 0,
};
w.assign_name_ids();
// Page Isolated Blending / Page Knockout Group: the page content is one isolated group.
@ -266,6 +267,8 @@ struct Writer<'a> {
/// Images written once in the defs and drawn with `<use>`, by key and size: the pieces an
/// envelope cuts a distorted image into share its pixels.
shared_images: HashMap<(String, u32, u32), String>,
/// Layers being written inside one another (more than one: a sublayer).
layer_nest: usize,
}
/// The characters type uses from one face, under one `@font-face` description: the family the
@ -545,8 +548,8 @@ impl Writer<'_> {
}
/// ` id="…"` for a named object, plus ` data-name="…"` with the name itself when the id had to
/// differ from it (spaces, punctuation, duplicates, the unique prefix).
/// The id of `n`'s element (with its name when the id isn't it), then the object's own data
/// as `data-*` attributes.
/// The id of `n`'s element (with its name when the id isn't it), a sublayer's mark
/// ([`crate::import::SUBLAYER`]), then the object's own data as `data-*` attributes.
fn id_attr(&self, n: &Node) -> String {
if self.anonymous {
return String::new();
@ -556,6 +559,10 @@ impl Writer<'_> {
(Some(id), _) => format!(" id=\"{}\"", xml_escape(id)),
(None, _) => String::new(),
};
// A sublayer is marked as one, so import makes it a sublayer again, not a group.
if !out.is_empty() && n.is_layer() && self.layer_nest > 1 {
out.push_str(&format!(" {}=\"sublayer\"", crate::import::SUBLAYER));
}
for (k, v) in n.attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) {
// Names as XML takes them; ours (`name`, `vc-…`) are written by the export itself.
let valid = k.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) && k != "name" && !k.starts_with("vc-");
@ -1215,6 +1222,8 @@ impl Writer<'_> {
/// An object, inside `<a>` when it links to a URL (Attributes panel).
fn node(&mut self, n: &Node) {
let layer = usize::from(n.is_layer());
self.layer_nest += layer;
match n.url().filter(|_| n.visible && !self.anonymous) {
Some(url) => {
self.line(&format!("<a xlink:href=\"{}\">", xml_escape(url)));
@ -1225,6 +1234,7 @@ impl Writer<'_> {
}
None => self.node_body(n),
}
self.layer_nest -= layer;
}
fn node_body(&mut self, n: &Node) {

View File

@ -8,7 +8,7 @@ use std::sync::Arc;
use usvg::roxmltree;
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{
Appearance, AppearanceItem, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeKind, PatternDef,
Appearance, AppearanceItem, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, PatternDef,
StrokeLayer, Unit,
};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2, shapes};
@ -65,6 +65,8 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
files,
non_scaling: found.non_scaling,
data: found.data,
sublayers: found.sublayers,
sublayer_groups: HashSet::new(),
screen: (kx * ky).sqrt(),
};
if im.files.nested_text() {
@ -74,22 +76,32 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
// usvg wraps everything in an id-less group carrying the viewBox transform when needed.
let mut top = tree.root();
let mut base = Affine::scale_non_uniform(kx, ky) * aff(top.transform());
if let [usvg::Node::Group(g)] = top.children()
&& g.id().is_empty()
&& is_plain(g)
&& im.text_slot(g).is_none()
{
if let Some(g) = im.wrapper(top) {
base *= aff(g.transform());
top = g;
}
// Top-level `<g id>` elements become layers (as in the reference app); otherwise all art goes
// into "Layer 1". A clip path on one (as on an Inkscape layer, or our clipping layers) makes it
// a clipping layer. Top-level text joins the layer below it (the first layer if none is).
// Top-level `<g id>` elements become layers (as in the reference app), named from their
// labels (`data-name`…) else their ids; otherwise all art goes into "Layer 1". A clip path on
// one (as on an Inkscape layer, or our clipping layers) makes it a clipping layer. Top-level
// text joins the layer below it (the first layer if none is).
let is_layer = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && !made_up(g.id()) && !im.links.contains_key(g.id()) && !im.uses.contains_key(g.id()) && only_clips(g) && im.text_slot(g).is_none());
let is_text = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if im.text_slot(g).is_some());
let layer_mode = top.children().iter().any(|c| is_layer(&im, c)) && top.children().iter().all(|c| is_layer(&im, c) || is_text(&im, c));
if layer_mode {
let layer_mode = |im: &Importer, top: &usvg::Group| {
top.children().iter().any(|c| is_layer(im, c)) && top.children().iter().all(|c| is_layer(im, c) || is_text(im, c))
};
// The layers may sit in more wrappers (a transform on all the art, a `<switch>`'s group).
if !layer_mode(&im, top) {
let (mut inner, mut ts) = (top, base);
while let Some(g) = im.wrapper(inner) {
ts *= aff(g.transform());
inner = g;
}
if layer_mode(&im, inner) {
(top, base) = (inner, ts);
}
}
if layer_mode(&im, top) {
im.doc.layers.clear();
let mut loose = vec![];
for c in top.children() {
@ -136,9 +148,48 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
*ch = children;
}
}
if !im.sublayer_groups.is_empty() {
for l in &mut im.doc.layers {
promote_sublayers(Arc::make_mut(l), &im.sublayer_groups);
}
}
Ok((im.doc, im.warnings))
}
/// What marks a nested `<g>` as a sublayer rather than a group: our export writes it on the layers
/// it writes inside layers. (Inkscape's `inkscape:groupmode="layer"` counts too.)
pub(crate) const SUBLAYER: &str = "data-vc-layer";
/// The Inkscape namespace (its layers and their labels).
const INKSCAPE: &str = "http://www.inkscape.org/namespaces/inkscape";
/// Is element `g` a `<g>` marked as a layer ([`SUBLAYER`])? Only one with an id counts: the id (or
/// the label beside it) is its name.
fn marked_layer(g: XNode) -> bool {
g.tag_name().name() == "g"
&& g.attribute("id").is_some_and(|id| !id.is_empty())
&& (g.has_attribute(SUBLAYER) || g.attribute((INKSCAPE, "groupmode")) == Some("layer"))
}
/// Turn the groups made of elements marked as layers (`marked`, see [`marked_layer`]) right inside
/// layer `l` into its sublayers, in its colour, and theirs in turn. A marked group anywhere else
/// (in a group, a mask, a symbol) stays a group: only layers hold layers.
fn promote_sublayers(l: &mut Node, marked: &HashSet<NodeId>) {
let NodeKind::Layer { color, children, .. } = &mut l.kind else { return };
let color = *color;
for c in children.iter_mut().filter(|c| marked.contains(&c.id)) {
let c = Arc::make_mut(c);
let NodeKind::Group { children, clip } = &mut c.kind else { continue };
let mut sub = Node::layer(c.id, "", color);
sub.set_clips(*clip);
if let Some(ch) = sub.children_mut() {
*ch = std::mem::take(children);
}
c.kind = sub.kind;
promote_sublayers(c, marked);
}
}
/// SVG's initial `font-size` (`medium`) in user units.
pub(super) const DEFAULT_FONT_SIZE: f64 = 12.0;
@ -214,6 +265,10 @@ struct Importer {
non_scaling: HashSet<String>,
/// The `data-*` attributes of elements, by id ([`user_data`]).
data: HashMap<String, Vec<(String, String)>>,
/// Ids of the `<g>` elements marked as layers ([`marked_layer`]).
sublayers: HashSet<String>,
/// The groups made of them, made sublayers where they sit in a layer ([`promote_sublayers`]).
sublayer_groups: HashSet<NodeId>,
/// Points per screen pixel (the root's user unit before its `viewBox`): what a non-scaling
/// stroke's width is measured in.
screen: f64,
@ -326,6 +381,8 @@ struct Found {
non_scaling: HashSet<String>,
/// The `data-*` attributes of elements, by id ([`user_data`]).
data: HashMap<String, Vec<(String, String)>>,
/// Ids of the `<g>` elements marked as layers ([`marked_layer`]).
sublayers: HashSet<String>,
warnings: Vec<String>,
}
@ -396,7 +453,7 @@ impl Edits {
fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
let mut found = Found::default();
// (`:` for prefixed elements such as `<svg:image>`.)
if !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-"].iter().any(|t| svg.contains(t)) {
if !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode"].iter().any(|t| svg.contains(t)) {
return (svg.into(), found);
}
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else {
@ -417,6 +474,7 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
}
}
}
found.sublayers = elements().filter(|g| marked_layer(*g)).filter_map(|g| g.attribute("id")).map(str::to_string).collect();
let symbols: HashSet<&str> = elements().filter(|n| n.tag_name().name() == "symbol").filter_map(|n| n.attribute("id")).collect();
for u in elements().filter(|n| n.tag_name().name() == "use") {
if let Some(sym) = href(u).and_then(|h| h.strip_prefix('#')).filter(|s| symbols.contains(s)) {
@ -440,19 +498,20 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
/// The elements that are objects (what [`hidden_objects`] shows).
const OBJECTS: &[&str] = &["g", "a", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image", "text", "use"];
/// Show the undisplayed objects outside `<defs>` (and the like) for usvg, recording their ids.
/// Show the undisplayed objects outside `<defs>` (and the like) for usvg, recording their ids. The
/// objects in a `<switch>` count too (some apps wrap all their layers in one).
fn hidden_objects(svg: &str, xml: &roxmltree::Document, css: &css::Styles, edits: &mut Edits, ids: &mut HashSet<String>) {
let linked: HashSet<&str> = xml.descendants().filter_map(href).filter_map(|h| h.strip_prefix('#')).collect();
let mut todo: Vec<XNode> = xml.root_element().children().filter(XNode::is_element).collect();
while let Some(n) = todo.pop() {
let tag = n.tag_name().name();
if !OBJECTS.contains(&tag) || n.attribute("id").is_some_and(|id| linked.contains(id)) {
if n.attribute("id").is_some_and(|id| linked.contains(id)) {
continue;
}
if matches!(tag, "g" | "a") {
if matches!(tag, "g" | "a" | "switch") {
todo.extend(n.children().filter(XNode::is_element));
}
if css.own(n, "display").as_deref() != Some("none") {
if !OBJECTS.contains(&tag) || css.own(n, "display").as_deref() != Some("none") {
continue;
}
ids.insert(edits.id(svg, n));
@ -498,7 +557,6 @@ fn link(n: &mut Node, url: &str) {
/// The names apps keep beside element ids, by id: `data-name`, `inkscape:label`, `serif:id` or
/// `aria-label` (the first one present).
fn labels(xml: &roxmltree::Document) -> HashMap<String, String> {
const INKSCAPE: &str = "http://www.inkscape.org/namespaces/inkscape";
const SERIF: &str = "http://www.serif.com/";
xml.descendants()
.filter_map(|n| {
@ -609,6 +667,15 @@ impl Importer {
}
}
/// The child of `g` when it's all `g` holds and only wraps art: an id-less group that leaves
/// it as it is (but for a transform) and isn't a text.
fn wrapper<'g>(&self, g: &'g usvg::Group) -> Option<&'g usvg::Group> {
match g.children() {
[usvg::Node::Group(w)] if w.id().is_empty() && is_plain(w) && self.text_slot(w).is_none() => Some(w),
_ => None,
}
}
fn children(&mut self, g: &usvg::Group, acc: Affine) -> Vec<Arc<Node>> {
g.children().iter().filter_map(|c| self.node(c, acc)).map(Arc::new).collect()
}
@ -853,6 +920,9 @@ impl Importer {
n.blend = blend(g.blend_mode());
n.isolate = g.isolate();
n.mask = mask;
if !is_text && self.sublayers.contains(g.id()) {
self.sublayer_groups.insert(n.id);
}
Some(n)
}

View File

@ -1,7 +1,7 @@
//! Symbols export as one `<symbol>` def and a `<use>` per instance (instances the def can't stand
//! for get their own art), hidden layers and objects are kept hidden when asked and come back
//! hidden with their `data-*` attributes, clipped layers come in as clipping layers, and the
//! editing data notices edits made elsewhere.
//! hidden with their `data-*` attributes, clipped layers come in as clipping layers, layers and
//! sublayers keep their names and nesting, and the editing data notices edits made elsewhere.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@ -350,6 +350,173 @@ fn hidden_groups_others_use_stay_hidden() {
assert_eq!((paths, hidden), (0, 0), "{:?}", d.layers);
}
/// The layer tree: each object by kind (layers and groups with their names), `(hidden)` when
/// hidden, its contents indented below it.
fn outline(d: &Document) -> Vec<String> {
fn walk(n: &Node, depth: usize, out: &mut Vec<String>) {
let name = n.children().map(|_| format!(" {}", n.name.as_deref().unwrap_or("-"))).unwrap_or_default();
let hidden = if n.visible { "" } else { " (hidden)" };
out.push(format!("{}{}{name}{hidden}", " ".repeat(depth), n.kind_label()));
for c in n.children().into_iter().flatten() {
walk(c, depth + 1, out);
}
}
let mut out = vec![];
for l in &d.layers {
walk(l, 0, &mut out);
}
out
}
/// Layers as other apps export them (#372): named top-level groups, one hidden; a hidden named
/// group and a hidden object inside a layer.
const EXPORTED_LAYERS: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
<g id="Main">
<rect id="plainStroke" x="20" y="50" width="120" height="60" style="fill:none;stroke:#000;stroke-width:4"/>
<rect id="hiddenObject" x="200" y="50" width="60" height="60" style="display:none;fill:#fff;stroke:#000"/>
<g id="HiddenSub" style="display:none"><circle cx="45" cy="175" r="25" style="fill:#fff;stroke:#000"/></g>
</g>
<g id="Second" display="none"><rect x="10" y="10" width="5" height="5"/></g>
</svg>"##;
#[test]
fn top_level_groups_become_named_layers() {
let expected = ["Layer Main", " Path", " Path (hidden)", " Group HiddenSub (hidden)", " Path", "Layer Second (hidden)", " Path"];
let d = import(EXPORTED_LAYERS).unwrap();
assert_eq!(outline(&d), expected);
// A nested group stays a group: nothing in the file says it's a sublayer.
// `data-name` names the layer when the id can't (spaces, punctuation).
let named = EXPORTED_LAYERS.replace("<g id=\"Main\">", "<g id=\"Main_Art\" data-name=\"Main Art\">");
assert_eq!(import(&named).unwrap().layers[0].name.as_deref(), Some("Main Art"));
// Layers wrapped in more groups: a transform on all the art, or a `<switch>` choosing them.
let moved = EXPORTED_LAYERS.replace("<g id=\"Main\">", "<g transform=\"translate(5 5)\"><g id=\"Main\">").replace("</svg>", "</g></svg>");
let d = import(&moved).unwrap();
assert_eq!(outline(&d), expected, "{moved}");
let stroke = &d.layers[0].children().unwrap()[0];
assert_eq!(stroke.geometric_bounds().unwrap(), Rect::new(25.0, 55.0, 145.0, 115.0));
let switched = EXPORTED_LAYERS
.replace("<g id=\"Main\">", "<switch><foreignObject requiredExtensions=\"urn:example:none\" width=\"1\" height=\"1\"/><g><g id=\"Main\">")
.replace("</svg>", "</g></switch></svg>");
assert_eq!(outline(&import(&switched).unwrap()), expected, "{switched}");
// Art beside the groups puts everything in one layer, as before.
let loose = EXPORTED_LAYERS.replace("</svg>", "<rect x=\"1\" y=\"1\" width=\"2\" height=\"2\"/></svg>");
assert_eq!(import(&loose).unwrap().layers.iter().map(|l| l.name.as_deref()).collect::<Vec<_>>(), [Some("Layer 1")]);
}
/// Layer "Base" holding a path, a named group, a clipping sublayer and a hidden sublayer "Notes"
/// with a sublayer of its own; layer "Top" above it.
fn sublayered() -> Document {
let mut d = Document::new(100.0, 100.0);
let square = |d: &mut Document, x: f64| {
let ap = Appearance::basic(Paint::solid(Color::rgb8(200, 30, 30)), Paint::None, 0.0);
Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, x, x + 10.0, x + 10.0)), ap))
};
let layer = |children: Vec<Arc<Node>>, d: &mut Document, name: &str| {
let mut l = Node::layer(d.alloc_id(), name, vectorcraft_doc::LayerColor::Preset(3));
*l.children_mut().unwrap() = children;
l
};
let deep = layer(vec![square(&mut d, 60.0)], &mut d, "Deep");
let mut notes = layer(vec![square(&mut d, 50.0), Arc::new(deep)], &mut d, "Notes");
notes.visible = false;
let mut clip = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 30.0, 30.0)), Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let mut window = layer(vec![Arc::new(clip), square(&mut d, 25.0)], &mut d, "Window");
window.set_clips(true);
let mut group = Node::group(d.alloc_id(), vec![square(&mut d, 30.0), square(&mut d, 40.0)]);
group.name = Some("Pair".into());
let art = vec![square(&mut d, 10.0), Arc::new(group), Arc::new(window), Arc::new(notes)];
let base = Arc::make_mut(&mut d.layers[0]);
base.name = Some("Base".into());
*base.children_mut().unwrap() = art;
let top = layer(vec![square(&mut d, 80.0)], &mut d, "Top");
d.layers.push(Arc::new(top));
d
}
#[test]
fn sublayers_round_trip() {
let d = sublayered();
let expected = [
"Layer Base",
" Path",
" Group Pair",
" Path",
" Path",
" Layer Window",
" Path",
" Path",
" Layer Notes (hidden)",
" Path",
" Layer Deep",
" Path",
"Layer Top",
" Path",
];
assert_eq!(outline(&d), expected);
let styles = [vectorcraft_svg::Styling::PresentationAttributes, vectorcraft_svg::Styling::InlineStyle, vectorcraft_svg::Styling::InternalCss];
for styling in styles {
// Saved with the hidden sublayer: the same tree comes back.
let kept = export(&d, &ExportOptions { hidden_layers: true, styling, ..Default::default() });
assert_eq!(kept.matches("data-vc-layer").count(), 3, "sublayers only: {kept}");
let back = import(&kept).unwrap();
assert_eq!(outline(&back), expected, "{kept}");
// A sublayer takes its layer's colour.
back.walk(|n| {
if let NodeKind::Layer { color, .. } = &n.kind
&& n.name.as_deref() != Some("Top")
{
assert_eq!(*color, vectorcraft_doc::LayerColor::Preset(0), "{:?}", n.name);
}
});
assert!(back.layers[0].children().unwrap()[2].clips());
assert_similar(&render_artboard(&back), &render_artboard(&d), 24.0, 0.002);
}
// Exported without hidden art, the visible sublayer still comes back as one.
let plain = import(&export(&d, &ExportOptions::default())).unwrap();
assert_eq!(
outline(&plain),
["Layer Base", " Path", " Group Pair", " Path", " Path", " Layer Window", " Path", " Path", "Layer Top", " Path"]
);
}
#[test]
fn marked_groups_are_sublayers_only_in_layers() {
// Inkscape marks its layers and sublayers; one marked group in a plain group stays a group.
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="100" height="100">
<g id="layer1" inkscape:groupmode="layer" inkscape:label="Base">
<rect x="1" y="1" width="5" height="5"/>
<g id="layer2" inkscape:groupmode="layer" inkscape:label="Sub" style="display:none">
<rect x="10" y="10" width="5" height="5"/>
<g id="layer3" inkscape:groupmode="layer" inkscape:label="Deeper"><rect x="20" y="10" width="5" height="5"/></g>
</g>
<g id="g1"><rect x="30" y="1" width="5" height="5"/><g id="layer4" data-vc-layer="sublayer"><rect x="40" y="1" width="5" height="5"/></g></g>
</g>
</svg>"##;
let d = import(svg).unwrap();
assert_eq!(
outline(&d),
[
"Layer Base",
" Path",
" Layer Sub (hidden)",
" Path",
" Layer Deeper",
" Path",
" Group g1",
" Path",
" Group layer4",
" Path",
]
);
// Art outside layers ("Layer 1" holds it): a marked group there is a sublayer of it.
let loose = svg.replace("</svg>", "<rect x=\"1\" y=\"1\" width=\"2\" height=\"2\"/></svg>");
let d = import(&loose).unwrap();
assert_eq!(outline(&d)[..4], ["Layer Layer 1", " Layer Base", " Path", " Layer Sub (hidden)"]);
}
#[test]
fn editing_data_notices_edits_made_elsewhere() {
let d = symbol_doc(2.0, &[Affine::translate((50.0, 50.0))]);