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.
This commit is contained in:
Dylan M. Taylor committed 2026-11-03 14:30:05 +00:00
1 parent 02c38a6d95
commit 37803cb1c8
5 files changed
+58 -7

No files matched your search

+13 -5
View File
@@ -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<String, u32> = 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();
+16
View File
@@ -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());
}
+4 -1
View File
@@ -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<String, &Value>> = BTreeMap::new();
for name in &set_names {
+7 -1
View File
@@ -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<String>,
/// 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() });
}
}
@@ -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<Value> = (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);
}