From 2f8dd85f4284c9b1ba0007d401d76200551c209e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 17:43:55 +0000 Subject: [PATCH 1/2] load: share one layer allowance across Penpot raw SVGs, sum font-run coverage once --- crates/engine/src/cmd/file/load.rs | 111 +++++++++++++++++++++++------ 1 file changed, 91 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 3e917d5dd..77dd68198 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -399,11 +399,19 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option) { + expand_penpot_raw_svgs_within(doc, notes, MAX_PENPOT_SVG_LAYERS); +} + +/// [`expand_penpot_raw_svgs`] with at most `room` layers made in all. +fn expand_penpot_raw_svgs_within(doc: &mut Document, notes: &mut Vec, mut room: usize) { let layers = doc .nodes() .filter_map(|node| { @@ -413,12 +421,25 @@ fn expand_penpot_raw_svgs(doc: &mut Document, notes: &mut Vec) { }) .collect::>(); let mut ok_count = 0usize; + // The SVG importer caps its own layers per file; `room` is the cap over all of a file's placeholders. for (id, name, width, height, xml, empty) in layers { + if room == 0 { + if let Some(n) = doc.node_mut(id) { + n.plugin_data.insert("penpot.svgRawStatus".into(), "not-expanded".into()); + } + if notes.len() < 100 { + notes.push(format!("Penpot SVG '{name}': not converted, the file's SVG layers are over the limit; the original markup is preserved")); + } + continue; + } if !empty { notes.push(format!("Penpot SVG '{name}': has separate child layers; the original tree was preserved to prevent duplicate rendering")); continue; } - match figcraft_svg::import_svg_with_notes(doc, xml.as_bytes(), id, 0.0, 0.0, &name) { + let before = doc.node_count(); + let imported = figcraft_svg::import_svg_with_notes(doc, xml.as_bytes(), id, 0.0, 0.0, &name); + room = room.saturating_sub(doc.node_count().saturating_sub(before)); + match imported { Ok(imported) => { // An SVG can be syntactically valid yet contain no supported drawable // elements. Do not claim success or leave an empty extra frame. @@ -470,6 +491,28 @@ fn expand_penpot_raw_svgs(doc: &mut Document, notes: &mut Vec) { } } +/// Whether some of the first `len` characters are in none of `runs`. Where runs start and stop +/// covering is summed once, so runs overlapping any number of times cost no more than their count. +fn has_uncovered(len: usize, runs: &[figcraft_doc::StyleRun]) -> bool { + let mut change = vec![0i64; len + 1]; + for run in runs { + let (start, end) = (run.start.min(len), run.end.min(len)); + if start < end { + if let Some(c) = change.get_mut(start) { + *c += 1; + } + if let Some(c) = change.get_mut(end) { + *c -= 1; + } + } + } + let mut open = 0i64; + change.iter().take(len).any(|c| { + open += c; + open <= 0 + }) +} + /// Non-destructive font diagnostic for the action palette, CLI and control API. /// Includes styled runs, not just the default typeface of each text layer. pub(super) fn font_report(doc: &Document) -> Value { @@ -485,21 +528,15 @@ fn font_report_with_families(doc: &Document, installed: &[String]) -> Value { if text.characters.is_empty() { continue; } - let mut covered = vec![false; text.characters.chars().count().min(100_000)]; + let len = text.characters.chars().count().min(100_000); let mut used = BTreeSet::new(); for run in &text.runs { - let start = run.start.min(covered.len()); - let end = run.end.min(covered.len()); - if start < end { - if !run.style.font_family.trim().is_empty() { - used.insert(run.style.font_family.clone()); - } - for entry in covered.iter_mut().take(end).skip(start) { - *entry = true; - } + if run.start.min(len) < run.end.min(len) && !run.style.font_family.trim().is_empty() { + used.insert(run.style.font_family.clone()); } } - if covered.iter().any(|entry| !entry) { + let uncovered = has_uncovered(len, &text.runs); + if uncovered { used.insert(text.style.font_family.clone()); } for family in used { @@ -599,19 +636,14 @@ fn missing_penpot_font_warnings(doc: &Document, families: &[String]) -> Vec"#; + let ids: Vec = (0..2) + .map(|i| { + let id = doc.create(page, NodeKind::Frame, &format!("S{i}"), 0.0, 0.0, 10.0, 10.0).unwrap(); + let layer = doc.node_mut(id).unwrap(); + layer.plugin_data.insert("penpot.svgRawXml".into(), svg.into()); + layer.plugin_data.insert("penpot.svgRawStatus".into(), "pending".into()); + id + }) + .collect(); + let mut warnings = Vec::new(); + expand_penpot_raw_svgs_within(&mut doc, &mut warnings, 1); + let status = |i: usize| doc.node(ids[i]).unwrap().plugin_data.get("penpot.svgRawStatus").cloned(); + assert_eq!(status(0).as_deref(), Some("expanded"), "the first uses the allowance"); + assert_eq!(status(1).as_deref(), Some("not-expanded")); + assert!(doc.node(ids[1]).unwrap().children.is_empty()); + assert!(warnings.iter().any(|w| w.contains("over the limit")), "{warnings:?}"); + } + + #[test] + fn many_overlapping_style_runs_are_summed_once() { + use figcraft_doc::StyleRun; + let mut doc = Document::new("Runs"); + let page = doc.first_page().unwrap(); + let mut t = TextData::new("x".repeat(100_000)); + t.style.font_family = "Unused Base".into(); + let mut run = t.style.clone(); + run.font_family = "Run Font".into(); + t.runs = (0..50_000).map(|_| StyleRun { start: 0, end: 100_000, style: run.clone() }).collect(); + doc.create(page, NodeKind::Text(t), "Long", 0.0, 0.0, 100.0, 30.0).unwrap(); + let report = font_report_with_families(&doc, &[]); + assert_eq!(report["fonts"].as_array().map(Vec::len), Some(1), "{report}"); + assert_eq!(report["fonts"][0]["family"], "Run Font"); + } + #[test] fn expands_inline_svg_into_editable_shapes_without_discarding_source() { let mut doc = Document::new("SVG fixture"); From cf77dfdabc040b98e2958429e40ad495dfd15659 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 21:31:26 +0000 Subject: [PATCH 2/2] load: an SVG that doesn't fit the allowance is taken out again; charge after cleanup --- crates/engine/src/cmd/file/load.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 77dd68198..da03ca1d1 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -438,7 +438,6 @@ fn expand_penpot_raw_svgs_within(doc: &mut Document, notes: &mut Vec, mu } let before = doc.node_count(); let imported = figcraft_svg::import_svg_with_notes(doc, xml.as_bytes(), id, 0.0, 0.0, &name); - room = room.saturating_sub(doc.node_count().saturating_sub(before)); match imported { Ok(imported) => { // An SVG can be syntactically valid yet contain no supported drawable @@ -453,6 +452,21 @@ fn expand_penpot_raw_svgs_within(doc: &mut Document, notes: &mut Vec, mu } continue; } + // Charged once the empty ones are gone; one that doesn't fit is taken out again. + let added = doc.node_count().saturating_sub(before); + if added > room { + let _ = doc.remove(imported.frame); + if let Some(n) = doc.node_mut(id) { + n.plugin_data.insert("penpot.svgRawStatus".into(), "not-expanded".into()); + } + if notes.len() < 100 { + notes.push(format!( + "Penpot SVG '{name}': not converted, the file's SVG layers are over the limit; the original markup is preserved" + )); + } + continue; + } + room -= added; let size = doc.node(imported.frame).map(|n| (n.width, n.height)); if let Some((svg_w, svg_h)) = size && svg_w.is_finite() @@ -837,7 +851,9 @@ mod penpot_svg_v10_tests { }) .collect(); let mut warnings = Vec::new(); - expand_penpot_raw_svgs_within(&mut doc, &mut warnings, 1); + let nodes = doc.node_count(); + expand_penpot_raw_svgs_within(&mut doc, &mut warnings, 4); + assert!(doc.node_count() - nodes <= 4, "never more layers than the allowance"); let status = |i: usize| doc.node(ids[i]).unwrap().plugin_data.get("penpot.svgRawStatus").cloned(); assert_eq!(status(0).as_deref(), Some("expanded"), "the first uses the allowance"); assert_eq!(status(1).as_deref(), Some("not-expanded"));