From 7a49c49adecff533a7785c8830dab538d094c8c9 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:59:16 +0000 Subject: [PATCH] SVG.1: preserve data attributes and validate export ranges Co-authored-by: Codex --- crates/svg/src/export.rs | 94 +++++-- crates/svg/tests/data_attributes.rs | 261 ++++++++++++++++++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/dialogs/svg_options.rs | 5 + .../src/dialogs/tests_svg_options_range.rs | 147 ++++++++++ 5 files changed, 489 insertions(+), 20 deletions(-) create mode 100644 crates/svg/tests/data_attributes.rs create mode 100644 crates/ui-egui/src/dialogs/tests_svg_options_range.rs 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#"{body}"#) +} +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")); +}