web engine - experimental web browser
at main 482 lines 15 kB view raw
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}