From 8a500cf4d77f671d5b9b0f16f6b47ba55a240bee Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Thu, 26 Feb 2026 17:47:41 +0100 Subject: [PATCH] Implement font discovery: enumerate and load system fonts - FontRegistry scans /System/Library/Fonts/ and /Library/Fonts/ on init - TTC collection parsing (ttcf header, per-font offsets) - Font selection by family name (case-insensitive) - Style-aware selection (bold/italic via macStyle, OS/2 weight, subfamily name) - Fallback mechanism: Helvetica -> Arial -> Geneva -> any available - FontEntry metadata: path, offset, family, subfamily, bold, italic - 10 new tests for registry discovery, TTC parsing, style selection, fallback Co-Authored-By: Claude Opus 4.6 --- crates/text/src/font/mod.rs | 2 + crates/text/src/font/registry.rs | 481 +++++++++++++++++++++++++++++++ 2 files changed, 483 insertions(+) create mode 100644 crates/text/src/font/registry.rs diff --git a/crates/text/src/font/mod.rs b/crates/text/src/font/mod.rs index 20bd5e6..8bc08c6 100644 --- a/crates/text/src/font/mod.rs +++ b/crates/text/src/font/mod.rs @@ -7,9 +7,11 @@ use std::fmt; mod parse; pub mod rasterizer; +pub mod registry; mod tables; pub use rasterizer::GlyphBitmap; +pub use registry::{FontEntry, FontRegistry}; pub use tables::cmap::CmapTable; pub use tables::glyf::{Contour, GlyphOutline, Point}; pub use tables::head::HeadTable; diff --git a/crates/text/src/font/registry.rs b/crates/text/src/font/registry.rs new file mode 100644 index 0000000..03e97c9 --- /dev/null +++ b/crates/text/src/font/registry.rs @@ -0,0 +1,481 @@ +//! 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, + }) +} + +/// 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" + ); + } +} -- 2.51.2