zenith_core/font/local.rs
1//! Local/system font scanning.
2//!
3//! Reads font files from a caller-supplied list of directories and extracts the
4//! family / weight / style of every face, so the CLI can register machine-local
5//! fonts as a LAST-RESORT resolution source (after bundled + project fonts).
6//!
7//! ## Determinism boundary
8//!
9//! This module performs filesystem reads of font FILES (the same kind of read
10//! the bundled `include_bytes!` faces already are at compile time), but it does
11//! NOT enumerate OS font locations itself: the directory list is passed in by
12//! the caller. OS-directory discovery lives in the CLI (`os_font_dirs`) so the
13//! core never reaches into machine-specific paths on its own.
14//!
15//! Output is fully deterministic for a given set of directory contents: files
16//! are collected and sorted by path before parsing, and the returned entries are
17//! sorted by `(family, weight, style, path, index)`.
18//!
19//! No `unwrap`/`expect`/`panic!`: every IO or parse failure is skipped via
20//! `match … continue`.
21
22use std::path::{Path, PathBuf};
23
24use ttf_parser::name_id;
25
26use super::FontStyle;
27
28/// Upper bound on faces probed inside a single font collection (`.ttc`). Guards
29/// against a malformed collection header advertising an unbounded face count.
30const MAX_COLLECTION_FACES: u32 = 64;
31
32/// Maximum subdirectory depth walked under each font root. Font directories nest
33/// only a few levels; this cap bounds the walk and terminates symlink cycles.
34const MAX_SCAN_DEPTH: u32 = 8;
35
36/// A single local/system font face discovered by [`scan_font_dirs`].
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct LocalFontEntry {
39 /// Absolute or caller-relative path to the font file on disk.
40 pub path: PathBuf,
41 /// Typographic family name (e.g. `"Inter"`), preferring name ID 16 over 1.
42 pub family: String,
43 /// Numeric weight (e.g. 400, 700).
44 pub weight: u16,
45 /// Normal or italic style.
46 pub style: FontStyle,
47 /// Face index within the file (0 for single-face files; >0 for `.ttc`).
48 pub index: u32,
49}
50
51/// Scan each directory in `dirs` for font files and return every readable face.
52///
53/// Each directory is read with `std::fs::read_dir`; directories that do not
54/// exist or cannot be read are silently skipped (no error). Files whose
55/// extension is `ttf`, `otf`, or `ttc` (case-insensitive) are collected, sorted
56/// by path for determinism, and parsed. For a font collection, faces are probed
57/// from index 0 upward until parsing fails or `MAX_COLLECTION_FACES` is
58/// reached. Faces with no readable family name are skipped.
59///
60/// The returned `Vec` is sorted by `(family, weight, style, path, index)`, so it
61/// is stable for a given set of directory contents.
62#[must_use]
63pub fn scan_font_dirs(dirs: &[PathBuf]) -> Vec<LocalFontEntry> {
64 let mut files: Vec<PathBuf> = Vec::new();
65 // OS font directories are hierarchical (e.g. `/usr/share/fonts/TTF/…`,
66 // `~/Library/Fonts/…`), so each root is walked recursively. A depth-capped
67 // worklist bounds the walk and terminates even on symlink cycles without
68 // needing a visited set.
69 let mut worklist: Vec<(PathBuf, u32)> = dirs.iter().map(|d| (d.clone(), 0u32)).collect();
70 while let Some((dir, depth)) = worklist.pop() {
71 let read = match std::fs::read_dir(&dir) {
72 Ok(r) => r,
73 Err(_) => continue,
74 };
75 for entry in read {
76 let entry = match entry {
77 Ok(e) => e,
78 Err(_) => continue,
79 };
80 // Use `file_type()` from the DirEntry rather than `path.is_dir()` to
81 // avoid an extra `stat` syscall per entry — most platforms populate
82 // the type during `readdir`.
83 let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
84 let path = entry.path();
85 if is_dir {
86 if depth < MAX_SCAN_DEPTH {
87 worklist.push((path, depth + 1));
88 }
89 } else if has_font_extension(&path) {
90 files.push(path);
91 }
92 }
93 }
94
95 // Sort files by path so parse order (and thus output order) is deterministic
96 // regardless of the directory-iteration order the OS returns.
97 files.sort();
98
99 let mut entries: Vec<LocalFontEntry> = Vec::new();
100 for path in files {
101 let bytes = match std::fs::read(&path) {
102 Ok(b) => b,
103 Err(_) => continue,
104 };
105 for index in 0..MAX_COLLECTION_FACES {
106 let face = match ttf_parser::Face::parse(&bytes, index) {
107 Ok(f) => f,
108 // A failed parse at index 0 means the file is unreadable; at a
109 // higher index it means the collection has no further faces.
110 // Either way, stop probing this file.
111 Err(_) => break,
112 };
113 let family = match best_family_name(&face) {
114 Some(f) => f,
115 None => continue,
116 };
117 let weight = face.weight().to_number();
118 let style = if face.is_italic() {
119 FontStyle::Italic
120 } else {
121 FontStyle::Normal
122 };
123 entries.push(LocalFontEntry {
124 path: path.clone(),
125 family,
126 weight,
127 style,
128 index,
129 });
130 }
131 }
132
133 entries.sort_by(|a, b| {
134 a.family
135 .cmp(&b.family)
136 .then(a.weight.cmp(&b.weight))
137 .then(a.style.cmp(&b.style))
138 .then(a.path.cmp(&b.path))
139 .then(a.index.cmp(&b.index))
140 });
141 entries
142}
143
144/// True when `path` has a `ttf`, `otf`, or `ttc` extension (case-insensitive).
145fn has_font_extension(path: &Path) -> bool {
146 match path.extension().and_then(|e| e.to_str()) {
147 Some(ext) => {
148 let ext = ext.to_ascii_lowercase();
149 ext == "ttf" || ext == "otf" || ext == "ttc"
150 }
151 None => false,
152 }
153}
154
155/// Return the best available family name from a face's name table.
156///
157/// Prefers name ID 16 (Typographic Family) over name ID 1 (Family). Mirrors the
158/// strategy in `zenith-layout`'s `font_meta::best_family_name` so a local face
159/// registers under the same family string a project asset would.
160fn best_family_name(face: &ttf_parser::Face<'_>) -> Option<String> {
161 let mut typo_family: Option<String> = None;
162 let mut family: Option<String> = None;
163
164 for name in face.names() {
165 if name.name_id == name_id::TYPOGRAPHIC_FAMILY
166 && typo_family.is_none()
167 && let Some(s) = name.to_string()
168 {
169 typo_family = Some(s);
170 } else if name.name_id == name_id::FAMILY
171 && family.is_none()
172 && let Some(s) = name.to_string()
173 {
174 family = Some(s);
175 }
176 }
177
178 typo_family.or(family)
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 /// The workspace bundled-fonts directory, used as a real, committed fixture.
186 fn bundled_fonts_dir() -> PathBuf {
187 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/fonts")
188 }
189
190 #[test]
191 fn scans_bundled_fonts_dir_for_noto_sans() {
192 let entries = scan_font_dirs(&[bundled_fonts_dir()]);
193 assert!(
194 !entries.is_empty(),
195 "scanning the bundled fonts dir must yield faces"
196 );
197 assert!(
198 entries.iter().any(|e| e.family.contains("Noto Sans")),
199 "expected a 'Noto Sans' family among scanned faces, got: {:?}",
200 entries.iter().map(|e| &e.family).collect::<Vec<_>>()
201 );
202 // A regular face (weight 400, Normal) must be present and parsed.
203 assert!(
204 entries
205 .iter()
206 .any(|e| e.weight == 400 && e.style == FontStyle::Normal),
207 "expected at least one 400/Normal face"
208 );
209 }
210
211 #[test]
212 fn extracts_weight_and_style_variants() {
213 let entries = scan_font_dirs(&[bundled_fonts_dir()]);
214 // The bundle ships bold (700) and italic faces; the scanner must read
215 // their weight/style from the font tables.
216 assert!(
217 entries.iter().any(|e| e.weight == 700),
218 "expected a 700-weight face among scanned bundled fonts"
219 );
220 assert!(
221 entries.iter().any(|e| e.style == FontStyle::Italic),
222 "expected an italic face among scanned bundled fonts"
223 );
224 }
225
226 #[test]
227 fn nonexistent_dir_yields_empty() {
228 let entries = scan_font_dirs(&[PathBuf::from("/this/path/does/not/exist/zenith")]);
229 assert!(entries.is_empty(), "a missing dir must yield no entries");
230 }
231
232 #[test]
233 fn empty_dir_list_yields_empty() {
234 let entries = scan_font_dirs(&[]);
235 assert!(
236 entries.is_empty(),
237 "an empty dir list must yield no entries"
238 );
239 }
240
241 #[test]
242 fn output_is_sorted_and_deterministic() {
243 let a = scan_font_dirs(&[bundled_fonts_dir()]);
244 let b = scan_font_dirs(&[bundled_fonts_dir()]);
245 assert_eq!(a, b, "two scans of the same dir must be identical");
246 // Verify the documented sort order holds.
247 let mut sorted = a.clone();
248 sorted.sort_by(|x, y| {
249 x.family
250 .cmp(&y.family)
251 .then(x.weight.cmp(&y.weight))
252 .then(x.style.cmp(&y.style))
253 .then(x.path.cmp(&y.path))
254 .then(x.index.cmp(&y.index))
255 });
256 assert_eq!(a, sorted, "scan output must already be sorted");
257 }
258
259 #[test]
260 fn non_font_extensions_are_skipped() {
261 // The bundled fonts dir also contains ABOUT.txt and LICENSE.txt; none of
262 // the scanned entries may point at a non-font file.
263 let entries = scan_font_dirs(&[bundled_fonts_dir()]);
264 for e in &entries {
265 assert!(
266 has_font_extension(&e.path),
267 "scanned a non-font file: {}",
268 e.path.display()
269 );
270 }
271 }
272}