Files
vectorcraft/crates/svg/tests/foreign_attributes.rs
T
Louise FoxandClaude Opus 5.5 8906abf30d 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-23 10:56:00 +07:00

129 lines
7.0 KiB
Rust

//! Attributes in other applications' XML namespaces (https://www.w3.org/TR/xml-names/) survive a
//! round trip through VectorCraft: kept on import, written back on export with their namespaces
//! declared on the root.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use usvg::roxmltree;
use vectorcraft_doc::{Document, ForeignAttribute, Node, NodeKind};
use vectorcraft_svg::{ExportOptions, MAX_FOREIGN, MAX_FOREIGN_VALUE, export, export_full, import, import_with_report};
const PLOTTER: &str = "https://example.org/plotter";
const CUTTER: &str = "https://example.org/cutter";
/// An SVG of `body` declaring the plotter's, the cutter's, Inkscape's and Sodipodi's namespaces.
fn svg(body: &str) -> String {
format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:plotter="{PLOTTER}" xmlns:cut="{CUTTER}"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
width="100" height="100">{body}</svg>"#
)
}
/// The object of `doc` named `name`.
fn named<'a>(doc: &'a Document, name: &str) -> &'a Node {
let mut found = None;
doc.walk(|n| {
if n.name.as_deref() == Some(name) {
found = Some(n);
}
});
found.unwrap_or_else(|| panic!("no object {name}"))
}
/// What the object of `doc` named `name` keeps of other applications' attributes.
fn foreign(doc: &Document, name: &str) -> Vec<ForeignAttribute> {
named(doc, name).extras().attrs.as_deref().map(|a| a.foreign.clone()).unwrap_or_default()
}
/// Attribute `prefix:name="value"` of namespace `ns`.
fn attr(ns: &str, prefix: &str, name: &str, value: &str) -> ForeignAttribute {
ForeignAttribute { ns: ns.into(), prefix: prefix.into(), name: name.into(), value: value.into() }
}
/// SVG text `text` as XML, to look up attributes by namespace.
fn parsed(text: &str) -> roxmltree::Document<'_> {
roxmltree::Document::parse(text).unwrap()
}
/// Attributes of other applications on a layer and on a shape survive import, export and import
/// again, Inkscape's and Sodipodi's own aside, and the native file keeps them.
#[test]
fn other_applications_attributes_survive_a_round_trip() {
let (doc, warnings) =
import_with_report(&svg(r#"<g id="frame" plotter:pen="2"><rect id="shape" width="20" height="20" plotter:pen="3" plotter:speed="40"
cut:passes="2" inkscape:connector-curvature="0" sodipodi:nodetypes="cccc" data-x="1"/></g>"#))
.unwrap();
assert_eq!(warnings, Vec::<String>::new());
let shape = [attr(PLOTTER, "plotter", "pen", "3"), attr(PLOTTER, "plotter", "speed", "40"), attr(CUTTER, "cut", "passes", "2")];
assert_eq!(foreign(&doc, "shape"), shape, "Inkscape's own attributes aren't another application's");
// A top-level group is a layer, and keeps them too (a plotter's pen per layer).
assert!(matches!(named(&doc, "frame").kind, NodeKind::Layer { .. }), "{:?}", named(&doc, "frame").kind);
assert_eq!(foreign(&doc, "frame"), [attr(PLOTTER, "plotter", "pen", "2")]);
// Written back on the same elements, each namespace declared once on the root.
let text = export(&doc, &ExportOptions::default());
let xml = parsed(&text);
let root = xml.root_element();
assert_eq!((root.lookup_namespace_uri(Some("plotter")), root.lookup_namespace_uri(Some("cut"))), (Some(PLOTTER), Some(CUTTER)));
let element = |id: &str| xml.descendants().find(|n| n.attribute("id") == Some(id)).unwrap();
assert_eq!((element("shape").attribute((PLOTTER, "speed")), element("shape").attribute((CUTTER, "passes"))), (Some("40"), Some("2")));
assert_eq!(element("frame").attribute((PLOTTER, "pen")), Some("2"));
let back = import(&text).unwrap();
assert_eq!((foreign(&back, "shape"), foreign(&back, "frame")), (shape.to_vec(), vec![attr(PLOTTER, "plotter", "pen", "2")]));
// And through the native file.
let attrs = named(&doc, "shape").extras().attrs.as_deref().unwrap().clone();
let json = serde_json::to_value(&attrs).unwrap();
assert_eq!(json["foreign"][0], serde_json::json!({"ns": PLOTTER, "prefix": "plotter", "name": "pen", "value": "3"}));
assert_eq!(serde_json::from_value::<vectorcraft_doc::ObjectAttributes>(json).unwrap(), attrs);
}
/// Two namespaces under one prefix, a prefix the export uses itself and one XML keeps for itself
/// are each written under a prefix of their own; names and values XML can't hold are left out
/// with a warning.
#[test]
fn prefixes_are_made_unique_and_bad_attributes_left_out() {
let mut doc = import(&svg(r#"<rect id="a" width="20" height="20"/><rect id="b" x="30" width="20" height="20"/>"#)).unwrap();
let set = |doc: &mut Document, name: &str, attrs: Vec<ForeignAttribute>| {
let id = named(doc, name).id;
doc.node_mut(id).unwrap().edit_attrs(|a| a.foreign = attrs);
};
set(&mut doc, "a", vec![attr("urn:one", "p", "x", "1"), attr("urn:three", "xlink", "y", "3"), attr("urn:four", "xmlish", "z", "4")]);
set(
&mut doc,
"b",
vec![
attr("urn:two", "p", "x", "2"),
attr("urn:one", "p", "w", "5"),
attr("urn:one", "p", "bad name", "6"),
attr("urn:one", "p", "v", "\u{1}"),
],
);
let out = export_full(&doc, &ExportOptions::default(), None);
let text = String::from_utf8(out.bytes()).unwrap();
let xml = parsed(&text);
let element = |id: &str| xml.descendants().find(|n| n.attribute("id") == Some(id)).unwrap();
assert_eq!(element("a").attribute(("urn:one", "x")), Some("1"));
assert_eq!(element("b").attribute(("urn:two", "x")), Some("2"));
assert_eq!(element("b").attribute(("urn:one", "w")), Some("5"));
assert_eq!(element("a").attribute(("urn:three", "y")), Some("3"));
assert_eq!(element("a").attribute(("urn:four", "z")), Some("4"));
let root = xml.root_element();
let prefixes: Vec<_> = ["urn:one", "urn:two", "urn:three", "urn:four"].map(|ns| root.lookup_prefix(ns)).to_vec();
assert_eq!(prefixes, [Some("p"), Some("p2"), Some("xlink2"), Some("ns")]);
assert!(out.warnings.iter().any(|w| w.contains("invalid XML names or characters")), "{:?}", out.warnings);
assert_eq!(element("b").attributes().filter(|a| a.namespace() == Some("urn:one")).count(), 1, "only w");
}
/// The file is untrusted: at most [`MAX_FOREIGN`] such attributes per element, each value at most
/// [`MAX_FOREIGN_VALUE`] bytes; what is left out is warned about, once.
#[test]
fn foreign_attributes_are_capped() {
let many: String = (0..MAX_FOREIGN + 6).map(|i| format!(r#" plotter:a{i}="{i}""#)).collect();
let long = "x".repeat(MAX_FOREIGN_VALUE + 1);
let (doc, warnings) = import_with_report(&svg(&format!(
r#"<rect id="many" width="20" height="20"{many}/><rect id="long" width="5" height="5" plotter:note="{long}" plotter:pen="1"/>"#
)))
.unwrap();
assert_eq!(foreign(&doc, "many").len(), MAX_FOREIGN);
assert_eq!(foreign(&doc, "long"), [attr(PLOTTER, "plotter", "pen", "1")]);
assert_eq!(warnings.iter().filter(|w| w.contains("other applications' attributes")).count(), 1, "{warnings:?}");
}