mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
Merge pull request #3758 from KaboomFox/p7-foreign-svg-attributes
SVG: keep other applications' attributes from import to export
This commit is contained in:
@@ -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