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//!
10//! The face a family resolves to is part of the page image, hence of the
11//! layout model's input: two hosts with different fonts installed convert
12//! the same file to different regions (#633 — Arial vs Liberation Sans
13//! shifted a title's score and surfaced a footnote on one host only).
14//! `DOCLING_RS_SYSTEM_FONTS=0` stops the search at the directories the
15//! deployment controls (`.models/fonts` + `DOCLING_RS_FONT_DIRS`), so a
16//! fleet that ships its fonts renders identically everywhere; the default
17//! keeps the host directories, as docling-parse's resolver does, so a
18//! desktop needs nothing installed. `DOCLING_RS_DEBUG=1` names the face each
19//! style resolved to.
20
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23use std::sync::{Arc, Mutex, OnceLock};
24
25/// Which family a name asks for.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27pub enum Family {
28    Sans,
29    Serif,
30    Mono,
31    Symbol,
32    Dingbats,
33    /// CJK text (non-embedded composite fonts with an Adobe-* ordering).
34    Cjk,
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
38pub struct Style {
39    pub family: Family,
40    pub bold: bool,
41    pub italic: bool,
42}
43
44/// A face file loaded once per process.
45pub struct FallbackFace {
46    pub data: Vec<u8>,
47    pub path: PathBuf,
48}
49
50struct Index {
51    /// lower-case file stem → path.
52    files: HashMap<String, PathBuf>,
53}
54
55fn index() -> &'static Index {
56    static INDEX: OnceLock<Index> = OnceLock::new();
57    INDEX.get_or_init(|| {
58        let mut files = HashMap::new();
59        for dir in font_dirs() {
60            scan(&dir, 0, &mut files);
61        }
62        Index { files }
63    })
64}
65
66fn font_dirs() -> Vec<PathBuf> {
67    // Set to an "off" spelling → the host directories are left out; unset
68    // or truthy → the full list (`flag` alone would read "unset" as off).
69    let system = docling_core::env::nonempty("DOCLING_RS_SYSTEM_FONTS").is_none()
70        || docling_core::env::flag("DOCLING_RS_SYSTEM_FONTS");
71    font_dirs_from(
72        PathBuf::from(crate::resolve_asset(".models/fonts")),
73        docling_core::env::nonempty("DOCLING_RS_FONT_DIRS").as_deref(),
74        system.then(|| HostDirs {
75            home: std::env::var_os("HOME").map(PathBuf::from),
76            windir: std::env::var_os("WINDIR").map(PathBuf::from),
77        }),
78    )
79}
80
81/// The host's own font locations, searched after the deployment's.
82struct HostDirs {
83    home: Option<PathBuf>,
84    windir: Option<PathBuf>,
85}
86
87/// The search order: the release's `.models/fonts`, then `extra`
88/// (`DOCLING_RS_FONT_DIRS`, a path list), then — unless the host directories
89/// are opted out — the user's and the system's font directories.
90fn font_dirs_from(models: PathBuf, extra: Option<&str>, host: Option<HostDirs>) -> Vec<PathBuf> {
91    let mut dirs = vec![models];
92    if let Some(extra) = extra {
93        dirs.extend(std::env::split_paths(extra));
94    }
95    let Some(host) = host else {
96        return dirs;
97    };
98    if let Some(home) = host.home {
99        dirs.push(home.join(".fonts"));
100        dirs.push(home.join(".local/share/fonts"));
101        dirs.push(home.join("Library/Fonts"));
102    }
103    for d in [
104        "/usr/share/fonts",
105        "/usr/local/share/fonts",
106        "/usr/X11R6/lib/X11/fonts",
107        "/Library/Fonts",
108        "/System/Library/Fonts",
109        "/System/Library/Fonts/Supplemental",
110        "C:\\Windows\\Fonts",
111    ] {
112        dirs.push(PathBuf::from(d));
113    }
114    if let Some(windir) = host.windir {
115        dirs.push(windir.join("Fonts"));
116    }
117    dirs
118}
119
120fn scan(dir: &Path, depth: usize, files: &mut HashMap<String, PathBuf>) {
121    if depth > 4 {
122        return;
123    }
124    let Ok(rd) = std::fs::read_dir(dir) else {
125        return;
126    };
127    for entry in rd.flatten() {
128        let p = entry.path();
129        if p.is_dir() {
130            scan(&p, depth + 1, files);
131            continue;
132        }
133        let ext = p
134            .extension()
135            .and_then(|e| e.to_str())
136            .map(|e| e.to_ascii_lowercase())
137            .unwrap_or_default();
138        if !matches!(ext.as_str(), "ttf" | "otf" | "ttc") {
139            continue;
140        }
141        if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
142            let key = stem.to_ascii_lowercase().replace([' ', '_'], "");
143            files.entry(key).or_insert(p);
144        }
145    }
146}
147
148/// Candidate file stems per family and style, in preference order — the
149/// resolver's own lists (`Arial, Liberation Sans, DejaVu Sans …`) turned into
150/// the file names those packages install under.
151fn candidates(style: Style) -> Vec<String> {
152    let (b, i) = (style.bold, style.italic);
153    let suffix_dash = |reg: &'static str,
154                       bold: &'static str,
155                       it: &'static str,
156                       bi: &'static str|
157     -> &'static str {
158        match (b, i) {
159            (true, true) => bi,
160            (true, false) => bold,
161            (false, true) => it,
162            (false, false) => reg,
163        }
164    };
165    let mut out = Vec::new();
166    let mut push = |s: String| out.push(s.to_ascii_lowercase().replace([' ', '_'], ""));
167    match style.family {
168        Family::Sans => {
169            push(format!(
170                "LiberationSans-{}",
171                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
172            ));
173            push(format!("arial{}", suffix_dash("", "bd", "i", "bi")));
174            push(format!(
175                "Arial{}",
176                suffix_dash("", " Bold", " Italic", " Bold Italic")
177            ));
178            push(format!(
179                "NimbusSans-{}",
180                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
181            ));
182            push(format!(
183                "DejaVuSans{}",
184                suffix_dash("", "-Bold", "-Oblique", "-BoldOblique")
185            ));
186            push(format!(
187                "FreeSans{}",
188                suffix_dash("", "Bold", "Oblique", "BoldOblique")
189            ));
190            push(format!(
191                "NotoSans-{}",
192                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
193            ));
194            push("Helvetica".into());
195            push("DejaVuSans".into());
196            push("LiberationSans-Regular".into());
197        }
198        Family::Serif => {
199            push(format!(
200                "LiberationSerif-{}",
201                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
202            ));
203            push(format!("times{}", suffix_dash("", "bd", "i", "bi")));
204            push(format!(
205                "Times New Roman{}",
206                suffix_dash("", " Bold", " Italic", " Bold Italic")
207            ));
208            push(format!(
209                "NimbusRoman-{}",
210                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
211            ));
212            push(format!(
213                "DejaVuSerif{}",
214                suffix_dash("", "-Bold", "-Italic", "-BoldItalic")
215            ));
216            push(format!(
217                "FreeSerif{}",
218                suffix_dash("", "Bold", "Italic", "BoldItalic")
219            ));
220            push(format!(
221                "NotoSerif-{}",
222                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
223            ));
224            push("Times".into());
225            push("DejaVuSerif".into());
226            push("LiberationSerif-Regular".into());
227        }
228        Family::Mono => {
229            push(format!(
230                "LiberationMono-{}",
231                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
232            ));
233            push(format!("cour{}", suffix_dash("", "bd", "i", "bi")));
234            push(format!(
235                "Courier New{}",
236                suffix_dash("", " Bold", " Italic", " Bold Italic")
237            ));
238            push(format!(
239                "NimbusMonoPS-{}",
240                suffix_dash("Regular", "Bold", "Italic", "BoldItalic")
241            ));
242            push(format!(
243                "DejaVuSansMono{}",
244                suffix_dash("", "-Bold", "-Oblique", "-BoldOblique")
245            ));
246            push(format!(
247                "FreeMono{}",
248                suffix_dash("", "Bold", "Oblique", "BoldOblique")
249            ));
250            push("Courier".into());
251            push("DejaVuSansMono".into());
252            push("LiberationMono-Regular".into());
253        }
254        Family::Symbol => {
255            push("StandardSymbolsPS".into());
256            push("Symbol".into());
257            push("symbol".into());
258            push("DejaVuSans".into());
259        }
260        Family::Dingbats => {
261            push("D050000L".into());
262            push("Dingbats".into());
263            push("ZapfDingbats".into());
264            push("DejaVuSans".into());
265        }
266        Family::Cjk => {
267            push("NotoSansCJK-Regular".into());
268            push("NotoSansCJKjp-Regular".into());
269            push("NotoSansCJKsc-Regular".into());
270            push("DroidSansFallbackFull".into());
271            push("DroidSansFallback".into());
272            push("wqy-microhei".into());
273            push("wqy-zenhei".into());
274            push("fonts-japanese-gothic".into());
275            push("ipag".into());
276            push("msgothic".into());
277            push("simsun".into());
278            push("PingFang".into());
279            push("DejaVuSans".into());
280        }
281    }
282    out
283}
284
285/// The face for `style`, loaded once; `None` when the host has nothing.
286pub fn face(style: Style) -> Option<Arc<FallbackFace>> {
287    static CACHE: OnceLock<Mutex<HashMap<Style, Option<Arc<FallbackFace>>>>> = OnceLock::new();
288    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
289    if let Some(v) = cache.lock().ok().and_then(|c| c.get(&style).cloned()) {
290        return v;
291    }
292    let idx = index();
293    let mut found = None;
294    for cand in candidates(style) {
295        if let Some(p) = idx.files.get(&cand) {
296            if let Ok(data) = std::fs::read(p) {
297                if ttf_parser::Face::parse(&data, 0).is_ok() {
298                    found = Some(Arc::new(FallbackFace {
299                        data,
300                        path: p.clone(),
301                    }));
302                    break;
303                }
304            }
305        }
306    }
307    // A styled variant missing → the regular face of the family.
308    if found.is_none() && (style.bold || style.italic) {
309        found = face(Style {
310            family: style.family,
311            bold: false,
312            italic: false,
313        });
314    } else {
315        // Once per style, so a host-dependent render can be traced to the
316        // face that produced it (#633).
317        docling_core::debug_log!(
318            "docling-pdf fallback font: {:?} bold={} italic={} → {}",
319            style.family,
320            style.bold,
321            style.italic,
322            found
323                .as_ref()
324                .map_or_else(|| "none".to_string(), |f| f.path.display().to_string())
325        );
326    }
327    if let Ok(mut c) = cache.lock() {
328        c.insert(style, found.clone());
329    }
330    found
331}
332
333/// Classify a `/BaseFont` name (subset prefix stripped) and descriptor flags
334/// into a fallback style, the way the standard-14 substitution table and
335/// docling-parse's name normalizer do it.
336pub fn style_for(base_font: &str, flags: Option<i64>, serif_hint: Option<bool>) -> Style {
337    let name = base_font.to_ascii_lowercase();
338    let style_part = name.split_once(['-', ',']).map(|(_, s)| s).unwrap_or("");
339    let bold = style_part.contains("bold")
340        || name.contains("bold")
341        || name.contains("black")
342        || name.contains("heavy")
343        || name.contains("semibold")
344        || flags.is_some_and(|f| f & (1 << 18) != 0);
345    let italic = name.contains("italic")
346        || name.contains("oblique")
347        || flags.is_some_and(|f| f & (1 << 6) != 0);
348    let family = if name.contains("symbol") {
349        Family::Symbol
350    } else if name.contains("dingbat") || name.contains("wingding") {
351        Family::Dingbats
352    } else if name.contains("courier")
353        || name.contains("mono")
354        || name.contains("consolas")
355        || name.contains("menlo")
356        || flags.is_some_and(|f| f & 1 != 0 && !name.contains("arial"))
357    {
358        Family::Mono
359    } else if name.contains("times")
360        || name.contains("georgia")
361        || name.contains("garamond")
362        || name.contains("book")
363        || name.contains("palatino")
364        || name.contains("century")
365        || name.contains("cambria")
366        || name.contains("minion")
367        || name.contains("nimbusrom")
368        || name.contains("roman")
369        || name.contains("serif") && !name.contains("sans")
370        || name.starts_with("cm")
371            && (name.starts_with("cmr") || name.starts_with("cmbx") || name.starts_with("cmti"))
372    {
373        Family::Serif
374    } else if name.contains("arial")
375        || name.contains("helvetica")
376        || name.contains("verdana")
377        || name.contains("calibri")
378        || name.contains("sans")
379        || name.contains("tahoma")
380        || name.contains("segoe")
381    {
382        Family::Sans
383    } else if serif_hint == Some(true) || flags.is_some_and(|f| f & 2 != 0) {
384        Family::Serif
385    } else {
386        Family::Sans
387    };
388    Style {
389        family,
390        bold,
391        italic,
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    /// #633: with the host directories opted out, only the deployment's
400    /// directories are searched — `.models/fonts` first, then every entry of
401    /// `DOCLING_RS_FONT_DIRS` in order — so a fleet shipping its fonts renders
402    /// the same page everywhere.
403    #[test]
404    fn system_fonts_off_keeps_only_the_deployment_directories() {
405        let extra = std::env::join_paths(["/srv/fonts", "/opt/fonts"]).unwrap();
406        let dirs = font_dirs_from(PathBuf::from(".models/fonts"), extra.to_str(), None);
407        assert_eq!(
408            dirs,
409            [".models/fonts", "/srv/fonts", "/opt/fonts"].map(PathBuf::from)
410        );
411    }
412
413    /// The default keeps the host: the deployment's directories still come
414    /// first, then the user's, then the system's.
415    #[test]
416    fn host_directories_follow_the_deployment_ones() {
417        let dirs = font_dirs_from(
418            PathBuf::from(".models/fonts"),
419            Some("/srv/fonts"),
420            Some(HostDirs {
421                home: Some(PathBuf::from("/home/u")),
422                windir: Some(PathBuf::from("C:\\W")),
423            }),
424        );
425        assert_eq!(
426            dirs[..3],
427            [".models/fonts", "/srv/fonts", "/home/u/.fonts"].map(PathBuf::from)
428        );
429        assert!(dirs.contains(&PathBuf::from("/usr/share/fonts")));
430        assert_eq!(dirs.last(), Some(&PathBuf::from("C:\\W").join("Fonts")));
431    }
432}