mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-08 19:51:32 +00:00
* #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:
1 parent
de4ca29309
commit
c4d5f70c1d
7 files changed
+500
-27
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user