From c4d5f70c1d4903ae759d41406d00a52ad9198728 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Sun, 8 Nov 2026 01:55:24 -0300 Subject: [PATCH] DOCX: resolve LibreOffice font fallback lists to first installed family (#5843) (#5851) * #5843: resolve LibreOffice semicolon-separated font fallback lists to first installed family * #5843: address review comments on font fallback list resolution * #5843: verify fallback list text on cache hit and accept Times New Roman in tests * #5843: bound the fallback lists read from a DOCX and reuse their classification A document could name thousands of fallback families in `w:ascii`/`w:hAnsi`. Nothing bounded that value, and the name-based rules (roman classification, Thai family, named substitutes) then scanned every candidate for each shaped piece, so opening the document took seconds. `w:ascii` and `w:hAnsi` now keep the leading families within MAX_FONT_NAME bytes, cut at a `;` so no name is split, as `w:cs`/`w:eastAsia` already bound their names. The family a real list would pick is kept: a LibreOffice list names a few families, well under the bound. The Thai-family and named-substitute answers are worked out once per list and kept in the fallback-list cache, keyed by the list text, so shaping no longer rescans candidates for every piece. Resolution still reads every candidate. A 500-paragraph document whose font names 20,000 candidates laid out in 10.8 s; bounded on read it takes 17 ms. Tests: - a hostile fallback list keeps its leading families, cut on a `;` - a real LibreOffice list is read whole - a later Thai or named fallback still classifies, and lists are cached --- crates/doc/src/symbol_fonts.rs | 9 +- crates/docx/src/read/parts.rs | 2 + crates/docx/src/read/props.rs | 32 ++- crates/docx/tests/it/fixtures.rs | 36 +++ crates/engine/src/tests/fonts.rs | 40 ++++ crates/fonts/src/word.rs | 398 +++++++++++++++++++++++++++++-- crates/layout/src/para/chars.rs | 10 +- 7 files changed, 500 insertions(+), 27 deletions(-) diff --git a/crates/doc/src/symbol_fonts.rs b/crates/doc/src/symbol_fonts.rs index 775de27736..1d5175540b 100644 --- a/crates/doc/src/symbol_fonts.rs +++ b/crates/doc/src/symbol_fonts.rs @@ -19,8 +19,8 @@ //! //! Tables are indexed by code − 0x20; 0 = no Unicode equivalent. -/// The symbol fonts this module knows, by lowercase family name. -fn table(family: &str) -> Option<&'static [u32; 224]> { +/// The symbol table for a single family name, by lowercase family name. +fn single_table(family: &str) -> Option<&'static [u32; 224]> { let f = family.trim(); if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") { Some(&SYMBOL) @@ -37,6 +37,11 @@ fn table(family: &str) -> Option<&'static [u32; 224]> { } } +/// The symbol fonts this module knows, by lowercase family name. +fn table(family: &str) -> Option<&'static [u32; 224]> { + if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) } +} + /// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3, /// Webdings), whose characters [`symbol_char`] maps. pub fn is_symbol_font(family: &str) -> bool { diff --git a/crates/docx/src/read/parts.rs b/crates/docx/src/read/parts.rs index 2e1f98e32f..ce4184dc7d 100644 --- a/crates/docx/src/read/parts.rs +++ b/crates/docx/src/read/parts.rs @@ -55,6 +55,8 @@ impl Reader<'_> { .children("w:font") .filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman")) .filter_map(|f| f.attr("w:name")) + .flat_map(|n| n.split(';')) + .map(str::trim) .filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME) .take(MAX_TABLE_FONTS) .map(str::to_string) diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index a3d8558bb1..182ee179f2 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -13,6 +13,7 @@ use wordcraft_doc::section::{ PageVAlign, SectionProps, SectionStart, }; +use super::MAX_FONT_NAME; use super::drawing_color::adjust_lum; use crate::units::{int, line_multiple, measure, on_off, tw, u32_of}; @@ -145,6 +146,29 @@ pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: & } } +/// A `w:rFonts` value as the document keeps it: `None` when it names no family, else its leading +/// families up to [`MAX_FONT_NAME`] bytes, cut at a `;` so no name is split. A LibreOffice file +/// names its Latin font as a semicolon-separated fallback list, and a real list names a few +/// families; a longer value is corrupt or hostile, and every piece of text drawn in it would pay +/// for its whole length as the list is classified and resolved. Keeping the leading families keeps +/// the family such a list picks, and the rest of the value is dropped like `w:cs`/`w:eastAsia` +/// drop an over-long name. +fn bounded_font_list(v: &str) -> Option { + if v.len() <= MAX_FONT_NAME { + return (!v.is_empty()).then(|| v.to_string()); + } + let mut end = 0usize; + for part in v.split_inclusive(';') { + let next = end + part.len(); + if next > MAX_FONT_NAME { + break; + } + end = next; + } + let kept = v.get(..end)?.trim_end_matches(';'); + (!kept.is_empty()).then(|| kept.to_string()) +} + impl PropCtx { pub fn style_id(&self, id: &str) -> String { self.alias.get(id).cloned().unwrap_or_else(|| id.to_string()) @@ -226,6 +250,8 @@ impl PropCtx { shd_fill(e) } + /// The character properties `e` (a `w:rPr`) sets: direct formatting, plus the fonts, size, + /// weight and posture of the complex-script and East Asian text beside them. pub fn rpr(&self, e: &El) -> CharProps { let mut c = CharProps::default(); for k in e.els() { @@ -234,7 +260,11 @@ impl PropCtx { "w:rFonts" => { // A theme font supersedes the fixed font beside it (`w:asciiTheme` over // `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`). - let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string); + // A LibreOffice file names the Latin font as a semicolon-separated fallback + // list. Names are bounded like `w:cs`/`w:eastAsia` below: a longer one is + // corrupt or hostile, and every piece of text drawn in it would pay for its + // whole length as the list is classified (see `bounded_font_list`). + let named = |a: &str| k.attr(a).and_then(bounded_font_list); let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t)); // `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` / // `font_cs`: `w:hint` only picks between them for ambiguous characters, never for diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index 9f82b95308..9d2e902505 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -3947,6 +3947,33 @@ fn bidi_theme_slots_follow_the_theme_font_language() { } } +/// A `w:rFonts` fallback list longer than the reader keeps is cut at the last complete family +/// within the bound, so the family a real list would pick survives while a hostile list cannot +/// cost its whole length in every piece of text drawn in it. +#[test] +fn a_hostile_fallback_list_keeps_its_leading_families() { + let candidates: Vec = (0..500).map(|i| format!("MissingCandidateFont{i}")).collect(); + let list = format!("{};Source Serif 4", candidates.join(";")); + let body = format!(r#"x"#); + let d = read_body(&body); + let c = &d.body[0].as_para().unwrap().runs[0].props; + let kept = c.font.as_deref().expect("the leading families are kept"); + assert!(kept.len() <= 256, "{} bytes kept", kept.len()); + assert!(kept.starts_with("MissingCandidateFont0"), "{kept}"); + // Every kept entry is a whole family name: the cut lands on a `;`, never inside a name. + assert!(kept.split(';').all(|f| f.starts_with("MissingCandidateFont") || f.contains("Source Serif")), "{kept}"); + assert!(!kept.ends_with(';'), "no dangling separator: {kept}"); +} + +/// A real LibreOffice fallback list is read whole: a few families, well under any bound. +#[test] +fn a_real_fallback_list_is_read_whole() { + let body = r#"x"#; + let d = read_body(body); + let c = &d.body[0].as_para().unwrap().runs[0].props; + assert_eq!(c.font.as_deref(), Some("Liberation Sans;Arial")); +} + /// The font table's family classes: Word draws a missing roman font in a serif face. #[test] fn roman_fonts_are_read_from_the_font_table() { @@ -3956,6 +3983,15 @@ fn roman_fonts_are_read_from_the_font_table() { assert_eq!(d.settings.roman_fonts, ["Brand Serif"]); } +/// Semicolon-separated roman font fallback lists in `fontTable.xml` register each candidate family. +#[test] +fn roman_fonts_split_fallback_lists() { + let fonts = r#""#; + let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")]; + let d = wordcraft_docx::read(&docx("", &rels, &[("word/fontTable.xml", fonts)])).unwrap(); + assert_eq!(d.settings.roman_fonts, ["Liberation Serif", "Times New Roman"]); +} + /// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up. #[test] fn html_auto_spacing_off_is_read_and_kept() { diff --git a/crates/engine/src/tests/fonts.rs b/crates/engine/src/tests/fonts.rs index 103f2029c9..960c56329f 100644 --- a/crates/engine/src/tests/fonts.rs +++ b/crates/engine/src/tests/fonts.rs @@ -469,3 +469,43 @@ fn an_east_asian_font_is_shown_as_the_face_its_text_is_drawn_in() { assert_eq!(m[0].shown_as, [shown], "{m:?}"); assert!(crate::cmd::font::east_asian_shown_as(&doc, "JetBrains Mono", "JetBrains Mono").is_none(), "installed"); } + +/// A semicolon-separated LibreOffice font fallback list is not reported as missing when any +/// candidate is installed, and substitutes by candidate family when none is. +#[test] +fn libreoffice_font_fallback_lists_are_not_reported_as_missing_when_installed() { + let db = wordcraft_fonts::FontDb::global(); + let doc = doc_of(vec![para_in("Text", "Liberation Sans;Arial")]); + if db.has_family("Liberation Sans") || db.has_family("Arial") { + let m = missing_in(doc.clone()); + assert!(m.is_empty(), "expected not to be reported as missing: {m:?}"); + } + let bundled_fallback = doc_of(vec![para_in("Text", "NonExistentSans123;Source Sans 3")]); + assert!(missing_in(bundled_fallback).is_empty(), "bundled fallback candidate is installed"); + + // Roundtrip through DOCX export and import: + let docx_bytes = crate::io::save_bytes("out.docx", &doc).unwrap(); + let loaded = wordcraft_docx::read(&docx_bytes).unwrap(); + assert_eq!(loaded.body[0].as_para().unwrap().runs[0].props.font.as_deref(), Some("Liberation Sans;Arial")); + if db.has_family("Liberation Sans") || db.has_family("Arial") { + let m = missing_in(loaded); + assert!(m.is_empty(), "expected not to report installed fallback font as missing: {m:?}"); + } + + // When none of the fonts is installed, missing_fonts flags it with an appropriate substitute: + if !db.has_family("NonExistentSans123") && !db.has_family("Times New Roman") { + let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;Times New Roman")])); + assert_eq!(m.len(), 1); + assert_eq!(m[0].font, "NonExistentSans123;Times New Roman"); + let drawn = m[0].shown_as.first().expect("drawn face"); + assert!(drawn.contains("Serif") || drawn == "Times" || drawn == "Tinos", "drawn: {drawn}"); + } + let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;NonExistentSerif123")])); + assert_eq!(m.len(), 1); + assert_eq!(m[0].font, "NonExistentSans123;NonExistentSerif123"); + let drawn = m[0].shown_as.first().expect("drawn face"); + assert!( + drawn.contains("Serif") || drawn == "Times" || drawn == "Times New Roman" || drawn == "Tinos" || drawn == "Caladea" || drawn == "Georgia", + "drawn: {drawn}" + ); +} diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index 5c73939c22..4860faf5a0 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -32,12 +32,27 @@ fn substitutes(family: &str) -> &'static [&'static str] { /// as Liberation Serif for Times New Roman, or the same design under another name), rather than /// the generic lists any unknown font gets? pub fn has_named_substitutes(family: &str) -> bool { + if family.contains(';') { + return list_class(family).named; + } substitutes_by_name(charset_base_family(family)).1 } +const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"]; + /// [`substitutes`], and whether they were chosen for this family by name. fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) { - const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"]; + if family.contains(';') { + for cand in family.split(';').map(str::trim).filter(|s| !s.is_empty()) { + let (subs, named) = substitutes_by_name(cand); + if named || is_thai_family(cand) || subs != SANS { + return (subs, named); + } + } + if let Some(first) = family.split(';').map(str::trim).find(|s| !s.is_empty()) { + return substitutes_by_name(first); + } + } const SERIF: &[&str] = &["Caladea", "Times New Roman", "Liberation Serif", "Georgia", "Noto Serif", "DejaVu Serif", "Source Serif 4"]; const MONO: &[&str] = &["Courier New", "Liberation Mono", "Menlo", "Consolas", "DejaVu Sans Mono", "JetBrains Mono"]; const ARIAL: &[&str] = &["Liberation Sans", "Helvetica", "Helvetica Neue", "Arimo", "Source Sans 3"]; @@ -157,11 +172,11 @@ fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) { /// itself: a Symbol bullet makes its line taller than the text alone. The values are those Word's /// PDFs give the fonts (`/Ascent`, `/Descent`). pub fn symbol_font_metrics(family: &str) -> Option<(f64, f64)> { - match family.trim().to_ascii_lowercase().as_str() { + family.split(';').map(str::trim).filter(|f| !f.is_empty()).find_map(|f| match f.to_ascii_lowercase().as_str() { "symbol" | "symbol (pcl6)" => Some((1.005, 0.216)), "wingdings" => Some((0.899, 0.205)), _ => None, - } + }) } /// The (ascent, descent) in em Word gives a line with a run in a symbol font, when that isn't the @@ -224,7 +239,10 @@ fn metric_twins(family: &str) -> impl Iterator + '_ { /// wraps and paginates as in `family`. pub fn metric_compatible(family: &str, substitute: &str) -> bool { let s = substitute.trim(); - metric_twins(charset_base_family(family.trim())).any(|t| t.eq_ignore_ascii_case(s)) + family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| { + let base = charset_base_family(f); + base.eq_ignore_ascii_case(s) || metric_twins(base).any(|t| t.eq_ignore_ascii_case(s)) + }) } /// Naskh-style Persian/Arabic document fonts (the "B …" family names come from the common @@ -356,10 +374,63 @@ fn is_thai_serif(f: &str) -> bool { /// Is `family` a Thai document font ([`is_thai`])? A missing one is drawn in a Thai face for the /// whole run, never in a Latin substitute (#1549). pub fn is_thai_family(family: &str) -> bool { - let f = family.to_ascii_lowercase(); + if family.contains(';') { + return list_class(family).thai; + } + let f = family.trim().to_ascii_lowercase(); is_thai(&f) || is_thai_serif(&f) } +/// What the name-based predicates answer about one semicolon-separated fallback list. Shaping asks +/// them for every piece of every run set in the list, so the answer is worked out once per list +/// rather than once per piece. +#[derive(Clone, Copy, Default)] +struct ListClass { + /// [`is_thai_family`]. + thai: bool, + /// [`has_named_substitutes`]. + named: bool, +} + +/// [`ListClass`] of the semicolon-separated `family`, memoised by its text. +fn list_class(family: &str) -> ListClass { + let key = family_list_key(family); + let generation = FontDb::global().generation(); + { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation != generation { + *c = FallbackListMemo { generation, ..Default::default() }; + } else if let Some((cached, class)) = c.classes.get(&key) + && cached == family + { + return *class; + } + } + let class = ListClass { thai: classify_thai(family), named: classify_named(family) }; + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation == generation { + if c.classes.len() >= FAMILY_CACHE_MAX { + c.classes.clear(); + } + c.classes.insert(key, (family.to_string(), class)); + } + class +} + +/// Whether any family of the list is a Thai document font: what [`is_thai_family`] answers. +fn classify_thai(family: &str) -> bool { + family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| { + let f = f.to_ascii_lowercase(); + is_thai(&f) || is_thai_serif(&f) + }) +} + +/// Whether any family of the list has substitutes of its own: what [`has_named_substitutes`] +/// answers. +fn classify_named(family: &str) -> bool { + family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| substitutes_by_name(charset_base_family(f)).1) +} + /// Khmer document fonts: the Khmer OS family, Khmer Mool1 and the like, Windows' and the Mac's /// own and Noto's. Matched by name only. fn is_khmer(f: &str) -> bool { @@ -416,8 +487,168 @@ fn resolve_cache() -> &'static Mutex { } const FAMILY_CACHE_MAX: usize = 4096; -/// Family names longer than this (bytes) are resolved but never remembered. +/// Family names longer than this (bytes) are resolved but never remembered as string keys. const FAMILY_KEY_MAX: usize = 256; +/// Maximum number of direct `FontDb::has_family` catalog scans per uncached fallback list +/// for candidate names not present in [`FontDb::families`]. +const MAX_DIRECT_CANDIDATE_PROBES: usize = 32; + +/// Memoised fallback-list resolutions and installed-family lookups for the current [`FontDb::generation`]. +#[derive(Default)] +struct FallbackListMemo { + generation: u64, + families: std::collections::HashSet, + probed: HashMap, + lists: HashMap<(u64, usize), (String, (String, bool))>, + long_resolved: HashMap<(u64, usize, bool, bool), (String, Resolved)>, + classes: HashMap<(u64, usize), (String, ListClass)>, +} + +/// Shared cache for fallback-list resolutions and candidate availability checks. +fn fallback_list_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(FallbackListMemo::default())) +} + +/// Hash and byte length of `family`, used as a compact cache key for fallback lists. +fn family_list_key(family: &str) -> (u64, usize) { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + family.hash(&mut h); + (h.finish(), family.len()) +} + +/// Whether `cand` is an installed family in `db`, using the memoised family set and bounded +/// direct catalog probes so long fallback lists do not rescan the font catalog for every candidate. +fn candidate_has_family(db: &FontDb, cand: &str, direct_probes: &mut usize) -> bool { + let cand = cand.trim(); + if cand.is_empty() || cand.len() > FAMILY_KEY_MAX { + return false; + } + let generation = db.generation(); + let lower = cand.to_ascii_lowercase(); + { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation != generation { + *c = FallbackListMemo { generation, ..Default::default() }; + } + if c.families.is_empty() { + c.families = db.families().into_iter().map(|f| f.to_ascii_lowercase()).collect(); + } + if c.families.contains(&lower) { + return true; + } + if let Some(&hit) = c.probed.get(&lower) { + return hit; + } + } + if *direct_probes == 0 { + return false; + } + *direct_probes -= 1; + let hit = db.has_family(cand); + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation == generation { + if c.probed.len() >= FAMILY_CACHE_MAX { + c.probed.clear(); + } + c.probed.insert(lower, hit); + } + hit +} + +/// Record a resolved `(family, substituted)` pair for a semicolon-separated fallback list. +fn remember_list_resolution(generation: u64, key: (u64, usize), family: &str, result: (String, bool)) -> (String, bool) { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation == generation { + if c.lists.len() >= FAMILY_CACHE_MAX { + c.lists.clear(); + } + c.lists.insert(key, (family.to_string(), result.clone())); + } + result +} + +/// Resolve a single family or semicolon-separated fallback list to `(family_name, substituted)`. +fn resolve_family(db: &FontDb, family: &str) -> (String, bool) { + if !family.contains(';') { + let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) }; + return if db.has_family(family) { + (family.to_string(), false) + } else if base != family && db.has_family(base) { + (base.to_string(), false) + } else { + (substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true) + }; + } + + let generation = db.generation(); + let list_key = family_list_key(family); + { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation != generation { + *c = FallbackListMemo { generation, ..Default::default() }; + } else if let Some((cached_family, r)) = c.lists.get(&list_key) + && cached_family == family + { + return r.clone(); + } + } + + let candidates: Vec<&str> = family.split(';').map(str::trim).filter(|s| !s.is_empty() && s.len() <= FAMILY_KEY_MAX).collect(); + if candidates.is_empty() { + return remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true)); + } + + let mut direct_probes = MAX_DIRECT_CANDIDATE_PROBES; + + // 1. First installed family in the list: + for &cand in &candidates { + let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) }; + if candidate_has_family(db, cand, &mut direct_probes) { + return remember_list_resolution(generation, list_key, family, (cand.to_string(), false)); + } + if base != cand && candidate_has_family(db, base, &mut direct_probes) { + return remember_list_resolution(generation, list_key, family, (base.to_string(), false)); + } + } + + // 2. None installed: fall back to an installed substitute for an entry in the list rather than generic Carlito. + // Try Thai substitutes before metric twins of later candidates: + for &cand in &candidates { + let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) }; + if is_thai_family(base) + && let Some(sub) = substitute(db, base) + { + return remember_list_resolution(generation, list_key, family, (sub.to_string(), true)); + } + let mut twin_probes = MAX_DIRECT_CANDIDATE_PROBES; + if let Some(twin) = metric_twins(base).find(|t| candidate_has_family(db, t, &mut twin_probes)) { + return remember_list_resolution(generation, list_key, family, (twin.to_string(), true)); + } + } + + // Try candidates with named or category substitutes (Thai, Hebrew, Khmer, Persian, serif, mono): + for &cand in &candidates { + let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) }; + let (subs, named) = substitutes_by_name(base); + if (named || is_thai_family(base) || subs != SANS) + && let Some(sub) = substitute(db, base) + { + return remember_list_resolution(generation, list_key, family, (sub.to_string(), true)); + } + } + + // Fall back to the first candidate's substitute: + if let Some(&first) = candidates.first() { + let base = if first.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(first) }; + if let Some(sub) = substitute(db, base) { + return remember_list_resolution(generation, list_key, family, (sub.to_string(), true)); + } + } + + remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true)) +} /// Resolve `family` with bold/italic to a face (memoised until user fonts are added or removed, /// [`FontDb::generation`]). @@ -427,6 +658,10 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved { let generation = db.generation(); let cacheable = family.len() <= FAMILY_KEY_MAX; let key = (if cacheable { family.to_string() } else { String::new() }, bold, italic); + let long_key = (!cacheable && family.contains(';')).then(|| { + let (h, len) = family_list_key(family); + (h, len, bold, italic) + }); { let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if c.0 != generation { @@ -436,24 +671,35 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved { return *r; } } - // Printer-driver Symbol aliases use the same encoding as Symbol. - let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) }; - let (fam, substituted) = if db.has_family(family) { - (family.to_string(), false) - } else if base != family && db.has_family(base) { - // Windows maps the charset names to the font itself: not a substitution. - (base.to_string(), false) - } else { - (substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true) - }; + if let Some(lk) = long_key { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation != generation { + *c = FallbackListMemo { generation, ..Default::default() }; + } else if let Some((cached_family, r)) = c.long_resolved.get(&lk) + && cached_family == family + { + return *r; + } + } + let (fam, substituted) = resolve_family(db, family); let face = db.face(&fam, style_name(bold, italic)); let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) }; - let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if cacheable && c.0 == generation { - if c.1.len() >= FAMILY_CACHE_MAX { - c.1.clear(); + if cacheable { + let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.0 == generation { + if c.1.len() >= FAMILY_CACHE_MAX { + c.1.clear(); + } + c.1.insert(key, r); + } + } else if let Some(lk) = long_key { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation == generation { + if c.long_resolved.len() >= FAMILY_CACHE_MAX { + c.long_resolved.clear(); + } + c.long_resolved.insert(lk, (family.to_string(), r)); } - c.1.insert(key, r); } r } @@ -787,4 +1033,114 @@ mod tests { assert!(!has_named_substitutes(f), "{f}"); } } + + /// Semicolon-separated fallback lists resolve to the first installed family, or to a + /// candidate's substitute when no listed family is installed. + #[test] + fn libreoffice_fallback_list_resolves_first_installed_family() { + let db = FontDb::global(); + if db.has_family("Liberation Sans") || db.has_family("Arial") { + let r = resolve("Liberation Sans;Arial", false, false); + assert!(!r.substituted, "should not be substituted when a candidate is installed: got {:?}", r.face.family); + if db.has_family("Liberation Sans") { + assert_eq!(r.face.family, "Liberation Sans"); + } else { + assert_eq!(r.face.family, "Arial"); + } + } + let bundled = resolve("NonExistentSansFace123;Source Serif 4", false, false); + assert!(!bundled.substituted); + assert_eq!(bundled.face.family, "Source Serif 4"); + + // When none of the families is installed, fall back to a substitute for an entry in the list rather than generic Carlito: + if !db.has_family("NonExistentSansFace123") && !db.has_family("Times New Roman") { + let r = resolve("NonExistentSansFace123;Times New Roman", false, false); + assert!(r.substituted); + assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is Times New Roman"); + assert!(r.face.family.contains("Serif") || r.face.family == "Times" || r.face.family == "Tinos", "got {:?}", r.face.family); + } + let r = resolve("NonExistentSansFace123;NonExistentSerifFace123", false, false); + assert!(r.substituted); + assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is a serif family"); + assert!( + r.face.family.contains("Serif") + || r.face.family == "Times" + || r.face.family == "Times New Roman" + || r.face.family == "Tinos" + || r.face.family == "Caladea" + || r.face.family == "Georgia", + "got {:?}", + r.face.family + ); + + // Metric compatibility of fallback list: + assert!(metric_compatible("Open Sans;Arial", "Liberation Sans")); + assert!(metric_compatible("Liberation Sans;Arial", "Helvetica")); + assert!(!metric_compatible("Open Sans;Arial", "Carlito")); + } + + /// In a mixed Thai-and-Latin fallback list where neither listed family is installed, an + /// earlier Thai substitute takes precedence over a later candidate's metric twin. + #[test] + fn thai_fallback_list_prefers_thai_substitute_before_later_metric_twin() { + let db = FontDb::global(); + let Some(thai_sub) = substitute(db, "NonExistentThaiUPC") else { + eprintln!("skipped: no Thai substitute installed"); + return; + }; + if !db.has_family("Cordia New") && !db.has_family("Arial") && metric_twins("Arial").any(|t| db.has_family(t)) { + let r = resolve("Cordia New;Arial", false, false); + assert!(r.substituted); + assert!(substitutes("Cordia New").contains(&r.face.family.as_str()), "got {:?}", r.face.family); + } + if let Some(latin) = + METRIC_TWINS.iter().flat_map(|g| g.iter().copied()).find(|&f| !db.has_family(f) && metric_twins(f).any(|t| db.has_family(t))) + { + let list = format!("NonExistentThaiUPC;{latin}"); + let r = resolve(&list, false, false); + assert!(r.substituted); + assert_eq!(r.face.family, thai_sub); + } + } + + /// Fallback lists longer than `FAMILY_KEY_MAX` still select a later installed family and + /// cache their resolution without growing `resolve_cache` string keys. + #[test] + fn long_fallback_list_selects_later_installed_family_and_caches() { + let mut candidates: Vec = (0..120).map(|i| format!("MissingCandidateFont{i}")).collect(); + candidates.push("Source Serif 4".to_string()); + let list = candidates.join(";"); + assert!(list.len() > FAMILY_KEY_MAX); + + let r1 = resolve(&list, false, false); + let r2 = resolve(&list, false, false); + assert!(!r1.substituted); + assert_eq!(r1.face.family, "Source Serif 4"); + assert_eq!(r1.face.id(), r2.face.id()); + assert!(!resolve_cache().lock().unwrap_or_else(|e| e.into_inner()).1.keys().any(|k| k.0.len() > FAMILY_KEY_MAX)); + } + + /// The name-based predicates look at every candidate, and their answer is cached per list: a + /// later Thai or named fallback still counts, and a list is classified once rather than for + /// every piece of text shaped in it. + #[test] + fn list_classification_reads_every_candidate_and_is_cached() { + // The Thai family is last, past any shorter bound, and still classifies the list. + let mut candidates: Vec = (0..200).map(|i| format!("MissingCandidateFont{i}")).collect(); + candidates.push("TH Sarabun New".to_string()); + let thai_list = candidates.join(";"); + assert!(is_thai_family(&thai_list), "a later Thai family still counts"); + + // A named substitute at the end classifies too (Times New Roman has Liberation Serif). + candidates.pop(); + candidates.push("Times New Roman".to_string()); + let named_list = candidates.join(";"); + assert!(has_named_substitutes(&named_list), "a later named fallback still counts"); + + // Both are memoised by list text: a second ask answers without rescanning, and a list + // whose text differs only after the key's hash is not mistaken for the cached one. + assert!(is_thai_family(&thai_list) && !is_thai_family(&named_list)); + let cached = fallback_list_cache().lock().unwrap_or_else(|e| e.into_inner()).classes.len(); + assert!(cached >= 2, "{cached} lists classified"); + } } diff --git a/crates/layout/src/para/chars.rs b/crates/layout/src/para/chars.rs index 28de11515e..b68d578287 100644 --- a/crates/layout/src/para/chars.rs +++ b/crates/layout/src/para/chars.rs @@ -130,13 +130,17 @@ pub(super) const MISSING_ROMAN_FONT: &str = "Cambria"; /// and where it isn't installed Liberation Serif has its metrics; Word never meets it missing. pub(super) fn lookup_family<'f>(doc: &Document, font: &'f str) -> &'f str { let db = wordcraft_fonts::FontDb::global(); - let roman = doc.settings.roman_fonts.iter().any(|f| f.eq_ignore_ascii_case(font)); + let roman = !doc.settings.roman_fonts.is_empty() + && font.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| doc.settings.roman_fonts.iter().any(|r| r.eq_ignore_ascii_case(f))); if roman && !wordcraft_fonts::word::is_thai_family(font) && !wordcraft_doc::symbol_fonts::is_symbol_font(font) && !wordcraft_fonts::word::has_named_substitutes(font) - && !db.has_family(font) - && !db.has_family(wordcraft_fonts::word::charset_base_family(font)) + && if font.contains(';') { + wordcraft_fonts::word::resolve(font, false, false).substituted + } else { + !db.has_family(font) && !db.has_family(wordcraft_fonts::word::charset_base_family(font)) + } { MISSING_ROMAN_FONT } else {