Skip to main content

fop_render/pdf/
font_config.rs

1//! Font configuration and system font discovery
2//!
3//! Provides a mapping from font family names to TTF/OTF file paths,
4//! and utilities to discover fonts installed on the system.
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9/// Maps font family names to font file paths.
10///
11/// Font names are stored in lowercase for case-insensitive lookup.
12#[derive(Debug, Default)]
13pub struct FontConfig {
14    /// Map from font family name (lowercase) to file path
15    mappings: HashMap<String, PathBuf>,
16}
17
18impl FontConfig {
19    /// Create an empty `FontConfig` with no font mappings.
20    pub fn new() -> Self {
21        Self {
22            mappings: HashMap::new(),
23        }
24    }
25
26    /// Add a font mapping: `name` (case-insensitive) → `path`.
27    pub fn add_mapping(&mut self, name: &str, path: PathBuf) {
28        self.mappings.insert(name.to_lowercase(), path);
29    }
30
31    /// Look up a font file path by family name (case-insensitive).
32    ///
33    /// Returns `None` if no mapping exists for `family`.
34    pub fn find_font(&self, family: &str) -> Option<&PathBuf> {
35        self.mappings.get(&family.to_lowercase())
36    }
37
38    /// Build a `FontConfig` by scanning standard system font directories
39    /// and registering every TTF/OTF file found there.
40    ///
41    /// Font names are extracted from the font file metadata using `ttf-parser`.
42    /// If a file cannot be parsed its name is derived from the file stem instead.
43    pub fn with_system_fonts() -> Self {
44        let mut config = Self::new();
45
46        for dir in system_font_dirs() {
47            if dir.is_dir() {
48                scan_font_dir(&dir, &mut config);
49            }
50        }
51
52        config
53    }
54
55    /// Return an iterator over all registered (name, path) pairs.
56    pub fn iter(&self) -> impl Iterator<Item = (&str, &PathBuf)> {
57        self.mappings.iter().map(|(k, v)| (k.as_str(), v))
58    }
59
60    /// Return the number of registered font mappings.
61    #[allow(dead_code)]
62    pub fn len(&self) -> usize {
63        self.mappings.len()
64    }
65
66    /// Return `true` if no font mappings are registered.
67    #[allow(dead_code)]
68    pub fn is_empty(&self) -> bool {
69        self.mappings.is_empty()
70    }
71}
72
73/// Return the platform-specific directories to search for installed fonts.
74fn system_font_dirs() -> Vec<PathBuf> {
75    let mut dirs = Vec::new();
76
77    #[cfg(target_os = "linux")]
78    {
79        dirs.push(PathBuf::from("/usr/share/fonts"));
80        dirs.push(PathBuf::from("/usr/local/share/fonts"));
81        if let Some(home) = home_dir() {
82            dirs.push(home.join(".fonts"));
83            dirs.push(home.join(".local/share/fonts"));
84        }
85    }
86
87    #[cfg(target_os = "macos")]
88    {
89        dirs.push(PathBuf::from("/Library/Fonts"));
90        dirs.push(PathBuf::from("/System/Library/Fonts"));
91        if let Some(home) = home_dir() {
92            dirs.push(home.join("Library/Fonts"));
93        }
94    }
95
96    #[cfg(target_os = "windows")]
97    {
98        dirs.push(PathBuf::from(r"C:\Windows\Fonts"));
99        if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
100            let mut p = PathBuf::from(local_app_data);
101            p.push("Microsoft");
102            p.push("Windows");
103            p.push("Fonts");
104            dirs.push(p);
105        }
106    }
107
108    // Fallback: on unknown platforms return an empty list.
109    dirs
110}
111
112/// Obtain the current user's home directory from the `HOME` / `USERPROFILE`
113/// environment variable.
114fn home_dir() -> Option<PathBuf> {
115    // Try HOME first (Unix), then USERPROFILE (Windows)
116    std::env::var_os("HOME")
117        .or_else(|| std::env::var_os("USERPROFILE"))
118        .map(PathBuf::from)
119}
120
121/// Recursively scan `dir` for TTF/OTF files and register them in `config`.
122fn scan_font_dir(dir: &std::path::Path, config: &mut FontConfig) {
123    let entries = match std::fs::read_dir(dir) {
124        Ok(e) => e,
125        Err(_) => return,
126    };
127
128    for entry in entries.flatten() {
129        let path = entry.path();
130
131        if path.is_dir() {
132            // Recurse into sub-directories (e.g. /usr/share/fonts/truetype/…)
133            scan_font_dir(&path, config);
134            continue;
135        }
136
137        // Only handle TrueType / OpenType files
138        let ext = path
139            .extension()
140            .and_then(|e| e.to_str())
141            .map(|e| e.to_lowercase());
142
143        if !matches!(ext.as_deref(), Some("ttf") | Some("otf")) {
144            continue;
145        }
146
147        register_font_file(&path, config);
148    }
149}
150
151/// Try to read the PostScript / family name from `path` using `ttf-parser`
152/// and register it in `config`. Falls back to the file stem on parse errors.
153fn register_font_file(path: &std::path::Path, config: &mut FontConfig) {
154    // Read the font data
155    let data = match std::fs::read(path) {
156        Ok(d) => d,
157        Err(_) => return,
158    };
159
160    // Try to parse the font and extract the preferred family name
161    let name = extract_font_family_name(&data).or_else(|| {
162        // Fall back to file stem (e.g. "NotoSansCJK-Regular")
163        path.file_stem()
164            .and_then(|s| s.to_str())
165            .map(|s| s.to_string())
166    });
167
168    if let Some(family) = name {
169        config.add_mapping(&family, path.to_path_buf());
170    }
171}
172
173/// Extract the preferred font family name from TTF/OTF data.
174///
175/// Tries, in order:
176/// 1. Name ID 16 – Typographic / Preferred Family
177/// 2. Name ID 1  – Font Family
178/// 3. Name ID 6  – PostScript name
179///
180/// Returns `None` if none of these can be found or decoded.
181pub fn extract_font_family_name(font_data: &[u8]) -> Option<String> {
182    use ttf_parser::name_id;
183
184    let face = ttf_parser::Face::parse(font_data, 0).ok()?;
185
186    // Priority: Preferred Family → Family → PostScript name
187    let preferred_ids = [
188        name_id::TYPOGRAPHIC_FAMILY, // 16
189        name_id::FAMILY,             // 1
190        name_id::POST_SCRIPT_NAME,   // 6
191    ];
192
193    for &id in &preferred_ids {
194        if let Some(name) = face
195            .names()
196            .into_iter()
197            .find(|n| n.name_id == id)
198            .and_then(|n| n.to_string())
199        {
200            return Some(name);
201        }
202    }
203
204    None
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_font_config_new_is_empty() {
213        let cfg = FontConfig::new();
214        assert!(cfg.is_empty());
215        assert_eq!(cfg.len(), 0);
216    }
217
218    #[test]
219    fn test_add_and_find_mapping() {
220        let mut cfg = FontConfig::new();
221        cfg.add_mapping("Noto Sans", PathBuf::from("/usr/share/fonts/NotoSans.ttf"));
222        assert_eq!(cfg.len(), 1);
223
224        // Case-insensitive lookup
225        assert!(cfg.find_font("noto sans").is_some());
226        assert!(cfg.find_font("Noto Sans").is_some());
227        assert!(cfg.find_font("NOTO SANS").is_some());
228    }
229
230    #[test]
231    fn test_find_missing_font_returns_none() {
232        let cfg = FontConfig::new();
233        assert!(cfg.find_font("NonExistentFont").is_none());
234    }
235
236    #[test]
237    fn test_add_mapping_overwrites_existing() {
238        let mut cfg = FontConfig::new();
239        cfg.add_mapping("Arial", PathBuf::from("/path/a.ttf"));
240        cfg.add_mapping("Arial", PathBuf::from("/path/b.ttf"));
241        // The second mapping should overwrite the first
242        assert_eq!(cfg.len(), 1);
243        assert_eq!(cfg.find_font("arial"), Some(&PathBuf::from("/path/b.ttf")));
244    }
245
246    #[test]
247    fn test_with_system_fonts_does_not_panic() {
248        // Just verify it can be called without panicking even if no fonts are present
249        let _cfg = FontConfig::with_system_fonts();
250    }
251
252    #[test]
253    fn test_iter() {
254        let mut cfg = FontConfig::new();
255        cfg.add_mapping("FontA", PathBuf::from("/a.ttf"));
256        cfg.add_mapping("FontB", PathBuf::from("/b.ttf"));
257
258        let names: Vec<&str> = cfg.iter().map(|(n, _)| n).collect();
259        assert_eq!(names.len(), 2);
260        assert!(names.contains(&"fonta"));
261        assert!(names.contains(&"fontb"));
262    }
263}
264
265#[cfg(test)]
266mod tests_extended {
267    use super::*;
268
269    #[test]
270    fn test_font_config_multiple_fonts() {
271        let mut cfg = FontConfig::new();
272        cfg.add_mapping("Font A", PathBuf::from("/a.ttf"));
273        cfg.add_mapping("Font B", PathBuf::from("/b.ttf"));
274        cfg.add_mapping("Font C", PathBuf::from("/c.ttf"));
275        assert_eq!(cfg.len(), 3);
276        assert!(!cfg.is_empty());
277    }
278
279    #[test]
280    fn test_font_config_lookup_is_case_insensitive() {
281        let mut cfg = FontConfig::new();
282        cfg.add_mapping("Arial Bold", PathBuf::from("/arial-bold.ttf"));
283        assert!(cfg.find_font("arial bold").is_some());
284        assert!(cfg.find_font("ARIAL BOLD").is_some());
285        assert!(cfg.find_font("Arial Bold").is_some());
286        assert!(cfg.find_font("ArIaL bOlD").is_some());
287    }
288
289    #[test]
290    fn test_font_config_path_is_preserved() {
291        let mut cfg = FontConfig::new();
292        let path = PathBuf::from("/usr/share/fonts/truetype/NotoSans.ttf");
293        cfg.add_mapping("Noto Sans", path.clone());
294        assert_eq!(cfg.find_font("noto sans"), Some(&path));
295    }
296
297    #[test]
298    fn test_font_config_iter_count() {
299        let mut cfg = FontConfig::new();
300        cfg.add_mapping("F1", PathBuf::from("/f1.ttf"));
301        cfg.add_mapping("F2", PathBuf::from("/f2.ttf"));
302        let count = cfg.iter().count();
303        assert_eq!(count, 2);
304    }
305
306    #[test]
307    fn test_extract_font_family_name_invalid_data() {
308        let bad_data = b"not a font";
309        let result = extract_font_family_name(bad_data);
310        assert!(result.is_none());
311    }
312
313    #[test]
314    fn test_extract_font_family_name_empty_data() {
315        let result = extract_font_family_name(b"");
316        assert!(result.is_none());
317    }
318
319    #[test]
320    fn test_font_config_default_is_empty() {
321        let cfg = FontConfig::default();
322        assert!(cfg.is_empty());
323    }
324}