Files
vectorcraft/crates/svg/tests/it/ids.rs
T
NetterSilverandClaude Opus 5.5 19d4adb39c Make SVG export def ids linear instead of quadratic
Writer::fresh_id probed prefix-1, prefix-2, ... from 1 on every call,
formatting and hashing each candidate, so writing N gradients, clip
paths or masks cost O(N^2). Ids are never released, so every number
below the last one handed out for a prefix is taken: remember where
the search for each prefix stopped and resume there. The ids written
are the same as before.

examples/still-life.vectorcraft (12k gradients, 6.3 MB SVG) exported
in about 8.6 s (median); it now takes about 0.15 s. Output is byte-identical
for every document in examples/.

Add crates/engine/examples/export_bench.rs to time document.export
on the example documents, and a test pinning the generated id
sequence when object names already take some of the numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:52:22 +01:00

139 lines
6.6 KiB
Rust

//! 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<String> {
svg.split(" id=\"").skip(1).map(|s| s[..s.find('"').unwrap()].to_string()).collect()
}
fn names(d: &Document) -> Vec<String> {
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 &amp; Front\""), "{s}");
let back = import(&s).unwrap();
assert_eq!(back.layers.iter().map(|l| l.name.clone().unwrap()).collect::<Vec<_>>(), ["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("<g id=\"Layer_1\">"), "{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::<HashSet<_>>().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::<HashSet<_>>();
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::<HashSet<_>>().len(), "unique ids");
let prefix = all.iter().find_map(|i| i.find("Layer_1").map(|at| i[..at].to_string())).unwrap();
let numbers: Vec<usize> =
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<usize> = numbers.into_iter().filter(|n| *n != 2 && *n != 4).collect();
gradients.sort_unstable();
let want: Vec<usize> = [1, 3].into_iter().chain(5..count + 3).collect();
assert_eq!(gradients, want);
}
}
#[test]
fn label_attributes_name_objects() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:serif="http://www.serif.com/" width="100" height="100">
<g id="layer1" inkscape:label="Background">
<rect id="r1" data-name="Sky box" x="0" y="0" width="10" height="10"/>
<rect id="r2" serif:id="Sun disc" x="20" y="0" width="10" height="10"/>
<rect id="r3" aria-label="Badge" x="40" y="0" width="10" height="10"/>
<rect id="plain" x="60" y="0" width="10" height="10"/>
</g>
</svg>"##;
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"]);
}