//! Font discovery and registry. //! //! Scans system font directories, builds an index by family name and style, //! and provides font selection with fallback. use super::{Font, FontError}; use crate::font::parse::Reader; use std::collections::HashMap; use std::path::{Path, PathBuf}; /// Metadata about a single font face discovered on the system. #[derive(Debug, Clone)] pub struct FontEntry { /// File path to the font. pub path: PathBuf, /// Byte offset within the file (0 for standalone .ttf/.otf, nonzero for .ttc fonts). pub offset: u32, /// Font family name (e.g., "Helvetica"). pub family: String, /// Subfamily name (e.g., "Regular", "Bold", "Italic", "Bold Italic"). pub subfamily: String, /// True if this face is bold (from macStyle bit 0 or weight class >= 700). pub bold: bool, /// True if this face is italic (from macStyle bit 1 or subfamily heuristic). pub italic: bool, } /// A registry of system fonts, indexed by family name. pub struct FontRegistry { /// Map from lowercase family name to a list of font entries. families: HashMap>, } impl Default for FontRegistry { fn default() -> Self { Self::new() } } impl FontRegistry { /// Scan system font directories and build the registry. /// /// Scans `/System/Library/Fonts/` and `/Library/Fonts/` for .ttf, .otf, /// and .ttc files. Errors in individual files are silently skipped. pub fn new() -> FontRegistry { let mut families: HashMap> = HashMap::new(); let dirs = [ Path::new("/System/Library/Fonts"), Path::new("/Library/Fonts"), ]; for dir in &dirs { let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(_) => continue, }; for entry in entries { let entry = match entry { Ok(e) => e, Err(_) => continue, }; let path = entry.path(); let ext = path .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_ascii_lowercase(); match ext.as_str() { "ttf" | "otf" => { if let Some(fe) = probe_single_font(&path, 0) { let key = fe.family.to_ascii_lowercase(); families.entry(key).or_default().push(fe); } } "ttc" => { if let Ok(offsets) = parse_ttc_offsets(&path) { for offset in offsets { if let Some(fe) = probe_single_font(&path, offset) { let key = fe.family.to_ascii_lowercase(); families.entry(key).or_default().push(fe); } } } } _ => {} } } } FontRegistry { families } } /// Find a font by family name. Returns the first match (prefers Regular). /// /// The family name match is case-insensitive. pub fn find_font(&self, family: &str) -> Option { let key = family.to_ascii_lowercase(); let entries = self.families.get(&key)?; // Prefer the Regular face. let entry = entries .iter() .find(|e| !e.bold && !e.italic) .or_else(|| entries.first())?; load_font_at_offset(&entry.path, entry.offset).ok() } /// Find a font by family name with bold/italic style preference. /// /// Falls back through: exact match -> any with same bold -> any in family. pub fn find_font_with_style(&self, family: &str, bold: bool, italic: bool) -> Option { let key = family.to_ascii_lowercase(); let entries = self.families.get(&key)?; // Exact style match. let entry = entries .iter() .find(|e| e.bold == bold && e.italic == italic) // Fallback: match bold, ignore italic. .or_else(|| entries.iter().find(|e| e.bold == bold)) // Fallback: any face in the family. .or_else(|| entries.first())?; load_font_at_offset(&entry.path, entry.offset).ok() } /// List all discovered font family names (sorted alphabetically). pub fn list_families(&self) -> Vec { // Return the original-case family name from the first entry of each family. let mut names: Vec = self .families .values() .filter_map(|entries| entries.first().map(|e| e.family.clone())) .collect(); names.sort(); names } /// Get all font entries for a given family name (case-insensitive). pub fn family_entries(&self, family: &str) -> Option<&[FontEntry]> { let key = family.to_ascii_lowercase(); self.families.get(&key).map(|v| v.as_slice()) } /// Find any available font, preferring common system defaults. /// /// Tries: Helvetica, Arial, Geneva, then any available font. pub fn find_fallback(&self) -> Option { for preferred in &["Helvetica", "Arial", "Geneva", "Lucida Grande"] { if let Some(font) = self.find_font(preferred) { return Some(font); } } // Last resort: pick the first font in the registry. for entries in self.families.values() { if let Some(entry) = entries.first() { if let Ok(font) = load_font_at_offset(&entry.path, entry.offset) { return Some(font); } } } None } /// Number of distinct font families in the registry. pub fn family_count(&self) -> usize { self.families.len() } } /// Parse a TTC (TrueType Collection) file header to get individual font offsets. fn parse_ttc_offsets(path: &Path) -> Result, FontError> { let data = std::fs::read(path).map_err(|_| FontError::UnexpectedEof)?; if data.len() < 12 { return Err(FontError::UnexpectedEof); } let r = Reader::new(&data); let tag = r.tag(0)?; // TTC header: tag must be "ttcf". if &tag != b"ttcf" { return Err(FontError::InvalidMagic(u32::from_be_bytes(tag))); } // version(4) + numFonts(4) let num_fonts = r.u32(8)? as usize; let mut offsets = Vec::with_capacity(num_fonts); for i in 0..num_fonts { let offset = r.u32(12 + i * 4)?; offsets.push(offset); } Ok(offsets) } /// Probe a single font at the given byte offset within a file. /// /// Reads just enough to extract family name, subfamily, and style flags. /// Returns `None` if the font can't be parsed. fn probe_single_font(path: &Path, offset: u32) -> Option { let data = std::fs::read(path).ok()?; let font = parse_font_at_offset(data, offset).ok()?; let name = font.name().ok()?; let family = name.family_name()?.to_owned(); let subfamily = name.subfamily_name().unwrap_or("Regular").to_owned(); // Determine bold/italic from head.macStyle and name heuristics. let (bold, italic) = detect_style(&font, &subfamily); Some(FontEntry { path: path.to_owned(), offset, family, subfamily, bold, italic, }) } /// Detect bold/italic from head.macStyle flags, OS/2 weight class, and subfamily name. fn detect_style(font: &Font, subfamily: &str) -> (bool, bool) { let sub_lower = subfamily.to_ascii_lowercase(); // Start with head.macStyle bits. let (mut bold, mut italic) = if let Ok(head) = font.head() { (head.mac_style & 1 != 0, head.mac_style & 2 != 0) } else { (false, false) }; // Also consider OS/2 weight class. if let Ok(os2) = font.os2() { if os2.us_weight_class >= 700 { bold = true; } } // Subfamily name heuristics as fallback. if sub_lower.contains("bold") { bold = true; } if sub_lower.contains("italic") || sub_lower.contains("oblique") { italic = true; } (bold, italic) } /// Parse a font from raw data at a given byte offset. /// /// For standalone fonts, offset is 0. For TTC fonts, offset points to /// the individual font's offset table within the collection. fn parse_font_at_offset(data: Vec, offset: u32) -> Result { if offset == 0 { return Font::parse(data); } // For TTC: we need to parse the table directory starting at `offset`. let off = offset as usize; let r = Reader::new(&data); let sf_version = r.u32(off)?; match sf_version { 0x00010000 | 0x4F54544F | 0x74727565 => {} _ => return Err(FontError::InvalidMagic(sf_version)), } let num_tables = r.u16(off + 4)? as usize; let mut tables = Vec::with_capacity(num_tables); for i in 0..num_tables { let base = off + 12 + i * 16; let tag = r.tag(base)?; let checksum = r.u32(base + 4)?; let table_offset = r.u32(base + 8)?; let length = r.u32(base + 12)?; tables.push(super::TableRecord { tag, checksum, offset: table_offset, length, }); } Ok(Font { data, sf_version, tables, glyph_cache: std::cell::RefCell::new(super::cache::GlyphCache::new()), }) } /// Load a font from a file at the given byte offset. pub fn load_font_at_offset(path: &Path, offset: u32) -> Result { let data = std::fs::read(path).map_err(|_| FontError::UnexpectedEof)?; parse_font_at_offset(data, offset) } #[cfg(test)] mod tests { use super::*; fn has_system_fonts() -> bool { Path::new("/System/Library/Fonts").exists() } #[test] fn registry_discovers_fonts() { if !has_system_fonts() { return; } let reg = FontRegistry::new(); assert!( reg.family_count() > 0, "should discover at least one font family" ); } #[test] fn registry_list_families() { if !has_system_fonts() { return; } let reg = FontRegistry::new(); let families = reg.list_families(); assert!(!families.is_empty(), "should list font families"); // Families should be sorted. for i in 1..families.len() { assert!( families[i] >= families[i - 1], "families should be sorted: '{}' < '{}'", families[i], families[i - 1] ); } } #[test] fn registry_find_font_case_insensitive() { if !has_system_fonts() { return; } let reg = FontRegistry::new(); let families = reg.list_families(); // Find the first family and try a case-insensitive lookup. if let Some(family) = families.first() { let upper = family.to_ascii_uppercase(); let font = reg.find_font(&upper); assert!( font.is_some(), "should find '{}' via uppercase '{}'", family, upper ); } } #[test] fn registry_find_fallback() { if !has_system_fonts() { return; } let reg = FontRegistry::new(); let font = reg.find_fallback(); assert!(font.is_some(), "should find at least one fallback font"); } #[test] fn registry_ttc_parsing() { // Check that TTC files in /System/Library/Fonts/ are parsed. if !has_system_fonts() { return; } let reg = FontRegistry::new(); // Courier.ttc exists on all macOS versions. let courier_path = Path::new("/System/Library/Fonts/Courier.ttc"); if courier_path.exists() { let font = reg.find_font("Courier"); assert!(font.is_some(), "should find Courier from .ttc file"); } } #[test] fn registry_find_with_style() { if !has_system_fonts() { return; } let reg = FontRegistry::new(); // Try to find a font family that has multiple styles. let families = reg.list_families(); for family in &families { if let Some(entries) = reg.family_entries(family) { if entries.len() > 1 { // This family has multiple faces — test style selection. let _regular = reg.find_font_with_style(family, false, false); let _bold = reg.find_font_with_style(family, true, false); // Just verify no crash. return; } } } } #[test] fn parse_ttc_offsets_courier() { let path = Path::new("/System/Library/Fonts/Courier.ttc"); if !path.exists() { return; } let offsets = parse_ttc_offsets(path).expect("should parse TTC offsets"); assert!( !offsets.is_empty(), "Courier.ttc should contain at least one font" ); // First offset should be a valid position (after the TTC header). assert!( offsets[0] >= 12, "first font offset should be past TTC header" ); } #[test] fn font_entry_has_valid_metadata() { if !has_system_fonts() { return; } let reg = FontRegistry::new(); let families = reg.list_families(); if let Some(family) = families.first() { let entries = reg.family_entries(family).unwrap(); for entry in entries { assert!(!entry.family.is_empty(), "family name should not be empty"); assert!( !entry.subfamily.is_empty(), "subfamily name should not be empty" ); assert!(entry.path.exists(), "font file should exist"); } } } #[test] fn load_font_at_offset_ttc() { let path = Path::new("/System/Library/Fonts/Courier.ttc"); if !path.exists() { return; } let offsets = parse_ttc_offsets(path).expect("should parse TTC"); if let Some(&offset) = offsets.first() { let font = load_font_at_offset(path, offset).expect("should load font from TTC"); // Verify we can parse basic tables. let name = font.name().expect("should parse name table"); assert!(name.family_name().is_some(), "should have a family name"); } } #[test] fn registry_nonexistent_family_returns_none() { if !has_system_fonts() { return; } let reg = FontRegistry::new(); assert!( reg.find_font("NonexistentFontFamily12345").is_none(), "should return None for nonexistent family" ); } }