From b5e5c2100e6a5b675aca95a7789b7927b0875d7a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:48:28 +0200 Subject: [PATCH] Make SVG unique_id linear for repeated layer names Fixes #1106 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/svg/src/lib.rs | 13 ++++++++++++- crates/svg/src/tests.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 55176aaf..5058946a 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -79,6 +79,8 @@ struct W<'a> { clip: Option, /// `id` attributes written so far. used_ids: std::collections::BTreeSet, + /// The next `_k` suffix to try per base id, so repeated names don't rescan from `_2`. + next_suffix: std::collections::HashMap, } /// ` clip-path="url(#…)"` for a clip path id (else nothing). @@ -549,7 +551,15 @@ impl W<'_> { fn unique_id(&mut self, name: &str) -> String { let base: String = name.chars().take(200).map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).collect(); let base = if base.is_empty() { "layer".to_string() } else { base }; - let id = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}_{k}") }).find(|i| !self.used_ids.contains(i)).unwrap_or(base); + let next = self.next_suffix.entry(base.clone()).or_insert(1); + let id = loop { + let k = *next; + *next += 1; + let candidate = if k == 1 { base.clone() } else { format!("{base}_{k}") }; + if !self.used_ids.contains(&candidate) { + break candidate; + } + }; self.used_ids.insert(id.clone()); id } @@ -1004,6 +1014,7 @@ pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Resu mask: None, clip: None, used_ids: Default::default(), + next_suffix: Default::default(), }; for id in ids { let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY); diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 8dece77f..2258ca20 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -159,3 +159,32 @@ fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() { assert!(svg_with(StrokeJoin::Miter).contains("stroke-width=\"2\""), "equal joins stay a stroke"); assert!(!svg_with(StrokeJoin::Round).contains("stroke-width="), "a differing join is drawn as an outline"); } + +#[test] +fn unique_id_is_linear_for_repeated_names() { + let d = Document::new("t"); + let opts = SvgOptions::default(); + let mut w = W { + doc: &d, + roots: &[], + page: Affine::IDENTITY, + defs: String::new(), + body: String::new(), + next: 0, + opts: &opts, + mask: None, + clip: None, + used_ids: Default::default(), + next_suffix: Default::default(), + }; + assert_eq!(w.unique_id("a"), "a"); + assert_eq!(w.unique_id("a_3"), "a_3"); + assert_eq!(w.unique_id("a"), "a_2"); + assert_eq!(w.unique_id("a"), "a_4"); + let start = std::time::Instant::now(); + for _ in 0..30_000 { + w.unique_id("Rectangle"); + } + assert_eq!(w.unique_id("Rectangle"), "Rectangle_30001"); + assert!(start.elapsed() < std::time::Duration::from_secs(2), "unique_id is quadratic: {:?}", start.elapsed()); +}