1use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23use std::sync::{Arc, Mutex, OnceLock};
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27pub enum Family {
28 Sans,
29 Serif,
30 Mono,
31 Symbol,
32 Dingbats,
33 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
44pub struct FallbackFace {
46 pub data: Vec<u8>,
47 pub path: PathBuf,
48}
49
50struct Index {
51 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 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
81struct HostDirs {
83 home: Option<PathBuf>,
84 windir: Option<PathBuf>,
85}
86
87fn 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
148fn 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
285pub 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 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 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
333pub 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 #[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 #[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}