web engine - experimental web browser
1//! Font discovery and registry.
2//!
3//! Scans system font directories, builds an index by family name and style,
4//! and provides font selection with fallback.
5
6use super::{Font, FontError};
7use crate::font::parse::Reader;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11/// Metadata about a single font face discovered on the system.
12#[derive(Debug, Clone)]
13pub struct FontEntry {
14 /// File path to the font.
15 pub path: PathBuf,
16 /// Byte offset within the file (0 for standalone .ttf/.otf, nonzero for .ttc fonts).
17 pub offset: u32,
18 /// Font family name (e.g., "Helvetica").
19 pub family: String,
20 /// Subfamily name (e.g., "Regular", "Bold", "Italic", "Bold Italic").
21 pub subfamily: String,
22 /// True if this face is bold (from macStyle bit 0 or weight class >= 700).
23 pub bold: bool,
24 /// True if this face is italic (from macStyle bit 1 or subfamily heuristic).
25 pub italic: bool,
26}
27
28/// A registry of system fonts, indexed by family name.
29pub struct FontRegistry {
30 /// Map from lowercase family name to a list of font entries.
31 families: HashMap<String, Vec<FontEntry>>,
32}
33
34impl Default for FontRegistry {
35 fn default() -> Self {
36 Self::new()
37 }
38}
39
40impl FontRegistry {
41 /// Scan system font directories and build the registry.
42 ///
43 /// Scans `/System/Library/Fonts/` and `/Library/Fonts/` for .ttf, .otf,
44 /// and .ttc files. Errors in individual files are silently skipped.
45 pub fn new() -> FontRegistry {
46 let mut families: HashMap<String, Vec<FontEntry>> = HashMap::new();
47
48 let dirs = [
49 Path::new("/System/Library/Fonts"),
50 Path::new("/Library/Fonts"),
51 ];
52
53 for dir in &dirs {
54 let entries = match std::fs::read_dir(dir) {
55 Ok(e) => e,
56 Err(_) => continue,
57 };
58
59 for entry in entries {
60 let entry = match entry {
61 Ok(e) => e,
62 Err(_) => continue,
63 };
64
65 let path = entry.path();
66 let ext = path
67 .extension()
68 .and_then(|e| e.to_str())
69 .unwrap_or("")
70 .to_ascii_lowercase();
71
72 match ext.as_str() {
73 "ttf" | "otf" => {
74 if let Some(fe) = probe_single_font(&path, 0) {
75 let key = fe.family.to_ascii_lowercase();
76 families.entry(key).or_default().push(fe);
77 }
78 }
79 "ttc" => {
80 if let Ok(offsets) = parse_ttc_offsets(&path) {
81 for offset in offsets {
82 if let Some(fe) = probe_single_font(&path, offset) {
83 let key = fe.family.to_ascii_lowercase();
84 families.entry(key).or_default().push(fe);
85 }
86 }
87 }
88 }
89 _ => {}
90 }
91 }
92 }
93
94 FontRegistry { families }
95 }
96
97 /// Find a font by family name. Returns the first match (prefers Regular).
98 ///
99 /// The family name match is case-insensitive.
100 pub fn find_font(&self, family: &str) -> Option<Font> {
101 let key = family.to_ascii_lowercase();
102 let entries = self.families.get(&key)?;
103
104 // Prefer the Regular face.
105 let entry = entries
106 .iter()
107 .find(|e| !e.bold && !e.italic)
108 .or_else(|| entries.first())?;
109
110 load_font_at_offset(&entry.path, entry.offset).ok()
111 }
112
113 /// Find a font by family name with bold/italic style preference.
114 ///
115 /// Falls back through: exact match -> any with same bold -> any in family.
116 pub fn find_font_with_style(&self, family: &str, bold: bool, italic: bool) -> Option<Font> {
117 let key = family.to_ascii_lowercase();
118 let entries = self.families.get(&key)?;
119
120 // Exact style match.
121 let entry = entries
122 .iter()
123 .find(|e| e.bold == bold && e.italic == italic)
124 // Fallback: match bold, ignore italic.
125 .or_else(|| entries.iter().find(|e| e.bold == bold))
126 // Fallback: any face in the family.
127 .or_else(|| entries.first())?;
128
129 load_font_at_offset(&entry.path, entry.offset).ok()
130 }
131
132 /// List all discovered font family names (sorted alphabetically).
133 pub fn list_families(&self) -> Vec<String> {
134 // Return the original-case family name from the first entry of each family.
135 let mut names: Vec<String> = self
136 .families
137 .values()
138 .filter_map(|entries| entries.first().map(|e| e.family.clone()))
139 .collect();
140 names.sort();
141 names
142 }
143
144 /// Get all font entries for a given family name (case-insensitive).
145 pub fn family_entries(&self, family: &str) -> Option<&[FontEntry]> {
146 let key = family.to_ascii_lowercase();
147 self.families.get(&key).map(|v| v.as_slice())
148 }
149
150 /// Find any available font, preferring common system defaults.
151 ///
152 /// Tries: Helvetica, Arial, Geneva, then any available font.
153 pub fn find_fallback(&self) -> Option<Font> {
154 for preferred in &["Helvetica", "Arial", "Geneva", "Lucida Grande"] {
155 if let Some(font) = self.find_font(preferred) {
156 return Some(font);
157 }
158 }
159
160 // Last resort: pick the first font in the registry.
161 for entries in self.families.values() {
162 if let Some(entry) = entries.first() {
163 if let Ok(font) = load_font_at_offset(&entry.path, entry.offset) {
164 return Some(font);
165 }
166 }
167 }
168
169 None
170 }
171
172 /// Number of distinct font families in the registry.
173 pub fn family_count(&self) -> usize {
174 self.families.len()
175 }
176}
177
178/// Parse a TTC (TrueType Collection) file header to get individual font offsets.
179fn parse_ttc_offsets(path: &Path) -> Result<Vec<u32>, FontError> {
180 let data = std::fs::read(path).map_err(|_| FontError::UnexpectedEof)?;
181 if data.len() < 12 {
182 return Err(FontError::UnexpectedEof);
183 }
184
185 let r = Reader::new(&data);
186 let tag = r.tag(0)?;
187
188 // TTC header: tag must be "ttcf".
189 if &tag != b"ttcf" {
190 return Err(FontError::InvalidMagic(u32::from_be_bytes(tag)));
191 }
192
193 // version(4) + numFonts(4)
194 let num_fonts = r.u32(8)? as usize;
195 let mut offsets = Vec::with_capacity(num_fonts);
196
197 for i in 0..num_fonts {
198 let offset = r.u32(12 + i * 4)?;
199 offsets.push(offset);
200 }
201
202 Ok(offsets)
203}
204
205/// Probe a single font at the given byte offset within a file.
206///
207/// Reads just enough to extract family name, subfamily, and style flags.
208/// Returns `None` if the font can't be parsed.
209fn probe_single_font(path: &Path, offset: u32) -> Option<FontEntry> {
210 let data = std::fs::read(path).ok()?;
211 let font = parse_font_at_offset(data, offset).ok()?;
212
213 let name = font.name().ok()?;
214 let family = name.family_name()?.to_owned();
215 let subfamily = name.subfamily_name().unwrap_or("Regular").to_owned();
216
217 // Determine bold/italic from head.macStyle and name heuristics.
218 let (bold, italic) = detect_style(&font, &subfamily);
219
220 Some(FontEntry {
221 path: path.to_owned(),
222 offset,
223 family,
224 subfamily,
225 bold,
226 italic,
227 })
228}
229
230/// Detect bold/italic from head.macStyle flags, OS/2 weight class, and subfamily name.
231fn detect_style(font: &Font, subfamily: &str) -> (bool, bool) {
232 let sub_lower = subfamily.to_ascii_lowercase();
233
234 // Start with head.macStyle bits.
235 let (mut bold, mut italic) = if let Ok(head) = font.head() {
236 (head.mac_style & 1 != 0, head.mac_style & 2 != 0)
237 } else {
238 (false, false)
239 };
240
241 // Also consider OS/2 weight class.
242 if let Ok(os2) = font.os2() {
243 if os2.us_weight_class >= 700 {
244 bold = true;
245 }
246 }
247
248 // Subfamily name heuristics as fallback.
249 if sub_lower.contains("bold") {
250 bold = true;
251 }
252 if sub_lower.contains("italic") || sub_lower.contains("oblique") {
253 italic = true;
254 }
255
256 (bold, italic)
257}
258
259/// Parse a font from raw data at a given byte offset.
260///
261/// For standalone fonts, offset is 0. For TTC fonts, offset points to
262/// the individual font's offset table within the collection.
263fn parse_font_at_offset(data: Vec<u8>, offset: u32) -> Result<Font, FontError> {
264 if offset == 0 {
265 return Font::parse(data);
266 }
267
268 // For TTC: we need to parse the table directory starting at `offset`.
269 let off = offset as usize;
270 let r = Reader::new(&data);
271
272 let sf_version = r.u32(off)?;
273 match sf_version {
274 0x00010000 | 0x4F54544F | 0x74727565 => {}
275 _ => return Err(FontError::InvalidMagic(sf_version)),
276 }
277
278 let num_tables = r.u16(off + 4)? as usize;
279 let mut tables = Vec::with_capacity(num_tables);
280
281 for i in 0..num_tables {
282 let base = off + 12 + i * 16;
283 let tag = r.tag(base)?;
284 let checksum = r.u32(base + 4)?;
285 let table_offset = r.u32(base + 8)?;
286 let length = r.u32(base + 12)?;
287 tables.push(super::TableRecord {
288 tag,
289 checksum,
290 offset: table_offset,
291 length,
292 });
293 }
294
295 Ok(Font {
296 data,
297 sf_version,
298 tables,
299 glyph_cache: std::cell::RefCell::new(super::cache::GlyphCache::new()),
300 })
301}
302
303/// Load a font from a file at the given byte offset.
304pub fn load_font_at_offset(path: &Path, offset: u32) -> Result<Font, FontError> {
305 let data = std::fs::read(path).map_err(|_| FontError::UnexpectedEof)?;
306 parse_font_at_offset(data, offset)
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 fn has_system_fonts() -> bool {
314 Path::new("/System/Library/Fonts").exists()
315 }
316
317 #[test]
318 fn registry_discovers_fonts() {
319 if !has_system_fonts() {
320 return;
321 }
322 let reg = FontRegistry::new();
323 assert!(
324 reg.family_count() > 0,
325 "should discover at least one font family"
326 );
327 }
328
329 #[test]
330 fn registry_list_families() {
331 if !has_system_fonts() {
332 return;
333 }
334 let reg = FontRegistry::new();
335 let families = reg.list_families();
336 assert!(!families.is_empty(), "should list font families");
337
338 // Families should be sorted.
339 for i in 1..families.len() {
340 assert!(
341 families[i] >= families[i - 1],
342 "families should be sorted: '{}' < '{}'",
343 families[i],
344 families[i - 1]
345 );
346 }
347 }
348
349 #[test]
350 fn registry_find_font_case_insensitive() {
351 if !has_system_fonts() {
352 return;
353 }
354 let reg = FontRegistry::new();
355 let families = reg.list_families();
356
357 // Find the first family and try a case-insensitive lookup.
358 if let Some(family) = families.first() {
359 let upper = family.to_ascii_uppercase();
360 let font = reg.find_font(&upper);
361 assert!(
362 font.is_some(),
363 "should find '{}' via uppercase '{}'",
364 family,
365 upper
366 );
367 }
368 }
369
370 #[test]
371 fn registry_find_fallback() {
372 if !has_system_fonts() {
373 return;
374 }
375 let reg = FontRegistry::new();
376 let font = reg.find_fallback();
377 assert!(font.is_some(), "should find at least one fallback font");
378 }
379
380 #[test]
381 fn registry_ttc_parsing() {
382 // Check that TTC files in /System/Library/Fonts/ are parsed.
383 if !has_system_fonts() {
384 return;
385 }
386 let reg = FontRegistry::new();
387
388 // Courier.ttc exists on all macOS versions.
389 let courier_path = Path::new("/System/Library/Fonts/Courier.ttc");
390 if courier_path.exists() {
391 let font = reg.find_font("Courier");
392 assert!(font.is_some(), "should find Courier from .ttc file");
393 }
394 }
395
396 #[test]
397 fn registry_find_with_style() {
398 if !has_system_fonts() {
399 return;
400 }
401 let reg = FontRegistry::new();
402
403 // Try to find a font family that has multiple styles.
404 let families = reg.list_families();
405 for family in &families {
406 if let Some(entries) = reg.family_entries(family) {
407 if entries.len() > 1 {
408 // This family has multiple faces — test style selection.
409 let _regular = reg.find_font_with_style(family, false, false);
410 let _bold = reg.find_font_with_style(family, true, false);
411 // Just verify no crash.
412 return;
413 }
414 }
415 }
416 }
417
418 #[test]
419 fn parse_ttc_offsets_courier() {
420 let path = Path::new("/System/Library/Fonts/Courier.ttc");
421 if !path.exists() {
422 return;
423 }
424 let offsets = parse_ttc_offsets(path).expect("should parse TTC offsets");
425 assert!(
426 !offsets.is_empty(),
427 "Courier.ttc should contain at least one font"
428 );
429 // First offset should be a valid position (after the TTC header).
430 assert!(
431 offsets[0] >= 12,
432 "first font offset should be past TTC header"
433 );
434 }
435
436 #[test]
437 fn font_entry_has_valid_metadata() {
438 if !has_system_fonts() {
439 return;
440 }
441 let reg = FontRegistry::new();
442 let families = reg.list_families();
443 if let Some(family) = families.first() {
444 let entries = reg.family_entries(family).unwrap();
445 for entry in entries {
446 assert!(!entry.family.is_empty(), "family name should not be empty");
447 assert!(
448 !entry.subfamily.is_empty(),
449 "subfamily name should not be empty"
450 );
451 assert!(entry.path.exists(), "font file should exist");
452 }
453 }
454 }
455
456 #[test]
457 fn load_font_at_offset_ttc() {
458 let path = Path::new("/System/Library/Fonts/Courier.ttc");
459 if !path.exists() {
460 return;
461 }
462 let offsets = parse_ttc_offsets(path).expect("should parse TTC");
463 if let Some(&offset) = offsets.first() {
464 let font = load_font_at_offset(path, offset).expect("should load font from TTC");
465 // Verify we can parse basic tables.
466 let name = font.name().expect("should parse name table");
467 assert!(name.family_name().is_some(), "should have a family name");
468 }
469 }
470
471 #[test]
472 fn registry_nonexistent_family_returns_none() {
473 if !has_system_fonts() {
474 return;
475 }
476 let reg = FontRegistry::new();
477 assert!(
478 reg.find_font("NonexistentFontFamily12345").is_none(),
479 "should return None for nonexistent family"
480 );
481 }
482}