//! Object IDs: names survive the round trip (Layer Names), Minimal writes only referenced ids, //! Unique ids never collide, and other apps' label attributes become object names. use std::collections::HashSet; use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::{Appearance, Document, Node}; use vectorcraft_geom::{Rect, shapes}; use vectorcraft_svg::{ExportOptions, ObjectIds, Styling, export, import}; /// A document with a named rectangle, an unnamed gradient-filled one and a second layer. fn doc(title: &str, shift: f64) -> Document { let mut d = Document::new(200.0, 200.0); d.title = title.into(); let l = d.layers[0].id; let mut a = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0 + shift, 10.0, 60.0, 60.0)), Appearance::default_art()); a.name = Some("Logo mark (v2)".into()); d.insert(Some(l), usize::MAX, a).unwrap(); let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); let b = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(80.0, 10.0, 120.0, 60.0)), Appearance::basic(g, Paint::None, 0.0)); d.insert(Some(l), usize::MAX, b).unwrap(); d.add_layer(Some("Top & Front")); let top = d.layers[1].id; let c = Node::path( d.alloc_id(), shapes::ellipse(Rect::new(0.0, 100.0, 50.0, 150.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), ); d.insert(Some(top), usize::MAX, c).unwrap(); d } /// Every `id="…"` value. fn ids(svg: &str) -> Vec { svg.split(" id=\"").skip(1).map(|s| s[..s.find('"').unwrap()].to_string()).collect() } fn names(d: &Document) -> Vec { let mut v = vec![]; d.walk(|n| v.extend(n.name.clone())); v } #[test] fn layer_names_round_trip_exactly() { let d = doc("a", 0.0); let s = export(&d, &ExportOptions::default()); assert!(s.contains("id=\"Layer_1\" data-name=\"Layer 1\""), "{s}"); assert!(s.contains("data-name=\"Top & Front\""), "{s}"); let back = import(&s).unwrap(); assert_eq!(back.layers.iter().map(|l| l.name.clone().unwrap()).collect::>(), ["Layer 1", "Top & Front"]); assert!(names(&back).contains(&"Logo mark (v2)".to_string()), "{:?}", names(&back)); // An id that is the name already gets no data-name. let mut d = Document::new(10.0, 10.0); std::sync::Arc::make_mut(&mut d.layers[0]).name = Some("Layer_1".into()); let s = export(&d, &ExportOptions::default()); assert!(s.contains(""), "{s}"); } #[test] fn minimal_writes_only_referenced_ids() { let s = export(&doc("a", 0.0), &ExportOptions { object_ids: ObjectIds::Minimal, ..Default::default() }); let ids = ids(&s); assert!(!ids.is_empty(), "the gradient keeps its id"); for id in &ids { assert!(s.contains(&format!("#{id})")) || s.contains(&format!("\"#{id}\"")), "`{id}` is never referenced:\n{s}"); } assert!(!s.contains("data-name")); } #[test] fn unique_ids_never_collide() { let opts = ExportOptions { object_ids: ObjectIds::Unique, styling: Styling::InternalCss, ..Default::default() }; let a = export(&doc("a", 0.0), &opts); let b = export(&doc("b", 5.0), &opts); let (ia, ib) = (ids(&a), ids(&b)); assert_eq!(ia.len(), ia.iter().collect::>().len(), "unique within a document"); assert!(ia.iter().all(|i| !ib.contains(i)), "{ia:?} vs {ib:?}"); // CSS class names are page-global too. let classes = |s: &str| s.split("class=\"").skip(1).map(|s| s[..s.find('"').unwrap()].to_string()).collect::>(); assert!(classes(&a).is_disjoint(&classes(&b))); // The same document gets the same ids, and the names still come back. assert_eq!(a, export(&doc("a", 0.0), &opts)); let back = import(&a).unwrap(); assert_eq!(back.layers[0].name.as_deref(), Some("Layer 1")); assert!(names(&back).contains(&"Logo mark (v2)".to_string())); } /// Generated def ids count up from 1 and step over numbers an object name already took, also /// when thousands of them are written. #[test] fn generated_ids_count_up_past_taken_names() { let mut d = Document::new(200.0, 200.0); let l = d.layers[0].id; for name in ["linear-gradient-2", "linear-gradient-4"] { let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Appearance::default_art()); n.name = Some(name.into()); d.insert(Some(l), usize::MAX, n).unwrap(); } let count = 3000; for i in 0..count { let stops = vec![ GradientStop::new(0.0, Color::rgb((i % 50) as f32 / 255.0, (i / 50) as f32 / 255.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0)), ]; let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)), Appearance::basic(g, Paint::None, 0.0)); d.insert(Some(l), usize::MAX, n).unwrap(); } for object_ids in [ObjectIds::LayerNames, ObjectIds::Unique] { let s = export(&d, &ExportOptions { object_ids, ..Default::default() }); let all = ids(&s); assert_eq!(all.len(), all.iter().collect::>().len(), "unique ids"); let prefix = all.iter().find_map(|i| i.find("Layer_1").map(|at| i[..at].to_string())).unwrap(); let numbers: Vec = all.iter().filter_map(|i| i.strip_prefix(&format!("{prefix}linear-gradient-"))).map(|n| n.parse().unwrap()).collect(); assert_eq!(numbers.len(), count + 2, "the two named objects and every gradient"); let mut gradients: Vec = numbers.into_iter().filter(|n| *n != 2 && *n != 4).collect(); gradients.sort_unstable(); let want: Vec = [1, 3].into_iter().chain(5..count + 3).collect(); assert_eq!(gradients, want); } } #[test] fn label_attributes_name_objects() { let svg = r##" "##; let d = import(svg).unwrap(); assert_eq!(d.layers[0].name.as_deref(), Some("Background")); assert_eq!(names(&d), ["Background", "Sky box", "Sun disc", "Badge", "plain"]); } #[test] fn unique_ids_differ_when_only_gradient_stops_differ() { let make_doc = |stop_color: Color| { let mut d = Document::new(200.0, 200.0); d.title = "Identical Structure".into(); let l = d.layers[0].id; let stops = vec![GradientStop::new(0.0, stop_color), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)), Appearance::basic(g, Paint::None, 0.0)); d.insert(Some(l), usize::MAX, n).unwrap(); d }; let opts = ExportOptions { object_ids: ObjectIds::Unique, ..Default::default() }; let svg1 = export(&make_doc(Color::rgb(1.0, 0.0, 0.0)), &opts); let svg2 = export(&make_doc(Color::rgb(0.0, 1.0, 0.0)), &opts); let ids1 = ids(&svg1); let ids2 = ids(&svg2); assert!(!ids1.is_empty() && !ids2.is_empty()); assert!(ids1.iter().all(|id| !ids2.contains(id)), "IDs must not collide: {ids1:?} vs {ids2:?}"); }