From 8906abf30d288429310202dda3d10df2fd200828 Mon Sep 17 00:00:00 2001 From: Louise Fox <2503492+KaboomFox@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:05:44 +0000 Subject: [PATCH] SVG: keep other applications' attributes from import to export Many tools note things on SVG objects in attributes of their own XML namespace: a plotter's pen, a cutter's passes, a game engine's collision flag. Import dropped them, so a file that went through VectorCraft lost what other software had set on its objects. #522 asked for unknown and data-* attributes to survive a round trip; #580 kept the data-* ones, and this keeps the ones in other applications' namespaces. - Import keeps every attribute in a namespace that isn't SVG's, XLink's, XML's, Inkscape's, Sodipodi's or VectorCraft's own, on the object or layer it belongs to, in order, with its namespace URI and the prefix the file gave it (ObjectAttributes::foreign, beside the data-* attributes and the event handlers). The file is untrusted: at most 64 per object and 64 KiB per value, and what is left out is warned about. Files that declare no other namespace skip the scan. - They are the object's own, as its data-* attributes are: a pasted copy and a duplicate keep them, and deleting the object takes them with it. The native file keeps them in the object's attributes, a key written only when an object has some. - Export writes them back on the same elements and declares each namespace once, on the root, under the file's prefix. A prefix the export uses itself, or that another namespace already has, becomes prefix2, prefix3...; one XML keeps for itself (xml...) becomes ns. A name or value XML can't hold is left out with a warning. Nothing in VectorCraft reads them, and nothing in the UI lists them. svg/tests/foreign_attributes.rs: a layer's and a shape's attributes survive import, export and import again, Inkscape's and Sodipodi's own aside and with no warnings, and the native form round-trips; prefixes are made unique, and bad names and values are left out with a warning; the caps hold and are warned about once. engine tests_attributes.rs: a pasted copy and a duplicate keep them, a deleted object leaves none behind, and the native file and SVG export keep them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un --- crates/doc/src/lib.rs | 2 +- crates/doc/src/node.rs | 22 +++++ crates/engine/src/tests_attributes.rs | 35 ++++++- crates/svg/src/export.rs | 58 ++++++++++- crates/svg/src/import.rs | 91 +++++++++++++++++- crates/svg/src/lib.rs | 6 +- crates/svg/tests/foreign_attributes.rs | 128 +++++++++++++++++++++++++ docs/file-format-parity.md | 6 +- docs/mcp.md | 3 +- docs/target-app-parity.md | 4 +- 10 files changed, 344 insertions(+), 11 deletions(-) create mode 100644 crates/svg/tests/foreign_attributes.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 46efb17bb..9f954603c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -106,7 +106,7 @@ pub use metadata::{CopyrightStatus, DocMetadata}; pub use node::Knockout; pub use node::Scaling; pub use node::{Extras, ExtrasMut, ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeExtras, NodeId, NodeKind, OpacityMask}; -pub use node::{ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS}; +pub use node::{ForeignAttribute, ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS}; pub use orient::OrientedBox; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use perspective::PerspectiveAttachment; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 1d5746eb5..7f7f34022 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -1800,6 +1800,28 @@ 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)>, + /// Attributes in other applications' XML namespaces, in order, kept from SVG import to SVG + /// export: what another tool notes on an object (a plotter's pen, a cutter's passes) survives + /// a round trip through VectorCraft. Nothing in VectorCraft reads them. They are the object's, + /// as `data` is, so copies, duplicates and pastes keep them and a deleted object takes them + /// with it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub foreign: Vec, +} + +/// An attribute in another application's XML namespace: `plotter:pen="2"` with `xmlns:plotter= +/// "https://example.org/plotter"` is `{ns: "https://example.org/plotter", prefix: "plotter", name: +/// "pen", value: "2"}`. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ForeignAttribute { + /// The namespace's URI, which names it. + pub ns: String, + /// The prefix the file gave the namespace, which export reuses where it can. + pub prefix: String, + /// The attribute's local name. + pub name: String, + /// The attribute's value, as the file had it. + pub value: String, } /// The events the SVG Interactivity panel attaches JavaScript to, by the names of the SVG event diff --git a/crates/engine/src/tests_attributes.rs b/crates/engine/src/tests_attributes.rs index 17bcb26f9..b354e9fdb 100644 --- a/crates/engine/src/tests_attributes.rs +++ b/crates/engine/src/tests_attributes.rs @@ -1,5 +1,6 @@ -//! Window → Attributes (`attributes.set`, `attributes.info`, `path.setFillRule`, `path.reverse`) -//! and the links SVG export writes for objects with a URL. +//! Window → Attributes (`attributes.set`, `attributes.info`, `path.setFillRule`, `path.reverse`), +//! the links SVG export writes for objects with a URL, and what objects keep for other software +//! (their `data-*` attributes and their attributes in other applications' namespaces). use serde_json::{Value, json}; use vectorcraft_doc::{ImageMap, NodeKind}; @@ -214,3 +215,33 @@ fn object_data_is_set_saved_and_written_to_svg() { let back = vectorcraft_format::load(&native).unwrap(); assert_eq!(back.node(a).unwrap().extras().attrs, s.doc().unwrap().doc.node(a).unwrap().extras().attrs); } + +/// An object's attributes in other applications' XML namespaces are its own, as its `data-*` +/// attributes are: a pasted copy and a duplicate keep them, deleting the object takes them with +/// it, and the native file and SVG export keep them. +#[test] +fn other_applications_attributes_go_with_their_object() { + let mut s = Session::new(); + let svg = r#" + "#; + run(&mut s, "document.open", json!({"name": "pens.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})); + // The `plotter:pen` values on the document's objects. + let pens = |d: &vectorcraft_doc::Document| { + let mut out = vec![]; + d.walk(|n| out.extend(n.extras().attrs.iter().flat_map(|a| a.foreign.iter().map(|f| f.value.clone())))); + out + }; + assert_eq!(pens(&s.doc().unwrap().doc), ["2"]); + run(&mut s, "select.all", json!({})); + run(&mut s, "edit.copy", json!({})); + run(&mut s, "edit.paste", json!({})); + assert_eq!(pens(&s.doc().unwrap().doc), ["2", "2"], "a pasted copy keeps them"); + run(&mut s, "edit.duplicate", json!({})); + assert_eq!(pens(&s.doc().unwrap().doc), ["2", "2", "2"], "so does a duplicate"); + run(&mut s, "edit.clear", json!({})); + assert_eq!(pens(&s.doc().unwrap().doc), ["2", "2"], "a deleted object leaves none behind"); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(pens(&back), ["2", "2"], "the native file keeps them"); + let out = run(&mut s, "document.serialize", json!({"format": "svg"}))["text"].as_str().unwrap().to_string(); + assert_eq!((out.matches("xmlns:plotter=\"https://example.org/plotter\"").count(), out.matches("plotter:pen=\"2\"").count()), (1, 2), "{out}"); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 625617cc9..07961c265 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1,6 +1,6 @@ //! The SVG writer. -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fmt::Write as _; use std::sync::Arc; @@ -35,6 +35,8 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions, native: Option<&[u8]> write(doc, opts, native, &prefix) } +/// `doc` as SVG with `opts`, the native file `native` embedded for Preserve Editing, its generated +/// ids starting with `id_prefix`. fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: &str) -> Output { let rect = opts .artboard @@ -71,6 +73,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: shared_images: HashMap::new(), layer_nest: 0, non_latin1_names: false, + foreign_ns: BTreeMap::new(), }; w.assign_name_ids(); // Page Isolated Blending / Page Knockout Group: the page content is one isolated group. @@ -108,6 +111,9 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: } let (ww, hh) = (w.num(rect.width()), w.num(rect.height())); out.push_str(" bool { s.chars().all(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}')) } +/// An XML name without a colon (an NCName): a letter or `_`, then [`xml_name_char`]s. +fn xml_ncname(s: &str) -> bool { + let mut chars = s.chars(); + chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(xml_name_char) +} + /// Namespace-free XML 1.0 NameChar. The fixed `data-` prefix supplies NameStartChar. fn xml_name_char(c: char) -> bool { matches!(c, @@ -330,6 +342,9 @@ struct Writer<'a> { layer_nest: usize, /// An emitted attribute name requires an encoding wider than Latin-1. non_latin1_names: bool, + /// The prefix written for each namespace of other applications' attributes, by its URI + /// ([`Writer::foreign_attrs`]): declared on the root. + foreign_ns: BTreeMap, } /// The characters type uses from one face, under one `@font-face` description: the family the @@ -652,9 +667,50 @@ impl Writer<'_> { self.non_latin1_names |= k.chars().any(|c| u32::from(c) > 0xff); out.push_str(&format!(" data-{k}=\"{}\"", xml_attr_escape(v))); } + self.foreign_attrs(n, &mut out); self.event_attrs(n, &mut out); out } + /// `n`'s attributes in other applications' namespaces (`plotter:pen="2"`), added to `out`. + /// Each namespace is declared once, on the root, under the prefix the file gave it unless the + /// export uses that prefix itself or for another namespace ([`Writer::foreign_prefix`]). + fn foreign_attrs(&mut self, n: &Node, out: &mut String) { + let attrs = n.extras().attrs.as_deref().map_or(&[][..], |a| a.foreign.as_slice()); + let mut seen = HashSet::new(); + for a in attrs { + if !xml_ncname(&a.prefix) || !xml_ncname(&a.name) || a.ns.is_empty() || !valid_xml_value(&a.ns) || !valid_xml_value(&a.value) { + self.warn("Attributes of other applications with invalid XML names or characters were left out of SVG"); + continue; + } + // Native data is a list: one attribute per namespace and name. + if !seen.insert((a.ns.as_str(), a.name.as_str())) { + self.warn("Repeated attributes of other applications were written only once in SVG"); + continue; + } + let prefix = self.foreign_prefix(&a.ns, &a.prefix); + self.non_latin1_names |= prefix.chars().chain(a.name.chars()).any(|c| u32::from(c) > 0xff); + out.push_str(&format!(" {prefix}:{}=\"{}\"", a.name, xml_attr_escape(&a.value))); + } + } + /// The prefix namespace `ns` is written under: `wanted` (the file's), else `wanted2`, + /// `wanted3`… when the export's own prefixes or another namespace already have it. XML keeps + /// prefixes starting with `xml` for itself: those become `ns`, `ns2`… + fn foreign_prefix(&mut self, ns: &str, wanted: &str) -> String { + if let Some(p) = self.foreign_ns.get(ns) { + return p.clone(); + } + const OURS: [&str; 5] = ["svg", "xlink", "rdf", "dc", "vectorcraft"]; + let base = if wanted.to_ascii_lowercase().starts_with("xml") { "ns" } else { wanted }; + let taken = |p: &str| OURS.contains(&p) || self.foreign_ns.values().any(|q| q == p); + let mut prefix = base.to_string(); + let mut k = 2; + while taken(&prefix) { + prefix = format!("{base}{k}"); + k += 1; + } + self.foreign_ns.insert(ns.to_string(), prefix.clone()); + prefix + } /// Window › SVG Interactivity: `n`'s events as event attributes (`onclick="…"`), added to /// `out`. The SVG Tiny 1.2 profile leaves them out with a warning. fn event_attrs(&mut self, n: &Node, out: &mut String) { diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 075904e51..6dce3a2d9 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -8,8 +8,8 @@ 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, MAX_SVG_SCRIPTS, Node, NodeId, - NodeKind, PatternDef, SVG_EVENTS, StrokeLayer, Unit, + Appearance, AppearanceItem, Dash, Document, FillLayer, ForeignAttribute, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, MAX_SVG_SCRIPTS, + Node, NodeId, NodeKind, PatternDef, SVG_EVENTS, StrokeLayer, Unit, }; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2, shapes}; @@ -38,6 +38,7 @@ fn parse(svg: &str) -> Result, SvgError> { .map_err(|e| SvgError::Parse(e.to_string())) } +/// The document SVG text `svg` holds, read with `opts`, and the warnings about what it couldn't keep. pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec), SvgError> { // Before anything reads the tree: the XML reader, usvg, and what is made of its tree, all by // recursion. @@ -92,6 +93,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec Result<(Document, Vec>, /// The event attributes of elements, by id ([`user_events`]). events: HashMap>, + /// The attributes of elements in other applications' namespaces, by id ([`foreign`]). + foreign: HashMap>, /// Ids of the `` elements marked as layers ([`marked_layer`]). sublayers: HashSet, /// The groups made of them, made sublayers where they sit in a layer ([`promote_sublayers`]). @@ -501,6 +506,8 @@ struct Found { data: HashMap>, /// The event attributes of elements, by id ([`user_events`]). events: HashMap>, + /// The attributes of elements in other applications' namespaces, by id ([`foreign`]). + foreign: HashMap>, /// The JavaScript files `