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 `