diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs
index 50e7291a..ef558c88 100644
--- a/crates/svg/src/export.rs
+++ b/crates/svg/src/export.rs
@@ -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("");
}
+ // 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!("{nl}", opts.encoding.xml_name()));
+ if !opts.minify || encoding == Encoding::Latin1 {
+ out.push_str(&format!("{nl}", encoding.xml_name()));
}
if opts.styling == Styling::StyleEntities && !w.classes.is_empty() {
out.push_str(", id_prefix:
out.push_str(&w.indent(1));
out.push_str(&format!("{}{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("");
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!(""));
self.defs.push_str(&art);
self.def(1, "");
diff --git a/crates/svg/tests/data_attributes.rs b/crates/svg/tests/data_attributes.rs
new file mode 100644
index 00000000..a8927970
--- /dev/null
+++ b/crates/svg/tests/data_attributes.rs
@@ -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#""#)
+}
+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#""#)).unwrap();
+ let attrs = serde_json::from_str::(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#""#)).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#""#,
+ ))
+ .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#""#)).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#""#)).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#""#)).unwrap();
+ let id = named(&doc, "shape").id;
+ doc.node_mut(id).unwrap().name = Some(serde_json::from_str::(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##""##,
+ );
+ 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::>().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::>();
+ 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#""#)).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);
+ }
+ }
+ }
+}
diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs
index ed7665dd..77619825 100644
--- a/crates/ui-egui/src/dialogs/mod.rs
+++ b/crates/ui-egui/src/dialogs/mod.rs
@@ -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;
diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs
index fe9c9345..0fd6998b 100644
--- a/crates/ui-egui/src/dialogs/svg_options.rs
+++ b/crates/ui-egui/src/dialogs/svg_options.rs
@@ -127,6 +127,11 @@ fn options(d: &Dialog) -> Value {
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result {
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() {
diff --git a/crates/ui-egui/src/dialogs/tests_svg_options_range.rs b/crates/ui-egui/src/dialogs/tests_svg_options_range.rs
new file mode 100644
index 00000000..83483c76
--- /dev/null
+++ b/crates/ui-egui/src/dialogs/tests_svg_options_range.rs
@@ -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) -> 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 {
+ 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"));
+}