//! 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").extras().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").extras().attrs.as_deref().unwrap().data, named(&doc, "shape").extras().attrs.as_deref().unwrap().data); } } } #[test] fn an_unnamed_group_around_one_object_keeps_its_own_data() { let with_data = |doc: &Document| { let mut found = vec![]; doc.walk(|n| { if let Some(a) = n.extras().attrs.as_deref().filter(|a| !a.data.is_empty()) { found.push((n.children().is_some(), a.data.clone())); } }); found }; let doc = import(&svg(r#""#)).unwrap(); let want = [(true, vec![("role".to_string(), "menu".to_string())]), (false, vec![("role".to_string(), "item".to_string())])]; assert_eq!(with_data(&doc), want); assert_eq!(with_data(&import(&export(&doc, &ExportOptions::default())).unwrap()), want); } 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").extras().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-aฬ", "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").extras().attrs.as_deref().unwrap().data, named(&doc, "shape").extras().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.extras().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); } } } }