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
This commit is contained in:
batistalucasv
2026-11-08 05:55:24 +01:00
committed by GitHub
parent de4ca29309
commit c4d5f70c1d
7 changed files with 500 additions and 27 deletions
+7 -2
View File
@@ -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 {
+2
View File
@@ -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)
+31 -1
View File
@@ -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<String> {
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
+36
View File
@@ -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<String> = (0..500).map(|i| format!("MissingCandidateFont{i}")).collect();
let list = format!("{};Source Serif 4", candidates.join(";"));
let body = format!(r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="{list}" w:hAnsi="{list}"/></w:rPr><w:t>x</w:t></w:r></w:p>"#);
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#"<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans;Arial" w:hAnsi="Liberation Sans;Arial"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:font w:name="Liberation Serif;Times New Roman"><w:family w:val="roman"/></w:font></w:fonts>"#;
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")];
let d = wordcraft_docx::read(&docx("<w:p/>", &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() {
+40
View File
@@ -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}"
);
}
+377 -21
View File
@@ -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<Item = &'static str> + '_ {
/// 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<Memo> {
}
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<String>,
probed: HashMap<String, bool>,
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<FallbackListMemo> {
static CACHE: OnceLock<Mutex<FallbackListMemo>> = 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<String> = (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<String> = (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");
}
}
+7 -3
View File
@@ -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 {