Skip to main content

rust_fontconfig/
config.rs

1//! OS-specific font configuration: directories, common families, and font file constants.
2//!
3//! All hardcoded data is returned as `&'static` references to avoid allocation.
4
5use alloc::string::String;
6use alloc::vec::Vec;
7
8use std::path::{Path, PathBuf};
9
10use crate::FcFontCache;
11use crate::OperatingSystem;
12
13/// Generic CSS font family keywords.
14///
15/// Recognized by [`is_generic_family`] and used wherever the code needs to
16/// distinguish generic families from specific font names.
17pub const GENERIC_FAMILIES: &[&str] = &[
18    "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui",
19];
20
21/// Check whether `family` is a generic CSS font family (case-insensitive).
22pub fn is_generic_family(family: &str) -> bool {
23    let lower = family.to_lowercase();
24    GENERIC_FAMILIES.iter().any(|g| *g == lower.as_str())
25}
26
27/// Style tokens to filter out when guessing family names from filenames.
28///
29/// These are the weight/style/width suffixes commonly appended to font filenames
30/// (e.g. "ArialBold.ttf", "NotoSans-SemiBold.otf"). Used by the scout thread
31/// to extract the base family name from a filename.
32pub const FONT_STYLE_TOKENS: &[&str] = &[
33    "Regular", "Bold", "Italic", "Light", "Medium", "Thin",
34    "Black", "ExtraLight", "ExtraBold", "SemiBold", "DemiBold",
35    "Heavy", "Oblique", "Condensed", "Expanded",
36    // The tokenizer splits compound styles (e.g. "SemiBold" → "Semi" + "Bold"),
37    // so we need the modifier prefixes as standalone style tokens too.
38    "Extra", "Semi", "Demi",
39];
40
41/// Static system font directories per OS. No allocation.
42///
43/// These are the well-known, fixed paths. User-specific directories
44/// (which require env var resolution) are added by [`font_directories`].
45pub fn system_font_dirs(os: OperatingSystem) -> &'static [&'static str] {
46    match os {
47        OperatingSystem::MacOS => &[
48            "/System/Library/Fonts",
49            "/Library/Fonts",
50            "/System/Library/AssetsV2",
51        ],
52        OperatingSystem::Linux => &[
53            "/usr/share/fonts",
54            "/usr/local/share/fonts",
55        ],
56        // Android system-font directories are world-readable. Vendor partitions
57        // (`/product/fonts`, `/system_ext/fonts`) carry OEM-specific families
58        // (Samsung One UI, MIUI, EMUI). `/data/fonts` is the user-selected
59        // font directory exposed by recent OEM ROMs.
60        OperatingSystem::Android => &[
61            "/system/fonts",
62            "/product/fonts",
63            "/system_ext/fonts",
64            "/data/fonts",
65        ],
66        // iOS bundles system fonts under sandboxed paths that cannot be
67        // enumerated with a plain `read_dir`. The cache enumerates them via
68        // `CTFontManagerCopyAvailableFontURLs` in `lib.rs::build_inner`; the
69        // returned `CFURL`s point inside `/System/Library/...` paths that are
70        // openable through the CoreText I/O bridge even though the underlying
71        // directory is unreadable.
72        OperatingSystem::IOS => &[],
73        // Windows paths require env var resolution — handled in font_directories()
74        OperatingSystem::Windows => &[],
75        OperatingSystem::Wasm => &[],
76    }
77}
78
79/// All font directories (system + user-specific).
80///
81/// Combines the static [`system_font_dirs`] with user-specific paths
82/// resolved from environment variables (`HOME`, `SystemRoot`, etc.).
83pub fn font_directories(os: OperatingSystem) -> Vec<PathBuf> {
84    let mut dirs: Vec<PathBuf> = system_font_dirs(os)
85        .iter()
86        .map(PathBuf::from)
87        .collect();
88
89    match os {
90        OperatingSystem::MacOS => {
91            if let Ok(home) = std::env::var("HOME") {
92                dirs.push(PathBuf::from(format!("{}/Library/Fonts", home)));
93            }
94        }
95        OperatingSystem::Linux => {
96            if let Ok(home) = std::env::var("HOME") {
97                dirs.push(PathBuf::from(format!("{}/.fonts", home)));
98                dirs.push(PathBuf::from(format!("{}/.local/share/fonts", home)));
99            }
100        }
101        OperatingSystem::Windows => {
102            let system_root = std::env::var("SystemRoot")
103                .or_else(|_| std::env::var("WINDIR"))
104                .unwrap_or_else(|_| "C:\\Windows".to_string());
105            let user_profile = std::env::var("USERPROFILE")
106                .unwrap_or_else(|_| "C:\\Users\\Default".to_string());
107            dirs.push(PathBuf::from(format!("{}\\Fonts", system_root)));
108            dirs.push(PathBuf::from(format!(
109                "{}\\AppData\\Local\\Microsoft\\Windows\\Fonts",
110                user_profile
111            )));
112        }
113        // No env-var-resolved user-font dir on iOS (no $HOME inside the sandbox)
114        // or Android (apps own /data/data/<package>/files/fonts but that's a
115        // private app dir, not a fontconfig directory).
116        OperatingSystem::IOS | OperatingSystem::Android => {}
117        OperatingSystem::Wasm => {}
118    }
119
120    dirs
121}
122
123/// Common font families for priority boosting, as human-readable names.
124/// No allocation — returns a static slice.
125///
126/// These are the most commonly needed system fonts per OS. The scout thread
127/// uses these to boost the build priority of likely-needed fonts so they're
128/// available sooner.
129///
130/// The names here are the canonical human-readable forms. Use
131/// [`matches_common_family`] for token-based matching against filenames.
132pub fn common_font_families(os: OperatingSystem) -> &'static [&'static str] {
133    match os {
134        OperatingSystem::MacOS => &[
135            // System UI fonts (actual filenames use SFNS prefix)
136            "San Francisco", "SFNS", "System Font",
137            // Sans-serif
138            "Helvetica Neue", "Helvetica", "Arial", "Lucida Grande",
139            // Serif
140            "Times New Roman", "Georgia",
141            // Monospace
142            "Menlo", "SF Mono", "Courier",
143        ],
144        OperatingSystem::Linux => &[
145            // Sans-serif
146            "DejaVu Sans", "Ubuntu", "Roboto", "Noto Sans",
147            "Liberation Sans", "Droid Sans", "Arial",
148            // Serif
149            "DejaVu Serif", "Noto Serif",
150            // Monospace
151            "DejaVu Sans Mono",
152        ],
153        OperatingSystem::Windows => &[
154            // Sans-serif
155            "Segoe UI", "Arial", "Tahoma", "Verdana",
156            // Serif
157            "Times New Roman", "Calibri",
158            // Monospace
159            "Consolas", "Courier New",
160        ],
161        OperatingSystem::IOS => &[
162            // System UI fonts (filenames use SFNS/SFUI prefix)
163            "San Francisco", "SFNS", "SFNSDisplay", "SFNSText", "SFUI",
164            ".AppleSystemUIFont", "System Font",
165            // Sans-serif
166            "Helvetica Neue", "Helvetica", "Avenir", "Avenir Next",
167            // Serif
168            "Times New Roman", "Georgia",
169            // Monospace
170            "Menlo", "SF Mono", "Courier",
171        ],
172        OperatingSystem::Android => &[
173            // System UI fonts
174            "Roboto", "Roboto Flex", "Roboto Condensed",
175            // Sans-serif
176            "Noto Sans", "Droid Sans",
177            // Serif
178            "Noto Serif", "Roboto Serif", "Droid Serif",
179            // Monospace
180            "Roboto Mono", "Droid Sans Mono", "Noto Sans Mono",
181        ],
182        OperatingSystem::Wasm => &[],
183    }
184}
185
186/// Pre-tokenize common font families for efficient per-file matching.
187///
188/// Call this once before iterating over font files, then pass the result
189/// to [`matches_common_family_tokens`] for each file.
190pub fn tokenize_common_families(os: OperatingSystem) -> Vec<Vec<String>> {
191    common_font_families(os)
192        .iter()
193        .map(|family| tokenize_lowercase(family))
194        .collect()
195}
196
197/// Check if a set of filename tokens matches any pre-tokenized common family.
198///
199/// Both sides are joined into a single normalized string (tokens concatenated),
200/// then checked for substring containment. This handles cases where the tokenizer
201/// produces different splits for the same underlying name (e.g. `"SFMono"` stays
202/// as one token from a filename, but `"SF Mono"` splits into `["sf", "mono"]`).
203pub fn matches_common_family_tokens(
204    file_tokens: &[String],
205    common_token_sets: &[Vec<String>],
206) -> bool {
207    let file_joined: String = file_tokens.concat();
208    common_token_sets.iter().any(|family_tokens| {
209        let family_joined: String = family_tokens.concat();
210        file_joined.contains(&family_joined)
211    })
212}
213
214/// Extract non-style tokens from a font filename stem.
215///
216/// Tokenizes using CamelCase boundaries, hyphens, underscores, and spaces,
217/// then filters out style tokens (Bold, Italic, Regular, etc.).
218/// Returns lowercased tokens suitable for family name matching.
219///
220/// # Examples
221///
222/// - `"ArialBold"` → `["arial"]`
223/// - `"NotoSansJP-Regular"` → `["noto", "sans", "jp"]`
224/// - `"HelveticaNeue-BoldItalic"` → `["helvetica", "neue"]`
225/// Tokenize a name into lowercase tokens (no style filtering).
226///
227/// Useful for priority scoring where style tokens like "Bold" are still relevant.
228pub fn tokenize_lowercase(name: &str) -> Vec<String> {
229    FcFontCache::extract_font_name_tokens(name)
230        .into_iter()
231        .map(|t| t.to_lowercase())
232        .collect()
233}
234
235/// Tokenize a font filename stem into lowercase tokens, filtering out style tokens.
236pub fn tokenize_font_stem(stem: &str) -> Vec<String> {
237    tokenize_lowercase(stem)
238        .into_iter()
239        .filter(|t| !FONT_STYLE_TOKENS.iter().any(|s| s.eq_ignore_ascii_case(t)))
240        .collect()
241}
242
243/// Guess the font family name from a filename, using tokenization.
244///
245/// Extracts non-style tokens from the filename stem and joins them
246/// into a single normalized string (lowercase, no separators).
247///
248/// # Examples
249///
250/// - `"ArialBold.ttf"` → `"arial"`
251/// - `"NotoSansJP-Regular.otf"` → `"notosansjp"`
252/// - `"Helvetica Neue Bold Italic.ttf"` → `"helveticaneue"`
253pub fn guess_family_from_filename(path: &Path) -> String {
254    let stem = path
255        .file_stem()
256        .and_then(|s| s.to_str())
257        .unwrap_or("");
258
259    tokenize_font_stem(stem).join("")
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    // ── Generic families ─────────────────────────────────────────────────
267
268    #[test]
269    fn generic_families_recognized() {
270        assert!(is_generic_family("sans-serif"));
271        assert!(is_generic_family("Sans-Serif")); // case-insensitive
272        assert!(is_generic_family("monospace"));
273        assert!(is_generic_family("SERIF"));
274        assert!(!is_generic_family("Arial"));
275        assert!(!is_generic_family("Noto Sans"));
276    }
277
278    // ── Constants ────────────────────────────────────────────────────────
279
280    #[test]
281    fn font_style_tokens_covers_common_styles() {
282        for token in &[
283            "Regular", "Bold", "Italic", "Light", "Medium",
284            "Thin", "Black", "Oblique", "SemiBold",
285        ] {
286            assert!(
287                FONT_STYLE_TOKENS.contains(token),
288                "missing style token: {}", token
289            );
290        }
291    }
292
293    // ── system_font_dirs ────────────────────────────────────────────────
294
295    #[test]
296    fn system_font_dirs_static_and_nonempty() {
297        assert!(!system_font_dirs(OperatingSystem::MacOS).is_empty());
298        assert!(!system_font_dirs(OperatingSystem::Linux).is_empty());
299        assert!(system_font_dirs(OperatingSystem::Wasm).is_empty());
300    }
301
302    // ── common_font_families ────────────────────────────────────────────
303
304    #[test]
305    fn common_font_families_nonempty_for_desktop() {
306        assert!(!common_font_families(OperatingSystem::MacOS).is_empty());
307        assert!(!common_font_families(OperatingSystem::Linux).is_empty());
308        assert!(!common_font_families(OperatingSystem::Windows).is_empty());
309        assert!(common_font_families(OperatingSystem::Wasm).is_empty());
310    }
311
312    // ── guess_family_from_filename ──────────────────────────────────────
313
314    #[test]
315    fn guess_family_strips_style_suffixes() {
316        assert_eq!(
317            guess_family_from_filename(Path::new("ArialBold.ttf")),
318            "arial"
319        );
320        assert_eq!(
321            guess_family_from_filename(Path::new("NotoSansJP-Regular.otf")),
322            "notosansjp"
323        );
324        assert_eq!(
325            guess_family_from_filename(Path::new("Helvetica Neue Bold Italic.ttf")),
326            "helveticaneue"
327        );
328    }
329
330    #[test]
331    fn guess_family_handles_underscores() {
332        assert_eq!(
333            guess_family_from_filename(Path::new("Liberation_Sans_Bold.ttf")),
334            "liberationsans"
335        );
336    }
337
338    #[test]
339    fn guess_family_handles_compound_styles() {
340        assert_eq!(
341            guess_family_from_filename(Path::new("LiberationSans-BoldItalic.ttf")),
342            "liberationsans"
343        );
344        assert_eq!(
345            guess_family_from_filename(Path::new("DejaVuSansMono-ExtraBold.ttf")),
346            "dejavusansmono"
347        );
348        assert_eq!(
349            guess_family_from_filename(Path::new("SFMono-SemiBold.otf")),
350            "sfmono"
351        );
352    }
353
354    // ── token-based matching ────────────────────────────────────────────
355
356    #[test]
357    fn matches_common_family_macos() {
358        let common = tokenize_common_families(OperatingSystem::MacOS);
359
360        // "SFNSDisplay" → tokens ["sfns", "display"] → matches "SFNS"
361        let tokens = tokenize_all("SFNSDisplay");
362        assert!(matches_common_family_tokens(&tokens, &common));
363
364        // "HelveticaNeue" → tokens ["helvetica", "neue"] → matches "Helvetica Neue"
365        let tokens = tokenize_all("HelveticaNeue");
366        assert!(matches_common_family_tokens(&tokens, &common));
367
368        // "Arial" → matches "Arial"
369        let tokens = tokenize_all("Arial");
370        assert!(matches_common_family_tokens(&tokens, &common));
371
372        // "SomeRandomFont" → no match
373        let tokens = tokenize_all("SomeRandomFont");
374        assert!(!matches_common_family_tokens(&tokens, &common));
375    }
376
377    #[test]
378    fn matches_common_family_linux() {
379        let common = tokenize_common_families(OperatingSystem::Linux);
380
381        let tokens = tokenize_all("DejaVuSans");
382        assert!(matches_common_family_tokens(&tokens, &common));
383
384        let tokens = tokenize_all("NotoSansCJK");
385        assert!(matches_common_family_tokens(&tokens, &common));
386
387        let tokens = tokenize_all("UbuntuMono-Regular");
388        assert!(matches_common_family_tokens(&tokens, &common));
389    }
390
391    #[test]
392    fn matches_common_family_windows() {
393        let common = tokenize_common_families(OperatingSystem::Windows);
394
395        let tokens = tokenize_all("SegoeUI-Regular");
396        assert!(matches_common_family_tokens(&tokens, &common));
397
398        let tokens = tokenize_all("Consolas");
399        assert!(matches_common_family_tokens(&tokens, &common));
400    }
401
402    // ── tokenize_font_stem ──────────────────────────────────────────────
403
404    #[test]
405    fn tokenize_font_stem_filters_styles() {
406        assert_eq!(tokenize_font_stem("ArialBold"), vec!["arial"]);
407        assert_eq!(
408            tokenize_font_stem("NotoSansJP-Regular"),
409            vec!["noto", "sans", "jp"]
410        );
411        // "SFMono" stays as one token (consecutive uppercase → no CamelCase split)
412        assert_eq!(
413            tokenize_font_stem("SFMono-SemiBold"),
414            vec!["sfmono"]
415        );
416    }
417
418    /// Helper: tokenize a stem into all lowercase tokens (including style tokens).
419    fn tokenize_all(stem: &str) -> Vec<String> {
420        tokenize_lowercase(stem)
421    }
422}