fop_render/pdf/
font_config.rs1use std::collections::HashMap;
7use std::path::PathBuf;
8
9#[derive(Debug, Default)]
13pub struct FontConfig {
14 mappings: HashMap<String, PathBuf>,
16}
17
18impl FontConfig {
19 pub fn new() -> Self {
21 Self {
22 mappings: HashMap::new(),
23 }
24 }
25
26 pub fn add_mapping(&mut self, name: &str, path: PathBuf) {
28 self.mappings.insert(name.to_lowercase(), path);
29 }
30
31 pub fn find_font(&self, family: &str) -> Option<&PathBuf> {
35 self.mappings.get(&family.to_lowercase())
36 }
37
38 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 pub fn iter(&self) -> impl Iterator<Item = (&str, &PathBuf)> {
57 self.mappings.iter().map(|(k, v)| (k.as_str(), v))
58 }
59
60 #[allow(dead_code)]
62 pub fn len(&self) -> usize {
63 self.mappings.len()
64 }
65
66 #[allow(dead_code)]
68 pub fn is_empty(&self) -> bool {
69 self.mappings.is_empty()
70 }
71}
72
73fn 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 dirs
110}
111
112fn home_dir() -> Option<PathBuf> {
115 std::env::var_os("HOME")
117 .or_else(|| std::env::var_os("USERPROFILE"))
118 .map(PathBuf::from)
119}
120
121fn 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 scan_font_dir(&path, config);
134 continue;
135 }
136
137 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
151fn register_font_file(path: &std::path::Path, config: &mut FontConfig) {
154 let data = match std::fs::read(path) {
156 Ok(d) => d,
157 Err(_) => return,
158 };
159
160 let name = extract_font_family_name(&data).or_else(|| {
162 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
173pub 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 let preferred_ids = [
188 name_id::TYPOGRAPHIC_FAMILY, name_id::FAMILY, name_id::POST_SCRIPT_NAME, ];
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 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 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 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}