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
This commit is contained in:
Louise Fox
2026-10-23 10:56:00 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 074b4a2670
commit 8906abf30d
10 changed files with 344 additions and 11 deletions
+1 -1
View File
@@ -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;
+22
View File
@@ -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<ForeignAttribute>,
}
/// 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
+33 -2
View File
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:plotter="https://example.org/plotter" width="100" height="100">
<rect width="20" height="20" plotter:pen="2"/></svg>"#;
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}");
}
+57 -1
View File
@@ -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("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"");
for (ns, prefix) in &w.foreign_ns {
out.push_str(&format!(" xmlns:{prefix}=\"{}\"", xml_attr_escape(ns)));
}
if w.tiny() {
out.push_str(" version=\"1.2\" baseProfile=\"tiny\"");
}
@@ -197,6 +203,12 @@ fn valid_xml_value(s: &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<String, String>,
}
/// 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) {
+89 -2
View File
@@ -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<roxmltree::Document<'_>, 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<String>), 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<S
non_scaling: found.non_scaling,
data: found.data,
events: found.events,
foreign: found.foreign,
sublayers: found.sublayers,
sublayer_groups: HashSet::new(),
screen: k,
@@ -173,6 +175,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
l.visible = !im.hidden.contains(g.id());
im.keep_data(g.id(), &mut l);
im.keep_events(g.id(), &mut l);
im.keep_foreign(g.id(), &mut l);
im.doc.layers.push(Arc::new(l));
}
} else {
@@ -383,6 +386,8 @@ struct Importer {
data: HashMap<String, Vec<(String, String)>>,
/// The event attributes of elements, by id ([`user_events`]).
events: HashMap<String, Vec<(String, String)>>,
/// The attributes of elements in other applications' namespaces, by id ([`foreign`]).
foreign: HashMap<String, Vec<ForeignAttribute>>,
/// 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`]).
@@ -501,6 +506,8 @@ struct Found {
data: HashMap<String, Vec<(String, String)>>,
/// The event attributes of elements, by id ([`user_events`]).
events: HashMap<String, Vec<(String, String)>>,
/// The attributes of elements in other applications' namespaces, by id ([`foreign`]).
foreign: HashMap<String, Vec<ForeignAttribute>>,
/// The JavaScript files `<script>` elements link to ([`linked_scripts`]).
scripts: Vec<String>,
/// Ids of the `<g>` elements marked as layers ([`marked_layer`]).
@@ -591,6 +598,7 @@ impl Edits {
/// * shapes with a non-scaling stroke get an id when they have none, recorded ([`vector_effect`]);
/// * objects with event attributes get an id when they have none, recorded with them
/// ([`user_events`]), and the `<script>` links are recorded ([`linked_scripts`]);
/// * so do objects with attributes in other applications' namespaces ([`foreign`]);
/// * linked files are read ([`files`]);
/// * percentage lengths in a `<symbol>` with a `viewBox` become numbers ([`symbol_units`]);
/// * what style sheet rules with a `:not()` set goes in `style` attributes ([`not_rules`]).
@@ -604,9 +612,11 @@ fn prepass<'s>(svg: &'s str, markers: &str, xml: &roxmltree::Document, opts: &Im
let css_transform = has_css_transform(markers);
let not = markers.contains(":not(");
let events = SVG_EVENTS.iter().any(|e| markers.contains(e));
let foreign_attrs = markers.contains("xmlns:") && has_foreign_attributes(xml);
if !css_transform
&& !not
&& !events
&& !foreign_attrs
&& !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode", "script"]
.iter()
.any(|t| markers.contains(t))
@@ -628,6 +638,14 @@ fn prepass<'s>(svg: &'s str, markers: &str, xml: &roxmltree::Document, opts: &Im
}
}
}
if foreign_attrs {
for e in elements().filter(|e| OBJECT_ELEMENTS.contains(&e.tag_name().name())) {
let kept = foreign(e, &mut found.warnings);
if !kept.is_empty() {
found.foreign.insert(edits.id(svg, e), kept);
}
}
}
if events {
for e in elements().filter(|e| EVENT_ELEMENTS.contains(&e.tag_name().name())) {
let events = user_events(e);
@@ -736,6 +754,64 @@ fn user_data(e: XNode) -> Vec<(String, String)> {
.collect()
}
/// The namespaces whose attributes VectorCraft reads, writes or makes again itself: their
/// attributes aren't kept as another application's ([`foreign`]).
const OWN_NAMESPACES: &[&str] = &[
"http://www.w3.org/2000/svg",
"http://www.w3.org/1999/xlink",
"http://www.w3.org/XML/1998/namespace",
"http://www.w3.org/2000/xmlns/",
INKSCAPE,
"http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd",
crate::EDITING_NS,
];
/// The most attributes in other applications' namespaces kept per element (the file is untrusted).
pub const MAX_FOREIGN: usize = 64;
/// The longest value of such an attribute kept, in bytes; a longer one is left out.
pub const MAX_FOREIGN_VALUE: usize = 64 * 1024;
/// The namespace of attribute `a` when it is another application's ([`OWN_NAMESPACES`] aside).
fn foreign_ns<'a>(a: &roxmltree::Attribute<'a, '_>) -> Option<&'a str> {
a.namespace().filter(|ns| !OWN_NAMESPACES.contains(ns))
}
/// Does any element of `xml` have an attribute in another application's namespace? Most files
/// declare only namespaces VectorCraft reads itself (XLink), and skip the scan for them.
fn has_foreign_attributes(xml: &roxmltree::Document) -> bool {
xml.descendants().filter(XNode::is_element).any(|e| e.attributes().any(|a| foreign_ns(&a).is_some()))
}
/// The attributes of element `e` in other applications' namespaces, in order, with the prefix the
/// file gives each namespace: `plotter:pen="2"`. Those past [`MAX_FOREIGN`], and values longer
/// than [`MAX_FOREIGN_VALUE`], are left out with a warning.
fn foreign(e: XNode, warnings: &mut Vec<String>) -> Vec<ForeignAttribute> {
let mut kept = vec![];
let mut left_out = false;
for a in e.attributes() {
let Some(ns) = foreign_ns(&a) else { continue };
if kept.len() == MAX_FOREIGN || a.value().len() > MAX_FOREIGN_VALUE {
left_out = true;
continue;
}
// A namespaced attribute always has a prefix in the file; `ns` stands in where the only
// one found is the default namespace's (export makes it unique).
let prefix = e.lookup_prefix(ns).filter(|p| !p.is_empty()).unwrap_or("ns");
kept.push(ForeignAttribute { ns: ns.to_string(), prefix: prefix.to_string(), name: a.name().to_string(), value: a.value().to_string() });
}
if left_out {
let w = format!(
"other applications' attributes past {MAX_FOREIGN} on an element, or longer than {} KiB, were left out",
MAX_FOREIGN_VALUE / 1024
);
if !warnings.contains(&w) {
warnings.push(w);
}
}
kept
}
/// The elements whose event attributes are kept: the objects, and type.
const EVENT_ELEMENTS: &[&str] = &["g", "a", "use", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image", "text"];
@@ -907,6 +983,8 @@ impl Importer {
g.children().iter().filter_map(|c| self.node(c, acc)).map(Arc::new).collect()
}
/// The object usvg node `n` makes under transform `acc`, with what the file kept on its element
/// (data, event and other applications' attributes); `None` for what imports as nothing.
fn node(&mut self, n: &usvg::Node, acc: Affine) -> Option<Node> {
let mut out = match n {
usvg::Node::Group(g) => self.group(g, acc),
@@ -920,6 +998,7 @@ impl Importer {
}
self.keep_data(n.id(), &mut out);
self.keep_events(n.id(), &mut out);
self.keep_foreign(n.id(), &mut out);
Some(out)
}
@@ -939,6 +1018,14 @@ impl Importer {
}
}
/// Give `n` the attributes of element `id` in other applications' namespaces, unless it has
/// some already (what an inner element made of it keeps).
fn keep_foreign(&self, id: &str, n: &mut Node) {
if let Some(kept) = self.foreign.get(id).filter(|_| n.extras().attrs.as_ref().is_none_or(|a| a.foreign.is_empty())) {
n.edit_attrs(|a| a.foreign = kept.clone());
}
}
/// A gradient's stops, midpoints restored.
fn stops(&self, g: &usvg::BaseGradient) -> Vec<GradientStop> {
gradient_stops(g, self.midpoints.get(g.id()).map(Vec::as_slice))
+5 -1
View File
@@ -9,6 +9,10 @@
//! ([`Profile`]), optionally with the fonts type uses embedded as `@font-face` subsets.
//! * [`import`] / [`import_with_report`] parse SVG with `usvg` and convert its normalized tree into
//! document nodes with all transforms baked into the geometry.
//! * What other software keeps on an object survives the round trip: its `data-*` attributes and
//! its attributes in other applications' XML namespaces (`plotter:pen="2"`) are kept with the
//! object ([`vectorcraft_doc::ObjectAttributes`]), so they are copied, saved and deleted with it,
//! and export writes them back.
//! * [`css_rules`] writes the CSS web pages style objects with (the CSS Properties panel): shapes'
//! paints as backgrounds and borders, type as fonts, shadows as `box-shadow`, each property
//! written as SVG export writes it in its style sheets.
@@ -536,7 +540,7 @@ pub fn import_with_report(svg: &str) -> Result<(Document, Vec<String>), SvgError
}
pub use glyph::glyph_art;
pub use import::MAX_ELEMENT_DEPTH;
pub use import::{MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE};
/// Import an SVG document reading the files it links to as `opts` says, also returning warnings.
pub fn import_with(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<String>), SvgError> {
+128
View File
@@ -0,0 +1,128 @@
//! 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:?}");
}
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -513,7 +513,8 @@ scaled while it has effects, brushes or live objects; and every instance of a sy
opacity masks (those stay on the page). `hiddenLayers: true` keeps hidden layers and objects, not displayed
(`display="none"`); `document.save` and the CLI's `convert` keep them unless told otherwise, other exports leave
them out, and they reopen hidden. An object's own data (`object.setProps {data: {pivot: "100,180"}}`) is written as
`data-*` attributes (`data-pivot="100,180"`), and an SVG's `data-*` attributes come in as it. A `preserveEditing` SVG carries the native document (CDATA in `<metadata>`) and a hash of the
`data-*` attributes (`data-pivot="100,180"`), and an SVG's `data-*` attributes come in as it. Attributes in other applications' XML namespaces
(`plotter:pen="2"`) come in on their objects too and are written back, each namespace declared once on the root. A `preserveEditing` SVG carries the native document (CDATA in `<metadata>`) and a hash of the
markup around it: if another app changed the SVG since, `document.open` reads it as plain SVG and says so in
`warnings`.
File diff suppressed because one or more lines are too long