diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index e208f72c7..3d99d3f57 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -1,6 +1,8 @@ //! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text //! draws in a stand-in; see `FontDb::document_face`). +use std::collections::HashSet; + use deckcraft_fonts::FontDb; use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::text::{Bullet, TextBody}; @@ -12,8 +14,13 @@ use deckcraft_model::{Presentation, Shape, ShapeKind}; /// font to load and are left for the round trip. pub fn install_embedded_fonts(p: &Presentation) { let db = FontDb::global(); + // Faces can name one media item many times; each is copied and enumerated once. + let mut seen = HashSet::new(); for font in &p.embedded_fonts { for id in font.faces().into_iter().flatten() { + if !seen.insert(id) { + continue; + } let Some(item) = p.media(id) else { continue }; let Some(sfnt) = deckcraft_fonts::embedded_sfnt(&item.data) else { continue }; db.add_font(sfnt.to_vec()); @@ -27,12 +34,8 @@ pub fn install_embedded_fonts(p: &Presentation) { /// and can't be installed, so they aren't reported. pub fn missing_fonts(p: &Presentation) -> Vec { let db = FontDb::global(); - let mut out: Vec = Vec::new(); - for family in used_fonts(p) { - if !family.starts_with('.') && !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) { - out.push(family); - } - } + // used_fonts is already free of case-insensitive duplicates. + let mut out: Vec = used_fonts(p).into_iter().filter(|f| !f.starts_with('.') && !db.has_family(f)).collect(); out.sort_by_key(|f| f.to_lowercase()); out } @@ -40,9 +43,10 @@ pub fn missing_fonts(p: &Presentation) -> Vec { /// The families the slides' text runs and bullets are drawn in. pub fn used_fonts(p: &Presentation) -> Vec { let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); let mut add = |f: String| { let f = f.trim().to_string(); - if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) { + if !f.is_empty() && seen.insert(f.to_ascii_lowercase()) { out.push(f); } }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 68de13418..2a1d174bb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6458,3 +6458,24 @@ fn master_body_placeholder_format_keeps_per_level_formatting() { assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(28.0), Some(24.0))); assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]); } + +#[test] +fn many_distinct_fonts_are_listed_once_each_without_a_quadratic_scan() { + // 30,000 runs in 15,000 families, each written twice in different case. + let mut p = deckcraft_model::Presentation::default(); + let slide = std::sync::Arc::make_mut(&mut p.slides[0]); + let mut body = deckcraft_model::text::TextBody::from_text("x"); + let run = body.paragraphs[0].runs[0].clone(); + body.paragraphs[0].runs = (0..30_000) + .map(|i| { + let mut r = run.clone(); + let name = format!("Qqq Font {}", i / 2); + r.props.font = Some(if i % 2 == 0 { name } else { name.to_uppercase() }); + r + }) + .collect(); + slide.shapes[0].text = Some(body); + let used = crate::fonts::used_fonts(&p); + assert_eq!(used.iter().filter(|f| f.starts_with("Qqq Font ")).count(), 15_000); + assert_eq!(crate::fonts::missing_fonts(&p).iter().filter(|f| f.to_ascii_lowercase().starts_with("qqq font ")).count(), 15_000); +}