From 37803cb1c80902f3e0a6623de8eb9daddce6a48b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 04:24:14 +0000 Subject: [PATCH] perf(format): index the lookups that made style-heavy files quadratic REST import searched every style imported so far for each layer's style reference; .fig export compared each text span's style with every distinct style before it; Penpot token-set ordering searched the whole order array for every comparison of the sort. Each now looks up a set, a map or a position table built once. --- crates/format/src/fig/export/shape.rs | 18 +++++++++++++----- crates/format/src/fig/export_tests.rs | 16 ++++++++++++++++ crates/format/src/penpot.rs | 5 ++++- crates/format/src/rest.rs | 8 +++++++- .../format/src/rest/import_regression_tests.rs | 18 ++++++++++++++++++ 5 files changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs index e4e2cae6b..58309784f 100644 --- a/crates/format/src/fig/export/shape.rs +++ b/crates/format/src/fig/export/shape.rs @@ -56,16 +56,24 @@ impl<'d> Exporter<'d> { } let spans = td.spans(); let mut table: Vec<&TextStyle> = Vec::new(); + // Each distinct style once, found by its serialized form rather than by comparing it + // with every earlier one (quadratic in the number of distinct styles). + let mut index: std::collections::HashMap = std::collections::HashMap::new(); let mut span_ids = Vec::with_capacity(spans.len()); for (_, _, st) in &spans { span_ids.push(if **st == td.style { 0 } else { - let i = table.iter().position(|t| *t == *st).unwrap_or_else(|| { - table.push(st); - table.len() - 1 - }); - i as u32 + 1 + match serde_json::to_string(st) { + Ok(key) => *index.entry(key).or_insert_with(|| { + table.push(st); + table.len() as u32 + }), + Err(_) => { + table.push(st); + table.len() as u32 + } + } }); } let mut chars = String::new(); diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index b1b31262a..f6951cdcb 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -1611,3 +1611,19 @@ fn a_whole_ellipse_starting_anywhere_comes_back_whole() { } } } + +#[test] +fn text_with_thousands_of_distinct_styles_exports_in_linear_time() { + // The style table used to be searched end to end for every span (quadratic in distinct styles). + let mut doc = Document::new("styles"); + let p = doc.first_page().unwrap(); + let mut td = TextData::new("x".repeat(20_000)); + td.runs = (0..20_000) + .map(|i| StyleRun { start: i, end: i + 1, style: TextStyle { font_size: 8.0 + i as f64 / 100.0, ..TextStyle::default() } }) + .collect(); + doc.create(p, NodeKind::Text(td), "T", 0.0, 0.0, 100.0, 100.0).unwrap(); + let start = std::time::Instant::now(); + let (bytes, _) = export(&doc, None).unwrap(); + assert!(!bytes.is_empty()); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); +} diff --git a/crates/format/src/penpot.rs b/crates/format/src/penpot.rs index 6cd8fe53c..7ffd208d8 100644 --- a/crates/format/src/penpot.rs +++ b/crates/format/src/penpot.rs @@ -2131,7 +2131,10 @@ fn import_themed_tokens( if set_names.is_empty() { return false; } - set_names.sort_by_key(|name| order.iter().position(|o| o == name).unwrap_or(usize::MAX)); + // Each set's place in the order once: a position search per comparison was quadratic in + // the order's length for every one of the sort's comparisons. + let place: BTreeMap<&str, usize> = order.iter().enumerate().map(|(i, o)| (*o, i)).collect(); + set_names.sort_by_key(|name| place.get(name).copied().unwrap_or(usize::MAX)); let set_names: Vec<&str> = set_names.into_iter().take(512).collect(); let mut tokens: BTreeMap<&str, BTreeMap> = BTreeMap::new(); for name in &set_names { diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs index 4604b73a6..9c1d27dde 100644 --- a/crates/format/src/rest.rs +++ b/crates/format/src/rest.rs @@ -427,6 +427,9 @@ pub(crate) struct Ctx<'a> { component_meta: BTreeMap<&'a str, &'a Value>, set_meta: BTreeMap<&'a str, &'a Value>, style_meta: BTreeMap<&'a str, &'a Value>, + /// The ids of the styles imported so far (`doc.styles` is a Vec: a lookup per reference + /// would be quadratic in a document with many distinct references). + style_ids: BTreeSet, /// Instances (imported as frames until their main is resolved) with their JSON. instances: Vec<(NodeId, &'a Value)>, /// `SLOT` layers (imported as frames until their slot property is resolved) with their JSON. @@ -468,6 +471,7 @@ impl<'a> Ctx<'a> { component_meta: BTreeMap::new(), set_meta: BTreeMap::new(), style_meta: BTreeMap::new(), + style_ids: BTreeSet::new(), instances: Vec::new(), slots: Vec::new(), slot_defaults: BTreeMap::new(), @@ -941,6 +945,7 @@ impl<'a> Ctx<'a> { }; self.bindings(&mut sample, node); let bound = sample.bound.iter().filter_map(|(k, v)| Some((style_binding(slot, k)?, v.clone()))).collect(); + self.style_ids.insert(id.to_string()); self.doc.styles.push(Style { id: id.to_string(), name, description, kind, bound }); } } @@ -961,13 +966,14 @@ impl<'a> Ctx<'a> { _ => continue, }; *target = Some(sid.to_string()); - if self.doc.styles.iter().any(|s| s.id == sid) { + if self.style_ids.contains(sid) { continue; } let meta = self.style_meta.get(sid); let name = meta.and_then(|m| text(m, "name")).filter(|s| !s.is_empty()).map_or_else(|| format!("Style {sid}"), |s| clip_str(s).to_string()); let description = meta.and_then(|m| text(m, "description")).map(|s| clip_str(s).to_string()).unwrap_or_default(); + self.style_ids.insert(sid.to_string()); self.doc.styles.push(Style { id: sid.to_string(), name, description, kind, bound: BTreeMap::new() }); } } diff --git a/crates/format/src/rest/import_regression_tests.rs b/crates/format/src/rest/import_regression_tests.rs index 71d9e0afb..5aeb4548b 100644 --- a/crates/format/src/rest/import_regression_tests.rs +++ b/crates/format/src/rest/import_regression_tests.rs @@ -75,3 +75,21 @@ fn stroke_colour_variable_on_outlined_vector_is_kept() { icon.bound ); } + +#[test] +fn thousands_of_distinct_style_references_import_in_linear_time() { + // Each reference used to search every style imported so far (quadratic in their number). + let fill = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}}); + let n = 20_000; + let children: Vec = (0..n) + .map(|i| { + json!({"id": format!("1:{i}"), "name": "R", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, + "fills": [fill], "styles": {"fill": format!("S:{i}")}}) + }) + .collect(); + let v = file(Value::Array(children)); + let start = std::time::Instant::now(); + let (doc, _) = import(&v, &no_images).unwrap(); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); + assert_eq!(doc.styles.len(), n); +}