1use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, Mutex, OnceLock};
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
16pub enum Family {
17 Sans,
18 Serif,
19 Mono,
20 Symbol,
21 Dingbats,
22 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
33pub struct FallbackFace {
35 pub data: Vec<u8>,
36 pub path: PathBuf,
37}
38
39struct Index {
40 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
114fn 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
251pub 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 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
287pub 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}