Merge pull request #3758 from KaboomFox/p7-foreign-svg-attributes

SVG: keep other applications' attributes from import to export
This commit is contained in:
BFlat
2026-10-23 10:56:15 +07:00
committed by GitHub
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