From e0b40678badcbe8e14714dc4475c2c873e943ec3 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:01:04 +0200 Subject: [PATCH 1/4] SVG: keep CSS classes of imported elements on export (#2414) Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/node.rs | 4 ++++ crates/svg/src/export.rs | 36 ++++++++++++++++++++++++++++++++- crates/svg/src/import.rs | 40 +++++++++++++++++++++++++++++-------- crates/svg/tests/classes.rs | 31 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 1 + 5 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 crates/svg/tests/classes.rs diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 25acfd088..fa05315cf 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -1719,6 +1719,10 @@ pub struct ObjectAttributes { /// the element's event attributes; nothing in VectorCraft runs them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub events: Vec<(String, String)>, + /// The CSS classes the object had in an imported SVG, written back to `class` on export + /// (beside the generated `cls-N` classes). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub classes: Vec, } /// The events the SVG Interactivity panel attaches JavaScript to, by the names of the SVG event diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 500e0a062..d778ce34f 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -504,7 +504,7 @@ impl Writer<'_> { fn line(&mut self, s: &str) { let ind = self.indent(self.depth); self.body.push_str(&ind); - self.body.push_str(s); + self.body.push_str(&merge_class_attrs(s)); if !self.opts.minify { self.body.push('\n'); } @@ -630,6 +630,18 @@ impl Writer<'_> { if !out.is_empty() && n.is_layer() && self.layer_nest > 1 { out.push_str(&format!(" {}=\"sublayer\"", crate::import::SUBLAYER)); } + let classes: Vec<&str> = n + .extras() + .attrs + .as_deref() + .map_or(&[][..], |a| a.classes.as_slice()) + .iter() + .map(String::as_str) + .filter(|c| !c.is_empty() && c.chars().all(xml_name_char)) + .collect(); + if !classes.is_empty() && !self.tiny() { + out.push_str(&format!(" class=\"{}\"", xml_attr_escape(&classes.join(" ")))); + } let mut keys = HashSet::new(); for (k, v) in n.extras().attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) { // Ours (`name`, `vc-…`) are written by the export itself. @@ -2425,3 +2437,25 @@ fn paints_opaque(n: &Node) -> bool { _ => false, } } + +/// An element that has both its own classes and a generated `cls-N` would write `class` twice: +/// join the two into one attribute (the first, the object's own, comes first). +fn merge_class_attrs(s: &str) -> std::borrow::Cow<'_, str> { + const KEY: &str = " class=\""; + let tag_end = s.find('>').unwrap_or(s.len()); + let tag = s.get(..tag_end).unwrap_or(s); + let Some(first) = tag.find(KEY) else { return s.into() }; + let first_val = first + KEY.len(); + let Some(first_len) = tag.get(first_val..).and_then(|t| t.find('"')) else { return s.into() }; + let first_end = first_val + first_len; + let Some(second) = tag.get(first_end..).and_then(|t| t.find(KEY)).map(|i| i + first_end) else { return s.into() }; + let second_val = second + KEY.len(); + let Some(second_len) = tag.get(second_val..).and_then(|t| t.find('"')) else { return s.into() }; + let second_end = second_val + second_len; + let (Some(b), Some(head), Some(mid), Some(rest)) = + (s.get(second_val..second_end), s.get(..first_end), s.get(first_end + 1..second), s.get(second_end + 1..)) + else { + return s.into(); + }; + format!("{head} {b}\"{mid}{rest}").into() +} diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 8074d439b..629e40fc5 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -374,7 +374,7 @@ struct Importer { /// Ids of the shapes whose strokes don't scale ([`vector_effect`]). non_scaling: HashSet, /// The `data-*` attributes of elements, by id ([`user_data`]). - data: HashMap>, + data: HashMap, /// The event attributes of elements, by id ([`user_events`]). events: HashMap>, /// Ids of the `` elements marked as layers ([`marked_layer`]). @@ -492,7 +492,7 @@ struct Found { /// Ids of the shapes whose strokes don't scale ([`vector_effect`]). non_scaling: HashSet, /// The `data-*` attributes of elements, by id ([`user_data`]). - data: HashMap>, + data: HashMap, /// The event attributes of elements, by id ([`user_events`]). events: HashMap>, /// The JavaScript files `