mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
074b4a2670
commit
8906abf30d
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user