Skip to main content

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}