Skip to main content

docling_pdf/render/font/
fallback.rs

1//! Substitute faces for fonts a PDF does not embed: the standard 14 and
2//! anything else named by `/BaseFont` alone. Like docling-parse's
3//! `blend2d_font_resolver`, the faces come from the host's font directories
4//! (Liberation / DejaVu / URW base35 / Noto on Linux, the system fonts on
5//! macOS and Windows); `.models/fonts/` is searched first so a release can
6//! ship its own, and `DOCLING_RS_FONT_DIRS` (path-list separated) adds more.
7//! Without any face the renderer outlines each glyph's box in the thin blue
8//! docling-parse draws for an unresolved cell.
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, Mutex, OnceLock};
13
14/// Which family a name asks for.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
16pub enum Family {
17    Sans,
18    Serif,
19    Mono,
20    Symbol,
21    Dingbats,
22    /// CJK text (non-embedded composite fonts with an Adobe-* ordering).
23    Cjk,
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27pub struct Style {
28    pub family: Family,
29    pub bold: bool,
30    pub italic: bool,
31}
32
33/// A face file loaded once per process.
34pub struct FallbackFace {
35    pub data: Vec<u8>,
36    pub path: PathBuf,
37}
38
39struct Index {
40    /// lower-case file stem → path.
41    files: HashMap<String, PathBuf>,
42}
43
44fn index() -> &'static Index {
45    static INDEX: OnceLock<Index> = OnceLock::new();
46    INDEX.get_or_init(|| {
47        let mut files = HashMap::new();
48        for dir in font_dirs() {
49            scan(&dir, 0, &mut files);
50        }
51        Index { files }
52    })
53}
54
55fn font_dirs() -> Vec<PathBuf> {
56    let mut dirs = Vec::new();
57    dirs.push(PathBuf::from(crate::resolve_asset(".models/fonts")));
58    if let Some(extra) = docling_core::env::nonempty("DOCLING_RS_FONT_DIRS") {
59        for d in std::env::split_paths(&extra) {
60            dirs.push(d);
61        }
62    }
63    if let Some(home) = std::env::var_os("HOME") {
64        let home = PathBuf::from(home);
65        dirs.push(home.join(".fonts"));
66        dirs.push(home.join(".local/share/fonts"));
67        dirs.push(home.join("Library/Fonts"));
68    }
69    for d in [
70        "/usr/share/fonts",
71        "/usr/local/share/fonts",
72        "/usr/X11R6/lib/X11/fonts",
73        "/Library/Fonts",
74        "/System/Library/Fonts",
75        "/System/Library/Fonts/Supplemental",
76        "C:\\Windows\\Fonts",
77    ] {
78        dirs.push(PathBuf::from(d));
79    }
80    if let Some(windir) = std::env::var_os("WINDIR") {
81        dirs.push(PathBuf::from(windir).join("Fonts"));
82    }
83    dirs
84}
85
86fn scan(dir: &Path, depth: usize, files: &mut HashMap<String, PathBuf>) {
87    if depth > 4 {
88        return;
89    }
90    let Ok(rd) = std::fs::read_dir(dir) else {
91        return;
92    };
93    for entry in rd.flatten() {
94        let p = entry.path();
95        if p.is_dir() {
96            scan(&p, depth + 1, files);
97            continue;
98        }
99        let ext = p
100            .extension()
101            .and_then(|e| e.to_str())
102            .map(|e| e.to_ascii_lowercase())
103            .unwrap_or_default();
104        if !matches!(ext.as_str(), "ttf" | "otf" | "ttc") {
105            continue;
106        }
107        if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
108            let key = stem.to_ascii_lowercase().replace([' ', '_'], "");
109            files.entry(key).or_insert(p);
110        }
111    }
112}
113
114/// Candidate file stems per family and style, in preference order — the
115/// resolver's own lists (`Arial, Liberation Sans, DejaVu Sans …`) turned into
116/// the file names those packages install under.
117fn candidates(style: Style) -> Vec<String> {
118    let (b, i) = (style.bold, style.italic);
119    let suffix_dash = |reg: &'static str,
120                       bold: &'static str,
121                       it: &'static str,
122                       bi: &'static str|
123     -> &'static str {
124        match (b, i) {
125            (true, true) => bi,
126            (true, false) => bold,
127            (false, true) => it,
128            (false, false) => reg,
129        }
130    };
131    let mut out = Vec::new();
132    let mut push = |s: String| out.push(s.to_ascii_lowercase().replace([' ', '_'], ""));
133    match style.family {
134        Family::Sans => {
135            push(format!(
136                "LiberationSans-{}",
137                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
138            ));
139            push(format!("arial{}", suffix_dash("", "bd", "i", "bi")));
140            push(format!(
141                "Arial{}",
142                suffix_dash("", " Bold", " Italic", " Bold Italic")
143            ));
144            push(format!(
145                "NimbusSans-{}",
146                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
147            ));
148            push(format!(
149                "DejaVuSans{}",
150                suffix_dash("", "-Bold", "-Oblique", "-BoldOblique")
151            ));
152            push(format!(
153                "FreeSans{}",
154                suffix_dash("", "Bold", "Oblique", "BoldOblique")
155            ));
156            push(format!(
157                "NotoSans-{}",
158                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
159            ));
160            push("Helvetica".into());
161            push("DejaVuSans".into());
162            push("LiberationSans-Regular".into());
163        }
164        Family::Serif => {
165            push(format!(
166                "LiberationSerif-{}",
167                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
168            ));
169            push(format!("times{}", suffix_dash("", "bd", "i", "bi")));
170            push(format!(
171                "Times New Roman{}",
172                suffix_dash("", " Bold", " Italic", " Bold Italic")
173            ));
174            push(format!(
175                "NimbusRoman-{}",
176                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
177            ));
178            push(format!(
179                "DejaVuSerif{}",
180                suffix_dash("", "-Bold", "-Italic", "-BoldItalic")
181            ));
182            push(format!(
183                "FreeSerif{}",
184                suffix_dash("", "Bold", "Italic", "BoldItalic")
185            ));
186            push(format!(
187                "NotoSerif-{}",
188                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
189            ));
190            push("Times".into());
191            push("DejaVuSerif".into());
192            push("LiberationSerif-Regular".into());
193        }
194        Family::Mono => {
195            push(format!(
196                "LiberationMono-{}",
197                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
198            ));
199            push(format!("cour{}", suffix_dash("", "bd", "i", "bi")));
200            push(format!(
201                "Courier New{}",
202                suffix_dash("", " Bold", " Italic", " Bold Italic")
203            ));
204            push(format!(
205                "NimbusMonoPS-{}",
206                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
207            ));
208            push(format!(
209                "DejaVuSansMono{}",
210                suffix_dash("", "-Bold", "-Oblique", "-BoldOblique")
211            ));
212            push(format!(
213                "FreeMono{}",
214                suffix_dash("", "Bold", "Oblique", "BoldOblique")
215            ));
216            push("Courier".into());
217            push("DejaVuSansMono".into());
218            push("LiberationMono-Regular".into());
219        }
220        Family::Symbol => {
221            push("StandardSymbolsPS".into());
222            push("Symbol".into());
223            push("symbol".into());
224            push("DejaVuSans".into());
225        }
226        Family::Dingbats => {
227            push("D050000L".into());
228            push("Dingbats".into());
229            push("ZapfDingbats".into());
230            push("DejaVuSans".into());
231        }
232        Family::Cjk => {
233            push("NotoSansCJK-Regular".into());
234            push("NotoSansCJKjp-Regular".into());
235            push("NotoSansCJKsc-Regular".into());
236            push("DroidSansFallbackFull".into());
237            push("DroidSansFallback".into());
238            push("wqy-microhei".into());
239            push("wqy-zenhei".into());
240            push("fonts-japanese-gothic".into());
241            push("ipag".into());
242            push("msgothic".into());
243            push("simsun".into());
244            push("PingFang".into());
245            push("DejaVuSans".into());
246        }
247    }
248    out
249}
250
251/// The face for `style`, loaded once; `None` when the host has nothing.
252pub fn face(style: Style) -> Option<Arc<FallbackFace>> {
253    static CACHE: OnceLock<Mutex<HashMap<Style, Option<Arc<FallbackFace>>>>> = OnceLock::new();
254    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
255    if let Some(v) = cache.lock().ok().and_then(|c| c.get(&style).cloned()) {
256        return v;
257    }
258    let idx = index();
259    let mut found = None;
260    for cand in candidates(style) {
261        if let Some(p) = idx.files.get(&cand) {
262            if let Ok(data) = std::fs::read(p) {
263                if ttf_parser::Face::parse(&data, 0).is_ok() {
264                    found = Some(Arc::new(FallbackFace {
265                        data,
266                        path: p.clone(),
267                    }));
268                    break;
269                }
270            }
271        }
272    }
273    // A styled variant missing → the regular face of the family.
274    if found.is_none() && (style.bold || style.italic) {
275        found = face(Style {
276            family: style.family,
277            bold: false,
278            italic: false,
279        });
280    }
281    if let Ok(mut c) = cache.lock() {
282        c.insert(style, found.clone());
283    }
284    found
285}
286
287/// Classify a `/BaseFont` name (subset prefix stripped) and descriptor flags
288/// into a fallback style, the way the standard-14 substitution table and
289/// docling-parse's name normalizer do it.
290pub fn style_for(base_font: &str, flags: Option<i64>, serif_hint: Option<bool>) -> Style {
291    let name = base_font.to_ascii_lowercase();
292    let style_part = name.split_once(['-', ',']).map(|(_, s)| s).unwrap_or("");
293    let bold = style_part.contains("bold")
294        || name.contains("bold")
295        || name.contains("black")
296        || name.contains("heavy")
297        || name.contains("semibold")
298        || flags.is_some_and(|f| f & (1 << 18) != 0);
299    let italic = name.contains("italic")
300        || name.contains("oblique")
301        || flags.is_some_and(|f| f & (1 << 6) != 0);
302    let family = if name.contains("symbol") {
303        Family::Symbol
304    } else if name.contains("dingbat") || name.contains("wingding") {
305        Family::Dingbats
306    } else if name.contains("courier")
307        || name.contains("mono")
308        || name.contains("consolas")
309        || name.contains("menlo")
310        || flags.is_some_and(|f| f & 1 != 0 && !name.contains("arial"))
311    {
312        Family::Mono
313    } else if name.contains("times")
314        || name.contains("georgia")
315        || name.contains("garamond")
316        || name.contains("book")
317        || name.contains("palatino")
318        || name.contains("century")
319        || name.contains("cambria")
320        || name.contains("minion")
321        || name.contains("nimbusrom")
322        || name.contains("roman")
323        || name.contains("serif") && !name.contains("sans")
324        || name.starts_with("cm")
325            && (name.starts_with("cmr") || name.starts_with("cmbx") || name.starts_with("cmti"))
326    {
327        Family::Serif
328    } else if name.contains("arial")
329        || name.contains("helvetica")
330        || name.contains("verdana")
331        || name.contains("calibri")
332        || name.contains("sans")
333        || name.contains("tahoma")
334        || name.contains("segoe")
335    {
336        Family::Sans
337    } else if serif_hint == Some(true) || flags.is_some_and(|f| f & 2 != 0) {
338        Family::Serif
339    } else {
340        Family::Sans
341    };
342    Style {
343        family,
344        bold,
345        italic,
346    }
347}