Merge pull request #989 from sanyamsingh001/codex/svg-data-attributes
Preserve SVG data attributes and validate export artboard ranges
This commit is contained in:
commit
1fdd7d2146
|
|
@ -69,6 +69,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
|||
fonts: Vec::new(),
|
||||
shared_images: HashMap::new(),
|
||||
layer_nest: 0,
|
||||
non_latin1_names: false,
|
||||
};
|
||||
w.assign_name_ids();
|
||||
// Page Isolated Blending / Page Knockout Group: the page content is one isolated group.
|
||||
|
|
@ -84,11 +85,18 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
|||
w.line("</g>");
|
||||
}
|
||||
|
||||
// XML names cannot use character references. Preserve names that Latin-1 cannot encode.
|
||||
let encoding = if opts.encoding == Encoding::Latin1 && w.non_latin1_names {
|
||||
w.warn("Unicode data attribute names require UTF-8; SVG was written in UTF-8");
|
||||
Encoding::Utf8
|
||||
} else {
|
||||
opts.encoding
|
||||
};
|
||||
let nl = if opts.minify { "" } else { "\n" };
|
||||
let mut out = String::new();
|
||||
// A file in neither UTF-8 nor UTF-16 must name its encoding.
|
||||
if !opts.minify || opts.encoding == Encoding::Latin1 {
|
||||
out.push_str(&format!("<?xml version=\"1.0\" encoding=\"{}\"?>{nl}", opts.encoding.xml_name()));
|
||||
if !opts.minify || encoding == Encoding::Latin1 {
|
||||
out.push_str(&format!("<?xml version=\"1.0\" encoding=\"{}\"?>{nl}", encoding.xml_name()));
|
||||
}
|
||||
if opts.styling == Styling::StyleEntities && !w.classes.is_empty() {
|
||||
out.push_str("<!DOCTYPE svg [");
|
||||
|
|
@ -133,7 +141,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
|||
out.push_str(&w.indent(1));
|
||||
out.push_str(&format!("<desc>{}</desc>{nl}", xml_escape(&doc.metadata.description)));
|
||||
}
|
||||
let latin1 = opts.encoding == Encoding::Latin1;
|
||||
let latin1 = encoding == Encoding::Latin1;
|
||||
if latin1 {
|
||||
latin1_refs(&mut out);
|
||||
latin1_refs(&mut w.body);
|
||||
|
|
@ -149,11 +157,32 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
|||
out.push_str(&w.body);
|
||||
out.push_str("</svg>");
|
||||
out.push_str(nl);
|
||||
Output { svg: out, linked: w.linked, warnings: w.warnings, encoding: opts.encoding }
|
||||
Output { svg: out, linked: w.linked, warnings: w.warnings, encoding }
|
||||
}
|
||||
|
||||
/// Write every character of `s` beyond ISO 8859-1 as a character reference (all of them sit in
|
||||
/// attribute values, text or style sheets, where references read as the character).
|
||||
/// XML 1.0 literal attribute whitespace is normalized to spaces; references retain the value.
|
||||
fn xml_attr_escape(s: &str) -> String {
|
||||
xml_escape(s).replace('\t', "	").replace('\n', " ").replace('\r', " ")
|
||||
}
|
||||
|
||||
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}'))
|
||||
}
|
||||
|
||||
/// Namespace-free XML 1.0 NameChar. The fixed `data-` prefix supplies NameStartChar.
|
||||
fn xml_name_char(c: char) -> bool {
|
||||
matches!(c,
|
||||
'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '\u{b7}' |
|
||||
'\u{c0}'..='\u{d6}' | '\u{d8}'..='\u{f6}' | '\u{f8}'..='\u{2ff}' |
|
||||
'\u{300}'..='\u{37d}' | '\u{37f}'..='\u{1fff}' | '\u{200c}'..='\u{200d}' |
|
||||
'\u{203f}'..='\u{2040}' | '\u{2070}'..='\u{218f}' | '\u{2c00}'..='\u{2fef}' |
|
||||
'\u{3001}'..='\u{d7ff}' | '\u{f900}'..='\u{fdcf}' | '\u{fdf0}'..='\u{fffd}' |
|
||||
'\u{10000}'..='\u{effff}'
|
||||
)
|
||||
}
|
||||
|
||||
/// Write every character of `s` beyond ISO 8859-1 as a character reference. Called only when
|
||||
/// names fit Latin-1, so references occur in values, text or style sheets.
|
||||
fn latin1_refs(s: &mut String) {
|
||||
if s.chars().all(|c| u32::from(c) < 0x100) {
|
||||
return;
|
||||
|
|
@ -269,6 +298,8 @@ struct Writer<'a> {
|
|||
shared_images: HashMap<(String, u32, u32), String>,
|
||||
/// Layers being written inside one another (more than one: a sublayer).
|
||||
layer_nest: usize,
|
||||
/// An emitted attribute name requires an encoding wider than Latin-1.
|
||||
non_latin1_names: bool,
|
||||
}
|
||||
|
||||
/// The characters type uses from one face, under one `@font-face` description: the family the
|
||||
|
|
@ -548,27 +579,43 @@ impl Writer<'_> {
|
|||
}
|
||||
/// ` id="…"` for a named object, plus ` data-name="…"` with the name itself when the id had to
|
||||
/// differ from it (spaces, punctuation, duplicates, the unique prefix).
|
||||
/// The id of `n`'s element (with its name when the id isn't it), a sublayer's mark
|
||||
/// ([`crate::import::SUBLAYER`]), then the object's own data as `data-*` attributes.
|
||||
fn id_attr(&self, n: &Node) -> String {
|
||||
/// The id of `n`'s element (with its name when the id isn't it), then the object's own data
|
||||
/// as `data-*` attributes. Sublayers also retain their import marker.
|
||||
fn id_attr(&mut self, n: &Node) -> String {
|
||||
if self.anonymous {
|
||||
return String::new();
|
||||
}
|
||||
let mut out = match (self.names.get(&n.id), n.name.as_deref()) {
|
||||
(Some(id), Some(name)) if name != id => format!(" id=\"{}\" data-name=\"{}\"", xml_escape(id), xml_escape(name)),
|
||||
(Some(id), _) => format!(" id=\"{}\"", xml_escape(id)),
|
||||
(None, _) => String::new(),
|
||||
};
|
||||
// A sublayer is marked as one, so import makes it a sublayer again, not a group.
|
||||
let mut out = self.names.get(&n.id).map_or_else(String::new, |id| format!(" id=\"{}\"", xml_escape(id)));
|
||||
if let (Some(id), Some(name)) = (self.names.get(&n.id), n.name.as_deref())
|
||||
&& name != id
|
||||
{
|
||||
if valid_xml_value(name) {
|
||||
out.push_str(&format!(" data-name=\"{}\"", xml_attr_escape(name)));
|
||||
} else {
|
||||
self.warn("Object names containing invalid XML characters were left out of SVG name attributes");
|
||||
}
|
||||
}
|
||||
// Preserve sublayer identity independently of object data attributes.
|
||||
if !out.is_empty() && n.is_layer() && self.layer_nest > 1 {
|
||||
out.push_str(&format!(" {}=\"sublayer\"", crate::import::SUBLAYER));
|
||||
}
|
||||
let mut keys = HashSet::new();
|
||||
for (k, v) in n.attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) {
|
||||
// Names as XML takes them; ours (`name`, `vc-…`) are written by the export itself.
|
||||
let valid = k.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) && k != "name" && !k.starts_with("vc-");
|
||||
if valid && !k.is_empty() {
|
||||
out.push_str(&format!(" data-{k}=\"{}\"", xml_escape(v)));
|
||||
// Ours (`name`, `vc-…`) are written by the export itself.
|
||||
if k == "name" || k.starts_with("vc-") {
|
||||
continue;
|
||||
}
|
||||
if k.is_empty() || !k.chars().all(xml_name_char) || !valid_xml_value(v) {
|
||||
self.warn("Data attributes with invalid XML names or characters were left out of SVG");
|
||||
continue;
|
||||
}
|
||||
// Native data is an ordered list. Keep the first valid value of each key.
|
||||
if !keys.insert(k.as_str()) {
|
||||
self.warn("Repeated data attribute keys were written only once in SVG");
|
||||
continue;
|
||||
}
|
||||
self.non_latin1_names |= k.chars().any(|c| u32::from(c) > 0xff);
|
||||
out.push_str(&format!(" data-{k}=\"{}\"", xml_attr_escape(v)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -1485,7 +1532,14 @@ impl Writer<'_> {
|
|||
self.symbol_nest -= 1;
|
||||
(self.xf, self.instance_xf, self.knockout, self.names, self.anonymous) = saved;
|
||||
// The symbol's name, when the id had to differ from it, comes back on import.
|
||||
let name = if id == sym.name { String::new() } else { format!(" data-name=\"{}\"", xml_escape(&sym.name)) };
|
||||
let name = if id == sym.name {
|
||||
String::new()
|
||||
} else if valid_xml_value(&sym.name) {
|
||||
format!(" data-name=\"{}\"", xml_attr_escape(&sym.name))
|
||||
} else {
|
||||
self.warn("Symbol names containing invalid XML characters were left out of SVG name attributes");
|
||||
String::new()
|
||||
};
|
||||
self.def(1, &format!("<symbol id=\"{id}\"{name} overflow=\"visible\">"));
|
||||
self.defs.push_str(&art);
|
||||
self.def(1, "</symbol>");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
//! Synthetic XML 1.0/SVG round trips: https://www.w3.org/TR/xml/#AVNormalize
|
||||
//! https://www.w3.org/TR/xml/#NT-NameChar https://www.w3.org/TR/xml/#charsets
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use std::collections::HashSet;
|
||||
use usvg::roxmltree;
|
||||
use vectorcraft_doc::{Document, Node};
|
||||
use vectorcraft_svg::{Encoding, ExportOptions, ObjectIds, Output, Styling, export, export_full, import};
|
||||
|
||||
fn svg(body: &str) -> String {
|
||||
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">{body}</svg>"#)
|
||||
}
|
||||
fn styles() -> [Styling; 4] {
|
||||
[Styling::PresentationAttributes, Styling::InlineStyle, Styling::InternalCss, Styling::StyleEntities]
|
||||
}
|
||||
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()
|
||||
}
|
||||
fn xml(text: &str) -> roxmltree::Document<'_> {
|
||||
roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap()
|
||||
}
|
||||
fn native_data(raw: &str) -> Document {
|
||||
let mut doc = import(&svg(r#"<rect id="shape" width="20" height="20"/>"#)).unwrap();
|
||||
let attrs = serde_json::from_str::<vectorcraft_doc::node::ObjectAttributes>(raw).unwrap();
|
||||
let id = named(&doc, "shape").id;
|
||||
doc.node_mut(id).unwrap().edit_attrs(|a| *a = attrs);
|
||||
doc
|
||||
}
|
||||
#[test]
|
||||
fn imported_decimal_whitespace_references_survive_export() {
|
||||
let doc = import(&svg(r#"<rect id="shape" width="20" height="20" data-value="a b" data-tab="a	b" data-cr="a b"/>"#)).unwrap();
|
||||
assert_eq!(
|
||||
named(&doc, "shape").attrs.as_deref().unwrap().data,
|
||||
[("value".into(), "a\nb".into()), ("tab".into(), "a\tb".into()), ("cr".into(), "a\rb".into())]
|
||||
);
|
||||
for styling in styles() {
|
||||
for minify in [false, true] {
|
||||
let text = export(&doc, &ExportOptions { styling, minify, ..Default::default() });
|
||||
let parsed = xml(&text);
|
||||
let n = parsed.descendants().find(|n| n.attribute("data-value").is_some()).unwrap();
|
||||
for (key, value) in [("data-value", "a\nb"), ("data-tab", "a\tb"), ("data-cr", "a\rb")] {
|
||||
assert_eq!(n.attribute(key), Some(value));
|
||||
}
|
||||
let back = import(&text).unwrap();
|
||||
assert_eq!(named(&back, "shape").attrs.as_deref().unwrap().data, named(&doc, "shape").attrs.as_deref().unwrap().data);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn encoded_text(out: &Output) -> String {
|
||||
let bytes = out.bytes();
|
||||
match out.encoding {
|
||||
Encoding::Utf8 => String::from_utf8(bytes).unwrap(),
|
||||
Encoding::Utf16 => {
|
||||
assert!(bytes.starts_with(&[0xfe, 0xff]));
|
||||
assert_eq!(bytes.len() % 2, 0);
|
||||
let chars: Vec<_> = bytes[2..].as_chunks::<2>().0.iter().copied().map(u16::from_be_bytes).collect();
|
||||
String::from_utf16(&chars).unwrap()
|
||||
}
|
||||
Encoding::Latin1 => bytes.into_iter().map(char::from).collect(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn unicode_data_names_survive_each_encoding() {
|
||||
let doc = import(&svg(
|
||||
r#"<rect id="shape" width="20" height="20" data-ok="safe" data-配方="blend" data-á="accent" data-𐀀="supplementary" data-·="suffix"/>"#,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(named(&doc, "shape").attrs.as_deref().unwrap().data.len(), 5);
|
||||
for styling in styles() {
|
||||
for encoding in [Encoding::Utf8, Encoding::Utf16, Encoding::Latin1] {
|
||||
for minify in [false, true] {
|
||||
let out = export_full(&doc, &ExportOptions { styling, encoding, minify, ..Default::default() }, None);
|
||||
assert_eq!(encoded_text(&out), out.svg);
|
||||
let parsed = xml(&out.svg);
|
||||
let n = parsed.descendants().find(|n| n.attribute("data-ok").is_some()).unwrap();
|
||||
for (key, value) in [("data-配方", "blend"), ("data-á", "accent"), ("data-𐀀", "supplementary"), ("data-·", "suffix")] {
|
||||
assert_eq!(n.attribute(key), Some(value));
|
||||
}
|
||||
if encoding == Encoding::Latin1 {
|
||||
assert_eq!(out.encoding, Encoding::Utf8, "XML names cannot use character references");
|
||||
assert!(!out.warnings.is_empty());
|
||||
assert!(!out.svg.contains("ISO-8859-1"));
|
||||
} else {
|
||||
assert_eq!(out.encoding, encoding);
|
||||
}
|
||||
let back = import(&out.svg).unwrap();
|
||||
assert_eq!(named(&back, "shape").attrs.as_deref().unwrap().data, named(&doc, "shape").attrs.as_deref().unwrap().data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn latin1_preserves_representable_unicode_names() {
|
||||
let doc = import(&svg(r#"<rect id="shape" width="20" height="20" data-é="配方"/>"#)).unwrap();
|
||||
for styling in styles() {
|
||||
let out = export_full(&doc, &ExportOptions { styling, encoding: Encoding::Latin1, ..Default::default() }, None);
|
||||
assert_eq!(out.encoding, Encoding::Latin1);
|
||||
assert!(out.warnings.is_empty());
|
||||
assert_eq!(encoded_text(&out), out.svg);
|
||||
let parsed = xml(&out.svg);
|
||||
assert!(parsed.descendants().any(|n| n.attribute("data-é") == Some("配方")));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn native_json_invalid_xml_data_cannot_break_export() {
|
||||
let doc = native_data(
|
||||
r#"{"data":[["ok","safe"],["control","a\u0000b"],["bad key","ordinary"],["colon:k","ordinary"],["noncharacter","\uffff"],["othercontrol","\u0001\u000b\u001f"],["bad\u0000key","ordinary"],["outside\udb80\udc00","ordinary"]]}"#,
|
||||
);
|
||||
for styling in styles() {
|
||||
let text = export(&doc, &ExportOptions { styling, ..Default::default() });
|
||||
let parsed = xml(&text);
|
||||
let n = parsed.descendants().find(|n| n.attribute("data-ok").is_some()).unwrap();
|
||||
assert_eq!(n.attribute("data-ok"), Some("safe"));
|
||||
assert!(n.attribute("data-othercontrol").is_none());
|
||||
assert!(n.attribute("data-control").is_none() && n.attribute("data-noncharacter").is_none());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn native_json_repeated_data_keys_emit_one_valid_attribute() {
|
||||
let doc = native_data(
|
||||
r#"{"data":[["ok","safe"],["repeat","first"],["repeat","second"],["fallback","\u0000"],["fallback","good"],["name","reserved"],["vc-private","reserved"]]}"#,
|
||||
);
|
||||
for styling in styles() {
|
||||
let text = export(&doc, &ExportOptions { styling, ..Default::default() });
|
||||
let parsed = xml(&text);
|
||||
let n = parsed.descendants().find(|n| n.attribute("data-ok").is_some()).unwrap();
|
||||
assert_eq!(n.attribute("data-repeat"), Some("first"));
|
||||
assert_eq!(n.attribute("data-fallback"), Some("good"));
|
||||
assert!(n.attribute("data-vc-private").is_none());
|
||||
assert!(n.attribute("data-name").is_none());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn name_aliases_preserve_decoded_whitespace() {
|
||||
let name = "name\na\tb\rc";
|
||||
let doc = import(&svg(r#"<rect id="shape" data-name="name a	b c" data-ok="safe" width="20" height="20"/>"#)).unwrap();
|
||||
for styling in styles() {
|
||||
let text = export(&doc, &ExportOptions { styling, ..Default::default() });
|
||||
let parsed = xml(&text);
|
||||
let n = parsed.descendants().find(|n| n.attribute("data-ok").is_some()).unwrap();
|
||||
assert_eq!(n.attribute("data-name"), Some(name));
|
||||
assert_eq!(named(&import(&text).unwrap(), name).name.as_deref(), Some(name));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn native_json_name_controls_do_not_break_xml() {
|
||||
let mut doc = import(&svg(r#"<rect id="shape" data-ok="safe" width="20" height="20"/>"#)).unwrap();
|
||||
let id = named(&doc, "shape").id;
|
||||
doc.node_mut(id).unwrap().name = Some(serde_json::from_str::<String>(r#""bad\u0000name""#).unwrap());
|
||||
let text = export(&doc, &ExportOptions::default());
|
||||
let parsed = xml(&text);
|
||||
let n = parsed.descendants().find(|n| n.attribute("data-ok").is_some()).unwrap();
|
||||
assert!(n.attribute("data-name").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn reid_imported_nodes_keeps_unique_ids_references_and_data() {
|
||||
let source = svg(
|
||||
r##"<defs><clipPath id="clip"><rect width="60" height="60"/></clipPath><linearGradient id="paint"><stop stop-color="blue"/><stop offset="1" stop-color="red"/></linearGradient><mask id="opacity"><rect width="100" height="100" fill="white"/></mask></defs><g id="layer"><g id="copy" clip-path="url(#clip)" data-copy="root"><rect id="clip-path-1" width="50" height="50" fill="url(#paint)" data-value="safe" data-配方="a b	c d"/><rect id="gradient-1" x="10" width="40" height="40" fill="url(#paint)"/><rect id="masked" width="30" height="30" fill="green" mask="url(#opacity)"/></g></g>"##,
|
||||
);
|
||||
let mut doc = import(&source).unwrap();
|
||||
let original = named(&doc, "copy").clone();
|
||||
let layer = doc.layers[0].id;
|
||||
for _ in 0..2 {
|
||||
let copy = doc.reid(&original);
|
||||
doc.insert(Some(layer), usize::MAX, copy).unwrap();
|
||||
}
|
||||
let mut nodes = Vec::new();
|
||||
doc.walk(|n| nodes.push(n.id));
|
||||
assert_eq!(nodes.len(), nodes.iter().collect::<HashSet<_>>().len());
|
||||
for object_ids in [ObjectIds::LayerNames, ObjectIds::Unique, ObjectIds::Minimal] {
|
||||
for styling in styles() {
|
||||
let text = export(&doc, &ExportOptions { object_ids, styling, ..Default::default() });
|
||||
let parsed = xml(&text);
|
||||
let ids: Vec<_> = parsed.descendants().filter_map(|n| n.attribute("id")).collect();
|
||||
let set = ids.iter().copied().collect::<HashSet<_>>();
|
||||
assert_eq!(ids.len(), set.len());
|
||||
for id in &ids {
|
||||
assert!(
|
||||
id.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
|
||||
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
|
||||
);
|
||||
}
|
||||
let mut count = 0;
|
||||
for suffix in text.split("url(#").skip(1) {
|
||||
let id = suffix.split(')').next().unwrap();
|
||||
assert!(set.contains(id));
|
||||
count += 1;
|
||||
}
|
||||
assert!(count > 0);
|
||||
assert_eq!(parsed.descendants().filter(|n| n.attribute("data-copy") == Some("root")).count(), 3);
|
||||
assert_eq!(parsed.descendants().filter(|n| n.attribute("data-value") == Some("safe")).count(), 3);
|
||||
let value = "a\nb\tc\rd";
|
||||
assert_eq!(parsed.descendants().filter(|n| n.attribute("data-配方") == Some(value)).count(), 3);
|
||||
let back = import(&text).unwrap();
|
||||
let mut copies = 0;
|
||||
back.walk(|n| {
|
||||
if n.attrs.as_deref().is_some_and(|a| a.data.iter().any(|(k, v)| k == "配方" && v == value)) {
|
||||
copies += 1;
|
||||
}
|
||||
});
|
||||
assert_eq!(copies, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn symbol_document(name: &str) -> Document {
|
||||
let mut doc = import(&svg(r#"<rect id="instance" width="20" height="20"/>"#)).unwrap();
|
||||
let id = named(&doc, "instance").id;
|
||||
let art = doc.node(id).unwrap().clone();
|
||||
doc.symbols.push(vectorcraft_doc::Symbol { name: name.into(), art: std::sync::Arc::new(art) });
|
||||
let instance = doc.node_mut(id).unwrap();
|
||||
instance.appearance = vectorcraft_doc::Appearance::default().into();
|
||||
instance.kind = vectorcraft_doc::NodeKind::SymbolInstance { symbol: name.into(), xf: vectorcraft_geom::Affine::IDENTITY };
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_name_aliases_preserve_decoded_whitespace() {
|
||||
let name = "symbol\nwith\ttab\rand é配方";
|
||||
let doc = symbol_document(name);
|
||||
for styling in styles() {
|
||||
for encoding in [Encoding::Utf8, Encoding::Utf16, Encoding::Latin1] {
|
||||
for minify in [false, true] {
|
||||
let out = export_full(&doc, &ExportOptions { styling, encoding, minify, ..Default::default() }, None);
|
||||
assert_eq!(out.encoding, encoding);
|
||||
assert_eq!(encoded_text(&out), out.svg);
|
||||
let parsed = xml(&out.svg);
|
||||
let symbol = parsed.descendants().find(|n| n.has_tag_name("symbol")).unwrap();
|
||||
assert_eq!(symbol.attribute("data-name"), Some(name));
|
||||
let back = import(&out.svg).unwrap();
|
||||
assert_eq!(back.symbols.len(), 1);
|
||||
assert_eq!(back.symbols[0].name, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_symbol_name_alias_does_not_break_xml() {
|
||||
for name in ["bad\0symbol", "bad\u{1}symbol", "bad\u{ffff}symbol"] {
|
||||
let doc = symbol_document(name);
|
||||
for styling in styles() {
|
||||
for encoding in [Encoding::Utf8, Encoding::Utf16, Encoding::Latin1] {
|
||||
let out = export_full(&doc, &ExportOptions { styling, encoding, ..Default::default() }, None);
|
||||
assert_eq!(encoded_text(&out), out.svg);
|
||||
let parsed = xml(&out.svg);
|
||||
let symbol = parsed.descendants().find(|n| n.has_tag_name("symbol")).unwrap();
|
||||
assert!(symbol.attribute("data-name").is_none());
|
||||
let id = symbol.attribute("id").unwrap();
|
||||
assert!(parsed.descendants().any(|n| n.attribute(("http://www.w3.org/1999/xlink", "href")) == Some(&format!("#{id}"))));
|
||||
assert!(out.warnings.iter().any(|w| w.contains("Symbol names")));
|
||||
assert_eq!(import(&out.svg).unwrap().symbols.len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -478,6 +478,8 @@ mod tests_import_pdf;
|
|||
mod tests_package;
|
||||
#[cfg(test)]
|
||||
mod tests_raster_formats;
|
||||
#[cfg(test)]
|
||||
mod tests_svg_options_range;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_dxf;
|
||||
|
|
|
|||
|
|
@ -127,6 +127,11 @@ fn options(d: &Dialog) -> Value {
|
|||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let opts = options(d);
|
||||
if Mode::of(d) == Mode::Export && d.bool("useArtboards") {
|
||||
let n = app.session.active().map_or(0, |st| st.doc.artboards.len());
|
||||
let pick: vectorcraft_engine::cmd::fileio::ArtboardPick = serde_json::from_value(opts.clone()).map_err(|e| e.to_string())?;
|
||||
pick.resolve(n)?;
|
||||
}
|
||||
// Remembered for next time, without the document's artboard range.
|
||||
let mut last = opts.clone();
|
||||
if let Some(o) = last.as_object_mut() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
//! Persistent-context pointer coverage of SVG artboard range validation and recovery.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use egui::{Event, Pos2, Rect, Shape, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::{Services, VectorcraftApp};
|
||||
|
||||
use super::tests_export::Written;
|
||||
|
||||
struct ExportHarness {
|
||||
app: VectorcraftApp,
|
||||
ctx: egui::Context,
|
||||
written: Written,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl ExportHarness {
|
||||
fn new() -> Self {
|
||||
let written = Rc::new(RefCell::new(vec![]));
|
||||
let w = written.clone();
|
||||
let services = Services {
|
||||
pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/out/{}", p.name)))),
|
||||
write: Some(Box::new(move |p: &str, b: &[u8]| {
|
||||
w.borrow_mut().push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 80, "height": 60, "artboards": 80, "columns": 10, "spacing": 20})).unwrap();
|
||||
app.run("paint.setStroke", json!({"none": true})).unwrap();
|
||||
for (i, name, colour, width, height) in
|
||||
[(0, "FirstArt", "#cc2200", 11, 13), (78, "OtherArt", "#2244cc", 9, 7), (79, "LastArt", "#00aa88", 17, 19)]
|
||||
{
|
||||
let r = app.session.active().unwrap().doc.artboards[i].rect;
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
app.run("paint.setFill", json!({"color": colour})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": r.x0 + 7.0, "y": r.y0 + 11.0, "width": width, "height": height})).unwrap();
|
||||
app.run("object.setProps", json!({"name": name})).unwrap();
|
||||
}
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
Self { app, ctx, written, time: 0.0 }
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>) -> egui::FullOutput {
|
||||
self.time += 0.1;
|
||||
let input = egui::RawInput {
|
||||
events,
|
||||
time: Some(self.time),
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 900.0))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = self.ctx.run_ui(input, |ui| super::show(&mut self.app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
fn settle(&mut self) -> egui::FullOutput {
|
||||
self.frame(vec![]);
|
||||
self.frame(vec![])
|
||||
}
|
||||
|
||||
fn click(&mut self, at: Pos2) -> egui::FullOutput {
|
||||
let button = |pressed| Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
self.frame(vec![Event::PointerMoved(at)]);
|
||||
self.frame(vec![button(true)]);
|
||||
self.frame(vec![button(false)]);
|
||||
self.settle()
|
||||
}
|
||||
|
||||
fn choose_range(&mut self, format: &str, range: &str) {
|
||||
self.app.run("file.exportAs", json!({"format": format})).unwrap();
|
||||
let out = self.settle();
|
||||
let out = self.click(text_at(&out, "Use Artboards"));
|
||||
let out = self.click(text_at(&out, "Range:"));
|
||||
assert!(!self.app.ui.dialog.as_ref().unwrap().bool("all"));
|
||||
self.click(text_at(&out, "1-80"));
|
||||
self.frame(vec![Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }]);
|
||||
let out = self.frame(vec![Event::Text(range.into())]);
|
||||
assert_eq!(self.app.ui.dialog.as_ref().unwrap().str("range"), range);
|
||||
assert!(self.written.borrow().is_empty());
|
||||
self.click(text_at(&out, "Export…"));
|
||||
let d = self.app.ui.dialog.as_ref().unwrap();
|
||||
assert!(d.bool("useArtboards"));
|
||||
assert_eq!(d.str("range"), range);
|
||||
}
|
||||
|
||||
fn finish(&mut self, button: &str) {
|
||||
let out = self.settle();
|
||||
self.click(text_at(&out, button));
|
||||
assert!(self.app.ui.dialog.is_none(), "{}", self.app.ui.status);
|
||||
}
|
||||
}
|
||||
|
||||
fn text_at(out: &egui::FullOutput, label: &str) -> Pos2 {
|
||||
fn find(shape: &Shape, label: &str) -> Option<Pos2> {
|
||||
match shape {
|
||||
Shape::Text(t) if t.galley.text().trim() == label => Some(t.pos + vec2(4.0, 4.0)),
|
||||
Shape::Vec(v) => v.iter().find_map(|s| find(s, label)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
out.shapes.iter().find_map(|s| find(&s.shape, label)).unwrap_or_else(|| panic!("missing {label}: {}", crate::tests_labels::shapes_text(out)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_range_preserves_export_dialog_and_writes_nothing() {
|
||||
let mut h = ExportHarness::new();
|
||||
h.app.run("file.exportAs", json!({"format": "svg", "useArtboards": true, "range": "81"})).unwrap();
|
||||
let out = h.settle();
|
||||
h.click(text_at(&out, "Export…"));
|
||||
assert_eq!(h.app.ui.dialog.as_ref().unwrap().kind, "exportAs");
|
||||
assert!(h.written.borrow().is_empty());
|
||||
assert!(h.app.ui.status.contains("81"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_range_in_svg_options_keeps_the_modal_and_saved_options() {
|
||||
let mut h = ExportHarness::new();
|
||||
h.choose_range("svg", "80");
|
||||
let saved = h.app.ui.svg_options.clone();
|
||||
let out = h.settle();
|
||||
h.click(text_at(&out, "80"));
|
||||
h.frame(vec![Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }]);
|
||||
let out = h.frame(vec![Event::Text("81".into())]);
|
||||
assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("range"), "81");
|
||||
h.click(text_at(&out, "OK"));
|
||||
assert_eq!(h.app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("svgOptions"));
|
||||
assert_eq!(h.app.ui.svg_options, saved);
|
||||
assert!(h.written.borrow().is_empty());
|
||||
assert!(h.app.ui.status.contains("81"));
|
||||
let out = h.settle();
|
||||
h.click(text_at(&out, "81"));
|
||||
h.frame(vec![Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }]);
|
||||
h.frame(vec![Event::Text("80".into())]);
|
||||
h.finish("OK");
|
||||
assert_eq!(h.written.borrow().len(), 1);
|
||||
let written = h.written.borrow();
|
||||
let svg = std::str::from_utf8(&written[0].1).unwrap();
|
||||
assert!(svg.contains("viewBox=\"0 0 80 60\"") && svg.contains("LastArt"));
|
||||
}
|
||||
Loading…
Reference in New Issue