Skip to main content

rust_fontconfig/
config.rs

1//! OS-specific font configuration.
2//!
3//! Contains default font directories, generic CSS families, and fallback configuration.
4//! Hardcoded data is returned as `&'static` references to avoid allocation.
5use crate::FcFontCache;
6use crate::OperatingSystem;
7use crate::UnicodeRange;
8use alloc::collections::BTreeMap;
9use alloc::string::{String, ToString};
10use alloc::vec::Vec;
11use std::path::{Path, PathBuf};
12
13/// Generic CSS font family keywords (CSS Fonts Level 4).
14
15/// Style tokens to filter out when guessing family names from filenames.
16/// These are the weight/style/width suffixes commonly appended to font filenames
17/// (e.g. "ArialBold.ttf", "NotoSans-SemiBold.otf"). Used by the scout thread
18/// to extract the base family name from a filename.
19pub const FONT_STYLE_TOKENS: &[&str] = &[
20    "Regular",
21    "Bold",
22    "Italic",
23    "Light",
24    "Medium",
25    "Thin",
26    "Black",
27    "ExtraLight",
28    "ExtraBold",
29    "SemiBold",
30    "DemiBold",
31    "Heavy",
32    "Oblique",
33    "Condensed",
34    "Expanded",
35    // The tokenizer splits compound styles (e.g. "SemiBold" → "Semi" + "Bold"),
36    // so we need the modifier prefixes as standalone style tokens too.
37    "Extra",
38    "Semi",
39    "Demi",
40];
41
42/// Check whether `family` is a generic CSS font family (case-insensitive).
43pub fn is_generic_family(family: &str) -> bool {
44    GenericFamily::from_css(family).is_some()
45}
46
47/// A CSS generic font family.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
49pub enum GenericFamily {
50    Serif,
51    SansSerif,
52    Monospace,
53    Cursive,
54    Fantasy,
55    SystemUi,
56    UiSerif,
57    UiSansSerif,
58    UiMonospace,
59    UiRounded,
60    Emoji,
61    Math,
62    Fangsong,
63}
64
65impl GenericFamily {
66    /// Every generic, in [`GENERIC_FAMILIES`] order.
67    pub const ALL: &'static [GenericFamily] = &[
68        GenericFamily::Serif,
69        GenericFamily::SansSerif,
70        GenericFamily::Monospace,
71        GenericFamily::Cursive,
72        GenericFamily::Fantasy,
73        GenericFamily::SystemUi,
74        GenericFamily::UiSerif,
75        GenericFamily::UiSansSerif,
76        GenericFamily::UiMonospace,
77        GenericFamily::UiRounded,
78        GenericFamily::Emoji,
79        GenericFamily::Math,
80        GenericFamily::Fangsong,
81    ];
82
83    /// Parse a CSS keyword (case-insensitive, separators ignored).
84    pub fn from_css(name: &str) -> Option<Self> {
85        let key: String = name
86            .chars()
87            .filter(|c| c.is_ascii_alphanumeric())
88            .map(|c| c.to_ascii_lowercase())
89            .collect();
90        Some(match key.as_str() {
91            "serif" => GenericFamily::Serif,
92            "sansserif" => GenericFamily::SansSerif,
93            "monospace" => GenericFamily::Monospace,
94            "cursive" => GenericFamily::Cursive,
95            "fantasy" => GenericFamily::Fantasy,
96            "systemui" => GenericFamily::SystemUi,
97            "uiserif" => GenericFamily::UiSerif,
98            "uisansserif" => GenericFamily::UiSansSerif,
99            "uimonospace" => GenericFamily::UiMonospace,
100            "uirounded" => GenericFamily::UiRounded,
101            "emoji" => GenericFamily::Emoji,
102            "math" => GenericFamily::Math,
103            "fangsong" => GenericFamily::Fangsong,
104            _ => return None,
105        })
106    }
107
108    /// The CSS keyword.
109    pub fn as_css(self) -> &'static str {
110        match self {
111            GenericFamily::Serif => "serif",
112            GenericFamily::SansSerif => "sans-serif",
113            GenericFamily::Monospace => "monospace",
114            GenericFamily::Cursive => "cursive",
115            GenericFamily::Fantasy => "fantasy",
116            GenericFamily::SystemUi => "system-ui",
117            GenericFamily::UiSerif => "ui-serif",
118            GenericFamily::UiSansSerif => "ui-sans-serif",
119            GenericFamily::UiMonospace => "ui-monospace",
120            GenericFamily::UiRounded => "ui-rounded",
121            GenericFamily::Emoji => "emoji",
122            GenericFamily::Math => "math",
123            GenericFamily::Fangsong => "fangsong",
124        }
125    }
126
127    /// The generic whose configuration stands in when this one has none.
128    pub fn parent(self) -> Option<Self> {
129        match self {
130            GenericFamily::Serif | GenericFamily::SansSerif | GenericFamily::Monospace => None,
131            GenericFamily::UiSerif | GenericFamily::Fangsong => Some(GenericFamily::Serif),
132            GenericFamily::UiMonospace => Some(GenericFamily::Monospace),
133            GenericFamily::Cursive
134            | GenericFamily::Fantasy
135            | GenericFamily::SystemUi
136            | GenericFamily::UiSansSerif
137            | GenericFamily::UiRounded
138            | GenericFamily::Emoji
139            | GenericFamily::Math => Some(GenericFamily::SansSerif),
140        }
141    }
142
143    /// `self`, then its parents, root last.
144    fn lineage(self) -> impl Iterator<Item = GenericFamily> {
145        let mut next = Some(self);
146        core::iter::from_fn(move || {
147            let current = next?;
148            next = current.parent();
149            Some(current)
150        })
151    }
152}
153
154/// Preferred families for a Unicode script block.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct FcScriptFallback {
157    /// Characters this preference applies to.
158    pub range: UnicodeRange,
159    /// Generic family this preference applies to, if any.
160    pub generic: Option<GenericFamily>,
161    /// Family names, best first.
162    pub families: Vec<String>,
163}
164
165/// Fallback configuration for font resolution.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct FcFallbackConfig {
168    /// Base candidates per generic family, best first.
169    pub generic_families: BTreeMap<GenericFamily, Vec<String>>,
170    /// Substitutions for named families that are not installed.
171    pub substitutions: BTreeMap<String, Vec<String>>,
172    /// Per-script preferences, in priority order.
173    pub script_fallbacks: Vec<FcScriptFallback>,
174    /// Last resort families used when no other font provides coverage.
175    pub last_resort: Vec<String>,
176    /// Default generic family when a stack doesn't specify one.
177    pub default_generic: GenericFamily,
178}
179
180impl Default for FcFallbackConfig {
181    fn default() -> Self {
182        Self::empty()
183    }
184}
185
186/// Unicode blocks the built-in tables refer to.
187pub mod blocks {
188    use crate::UnicodeRange;
189
190    pub const ARABIC: UnicodeRange = UnicodeRange {
191        start: 0x0600,
192        end: 0x06FF,
193    };
194    pub const HEBREW: UnicodeRange = UnicodeRange {
195        start: 0x0590,
196        end: 0x05FF,
197    };
198    pub const THAI: UnicodeRange = UnicodeRange {
199        start: 0x0E00,
200        end: 0x0E7F,
201    };
202    pub const CJK_SYMBOLS_AND_PUNCTUATION: UnicodeRange = UnicodeRange {
203        start: 0x3000,
204        end: 0x303F,
205    };
206    pub const HIRAGANA: UnicodeRange = UnicodeRange {
207        start: 0x3040,
208        end: 0x309F,
209    };
210    pub const KATAKANA: UnicodeRange = UnicodeRange {
211        start: 0x30A0,
212        end: 0x30FF,
213    };
214    pub const CJK_UNIFIED_IDEOGRAPHS: UnicodeRange = UnicodeRange {
215        start: 0x4E00,
216        end: 0x9FFF,
217    };
218    pub const HANGUL_SYLLABLES: UnicodeRange = UnicodeRange {
219        start: 0xAC00,
220        end: 0xD7A3,
221    };
222    pub const HALFWIDTH_AND_FULLWIDTH_FORMS: UnicodeRange = UnicodeRange {
223        start: 0xFF00,
224        end: 0xFFEF,
225    };
226}
227
228fn names(list: &[&str]) -> Vec<String> {
229    list.iter().map(|s| s.to_string()).collect()
230}
231
232fn push_unique(out: &mut Vec<String>, name: &str) {
233    if !out.iter().any(|e| e.eq_ignore_ascii_case(name)) {
234        out.push(name.to_string());
235    }
236}
237
238impl FcFallbackConfig {
239    /// Returns an empty fallback configuration.
240    pub fn empty() -> Self {
241        Self {
242            generic_families: BTreeMap::new(),
243            substitutions: BTreeMap::new(),
244            script_fallbacks: Vec::new(),
245            last_resort: Vec::new(),
246            default_generic: GenericFamily::SansSerif,
247        }
248    }
249
250    /// Returns the default OS-specific fallback configuration.
251    pub fn os_defaults(os: OperatingSystem) -> Self {
252        use blocks::*;
253        use GenericFamily::{Monospace, SansSerif, Serif, SystemUi};
254        let mut config = Self::empty();
255        let mut generic = |g: GenericFamily, list: &[&str]| {
256            config.generic_families.insert(g, names(list));
257        };
258        match os {
259            OperatingSystem::Windows => {
260                generic(Serif, &["Times New Roman"]);
261                generic(
262                    SansSerif,
263                    &[
264                        "Segoe UI",
265                        "Tahoma",
266                        "Microsoft Sans Serif",
267                        "MS Sans Serif",
268                        "Helv",
269                    ],
270                );
271                generic(
272                    Monospace,
273                    &[
274                        "Segoe UI Mono",
275                        "Courier New",
276                        "Cascadia Code",
277                        "Cascadia Mono",
278                        "Consolas",
279                    ],
280                );
281            }
282            OperatingSystem::Linux => {
283                generic(
284                    Serif,
285                    &[
286                        "Times",
287                        "Times New Roman",
288                        "DejaVu Serif",
289                        "Free Serif",
290                        "Noto Serif",
291                        "Bitstream Vera Serif",
292                        "Roman",
293                        "Regular",
294                    ],
295                );
296                generic(
297                    SansSerif,
298                    &[
299                        "Ubuntu",
300                        "Arial",
301                        "DejaVu Sans",
302                        "Noto Sans",
303                        "Liberation Sans",
304                    ],
305                );
306                generic(
307                    Monospace,
308                    &[
309                        "Source Code Pro",
310                        "Cantarell",
311                        "DejaVu Sans Mono",
312                        "Roboto Mono",
313                        "Ubuntu Monospace",
314                        "Droid Sans Mono",
315                    ],
316                );
317            }
318            OperatingSystem::MacOS | OperatingSystem::IOS => {
319                generic(
320                    SystemUi,
321                    &[
322                        "San Francisco",
323                        "SFNS",
324                        "SFNSDisplay",
325                        "SFNSText",
326                        "SFUI",
327                        ".AppleSystemUIFont",
328                        ".SFUIText",
329                        ".SFUI-Regular",
330                        "System Font",
331                    ],
332                );
333                generic(Serif, &["Times New Roman", "Times", "New York", "Palatino"]);
334                generic(SansSerif, &["Helvetica Neue", "Helvetica", "Lucida Grande"]);
335                generic(
336                    Monospace,
337                    &[
338                        "SF Mono",
339                        "Menlo",
340                        "Monaco",
341                        "Courier",
342                        "Oxygen Mono",
343                        "Source Code Pro",
344                        "Fira Mono",
345                    ],
346                );
347            }
348            OperatingSystem::Android => {
349                generic(Serif, &["Noto Serif", "Roboto Serif", "Droid Serif"]);
350                generic(
351                    SansSerif,
352                    &["Roboto", "Roboto-Regular", "Noto Sans", "Droid Sans"],
353                );
354                generic(
355                    Monospace,
356                    &[
357                        "Roboto Mono",
358                        "Droid Sans Mono",
359                        "Noto Sans Mono",
360                        "DejaVu Sans Mono",
361                    ],
362                );
363            }
364            OperatingSystem::Wasm => {}
365        }
366
367        let mut script = |g: GenericFamily, range: UnicodeRange, list: &[&str]| {
368            config.script_fallbacks.push(FcScriptFallback {
369                range,
370                generic: Some(g),
371                families: names(list),
372            });
373        };
374
375        // CJK: ideographs are shared by Chinese, Japanese and Korean, so
376        // their list keeps the historical order; kana are Japanese and
377        // Hangul is Korean, so those blocks put the matching font first.
378        let mut cjk = |g: GenericFamily, ideographs: &[&str], kana: &[&str], hangul: &[&str]| {
379            for block in [
380                CJK_SYMBOLS_AND_PUNCTUATION,
381                CJK_UNIFIED_IDEOGRAPHS,
382                HALFWIDTH_AND_FULLWIDTH_FORMS,
383            ] {
384                script(g, block, ideographs);
385            }
386            for block in [HIRAGANA, KATAKANA] {
387                script(g, block, kana);
388            }
389            script(g, HANGUL_SYLLABLES, hangul);
390        };
391        match os {
392            OperatingSystem::Windows => {
393                cjk(
394                    Serif,
395                    &["MS Mincho", "SimSun", "MingLiU"],
396                    &["MS Mincho", "SimSun", "MingLiU"],
397                    &["SimSun", "MS Mincho", "MingLiU"],
398                );
399                cjk(
400                    SansSerif,
401                    &["Microsoft YaHei", "MS Gothic", "Malgun Gothic", "SimHei"],
402                    &["MS Gothic", "Microsoft YaHei", "Malgun Gothic", "SimHei"],
403                    &["Malgun Gothic", "Microsoft YaHei", "MS Gothic", "SimHei"],
404                );
405                cjk(
406                    Monospace,
407                    &["MS Gothic", "SimHei"],
408                    &["MS Gothic", "SimHei"],
409                    &["MS Gothic", "SimHei"],
410                );
411                script(Serif, ARABIC, &["Traditional Arabic"]);
412                script(SansSerif, ARABIC, &["Segoe UI Arabic"]);
413                script(SansSerif, HEBREW, &["Segoe UI Hebrew"]);
414                script(SansSerif, THAI, &["Leelawadee UI"]);
415            }
416            OperatingSystem::Linux => {
417                cjk(
418                    Serif,
419                    &[
420                        "Noto Serif CJK SC",
421                        "Noto Serif CJK JP",
422                        "Noto Serif CJK KR",
423                    ],
424                    &[
425                        "Noto Serif CJK JP",
426                        "Noto Serif CJK SC",
427                        "Noto Serif CJK KR",
428                    ],
429                    &[
430                        "Noto Serif CJK KR",
431                        "Noto Serif CJK SC",
432                        "Noto Serif CJK JP",
433                    ],
434                );
435                cjk(
436                    SansSerif,
437                    &[
438                        "Noto Sans CJK SC",
439                        "Noto Sans CJK JP",
440                        "Noto Sans CJK KR",
441                        "WenQuanYi Micro Hei",
442                        "Droid Sans Fallback",
443                    ],
444                    &[
445                        "Noto Sans CJK JP",
446                        "Noto Sans CJK SC",
447                        "Noto Sans CJK KR",
448                        "WenQuanYi Micro Hei",
449                        "Droid Sans Fallback",
450                    ],
451                    &[
452                        "Noto Sans CJK KR",
453                        "Noto Sans CJK SC",
454                        "Noto Sans CJK JP",
455                        "WenQuanYi Micro Hei",
456                        "Droid Sans Fallback",
457                    ],
458                );
459                cjk(
460                    Monospace,
461                    &[
462                        "Noto Sans Mono CJK SC",
463                        "Noto Sans Mono CJK JP",
464                        "WenQuanYi Zen Hei Mono",
465                    ],
466                    &[
467                        "Noto Sans Mono CJK JP",
468                        "Noto Sans Mono CJK SC",
469                        "WenQuanYi Zen Hei Mono",
470                    ],
471                    &[
472                        "Noto Sans Mono CJK SC",
473                        "Noto Sans Mono CJK JP",
474                        "WenQuanYi Zen Hei Mono",
475                    ],
476                );
477                script(Serif, ARABIC, &["Noto Serif Arabic"]);
478                script(SansSerif, ARABIC, &["Noto Sans Arabic"]);
479                script(SansSerif, HEBREW, &["Noto Sans Hebrew"]);
480                script(SansSerif, THAI, &["Noto Sans Thai"]);
481            }
482            OperatingSystem::MacOS | OperatingSystem::IOS => {
483                cjk(
484                    Serif,
485                    &["Hiragino Mincho ProN", "STSong", "AppleMyungjo"],
486                    &["Hiragino Mincho ProN", "STSong", "AppleMyungjo"],
487                    &["AppleMyungjo", "Hiragino Mincho ProN", "STSong"],
488                );
489                cjk(
490                    SansSerif,
491                    &[
492                        "Hiragino Sans",
493                        "Hiragino Kaku Gothic ProN",
494                        "PingFang SC",
495                        "PingFang TC",
496                        "Apple SD Gothic Neo",
497                    ],
498                    &[
499                        "Hiragino Sans",
500                        "Hiragino Kaku Gothic ProN",
501                        "PingFang SC",
502                        "PingFang TC",
503                        "Apple SD Gothic Neo",
504                    ],
505                    &[
506                        "Apple SD Gothic Neo",
507                        "Hiragino Sans",
508                        "Hiragino Kaku Gothic ProN",
509                        "PingFang SC",
510                        "PingFang TC",
511                    ],
512                );
513                cjk(
514                    Monospace,
515                    &["Hiragino Sans", "PingFang SC"],
516                    &["Hiragino Sans", "PingFang SC"],
517                    &["Hiragino Sans", "PingFang SC"],
518                );
519                script(Serif, ARABIC, &["Geeza Pro"]);
520                script(SansSerif, ARABIC, &["Geeza Pro"]);
521                script(SansSerif, HEBREW, &["Arial Hebrew"]);
522                script(SansSerif, THAI, &["Thonburi"]);
523            }
524            OperatingSystem::Android => {
525                cjk(
526                    Serif,
527                    &[
528                        "Noto Serif CJK SC",
529                        "Noto Serif CJK JP",
530                        "Noto Serif CJK KR",
531                    ],
532                    &[
533                        "Noto Serif CJK JP",
534                        "Noto Serif CJK SC",
535                        "Noto Serif CJK KR",
536                    ],
537                    &[
538                        "Noto Serif CJK KR",
539                        "Noto Serif CJK SC",
540                        "Noto Serif CJK JP",
541                    ],
542                );
543                cjk(
544                    SansSerif,
545                    &[
546                        "Noto Sans CJK SC",
547                        "Noto Sans CJK JP",
548                        "Noto Sans CJK KR",
549                        "Droid Sans Fallback",
550                    ],
551                    &[
552                        "Noto Sans CJK JP",
553                        "Noto Sans CJK SC",
554                        "Noto Sans CJK KR",
555                        "Droid Sans Fallback",
556                    ],
557                    &[
558                        "Noto Sans CJK KR",
559                        "Noto Sans CJK SC",
560                        "Noto Sans CJK JP",
561                        "Droid Sans Fallback",
562                    ],
563                );
564                cjk(
565                    Monospace,
566                    &["Noto Sans Mono CJK SC", "Noto Sans Mono CJK JP"],
567                    &["Noto Sans Mono CJK JP", "Noto Sans Mono CJK SC"],
568                    &["Noto Sans Mono CJK SC", "Noto Sans Mono CJK JP"],
569                );
570                script(Serif, ARABIC, &["Noto Naskh Arabic"]);
571                script(SansSerif, ARABIC, &["Noto Sans Arabic"]);
572                script(SansSerif, HEBREW, &["Noto Sans Hebrew"]);
573                script(SansSerif, THAI, &["Noto Sans Thai"]);
574            }
575            OperatingSystem::Wasm => {}
576        }
577
578        config
579    }
580
581    /// Base candidates for `generic`, borrowing from its
582    /// [`parent`](GenericFamily::parent) when it has no entry of its own.
583    pub fn generic_candidates(&self, generic: GenericFamily) -> &[String] {
584        generic
585            .lineage()
586            .find_map(|g| self.generic_families.get(&g))
587            .map(Vec::as_slice)
588            .unwrap_or(&[])
589    }
590
591    /// Substitutions for the named `family` (normalized lookup).
592    pub fn substitutions_for(&self, family: &str) -> &[String] {
593        self.substitutions
594            .get(&crate::utils::normalize_family_name(family))
595            .map(Vec::as_slice)
596            .unwrap_or(&[])
597    }
598
599    /// Returns the preferred fallback families for a given unicode range.
600    /// Checks the specified generic family first (if any), followed by
601    /// generic-agnostic fallbacks. Results are deduplicated and keep their order.
602    pub fn script_candidates(
603        &self,
604        generic: Option<GenericFamily>,
605        block: &UnicodeRange,
606    ) -> Vec<String> {
607        let mut out = Vec::new();
608        if let Some(generic) = generic {
609            for g in generic.lineage() {
610                for entry in &self.script_fallbacks {
611                    if entry.generic == Some(g) && entry.range.overlaps(block) {
612                        entry.families.iter().for_each(|f| push_unique(&mut out, f));
613                    }
614                }
615            }
616        }
617        for entry in &self.script_fallbacks {
618            if entry.generic.is_none() && entry.range.overlaps(block) {
619                entry.families.iter().for_each(|f| push_unique(&mut out, f));
620            }
621        }
622        out
623    }
624
625    /// Every family name a generic may resolve to for text in `ranges`:
626    /// script preferences for the overlapping blocks first, then the base
627    /// candidates.
628    pub fn expand_generic(&self, generic: GenericFamily, ranges: &[UnicodeRange]) -> Vec<String> {
629        let mut out = Vec::new();
630        for block in ranges {
631            self.script_candidates(Some(generic), block)
632                .iter()
633                .for_each(|f| push_unique(&mut out, f));
634        }
635        self.generic_candidates(generic)
636            .iter()
637            .for_each(|f| push_unique(&mut out, f));
638        out
639    }
640
641    /// [`expand_generic`](Self::expand_generic) for a generic keyword; a
642    /// named family expands to itself followed by its substitutions.
643    pub fn expand_family(&self, family: &str, ranges: &[UnicodeRange]) -> Vec<String> {
644        match GenericFamily::from_css(family) {
645            Some(generic) => self.expand_generic(generic, ranges),
646            None => {
647                let mut out = Vec::new();
648                push_unique(&mut out, family);
649                self.substitutions_for(family)
650                    .iter()
651                    .for_each(|f| push_unique(&mut out, f));
652                out
653            }
654        }
655    }
656
657    /// Expands a CSS font stack and unicode ranges into a complete, ordered
658    /// list of candidate font families to search for.
659    pub fn candidate_families(&self, stack: &[String], ranges: &[UnicodeRange]) -> Vec<String> {
660        let mut out = Vec::new();
661        let mut any_generic = false;
662        for family in stack {
663            any_generic |= GenericFamily::from_css(family).is_some();
664            self.expand_family(family, ranges)
665                .iter()
666                .for_each(|f| push_unique(&mut out, f));
667        }
668        for block in ranges {
669            let generic = if any_generic {
670                None
671            } else {
672                Some(self.default_generic)
673            };
674            self.script_candidates(generic, block)
675                .iter()
676                .for_each(|f| push_unique(&mut out, f));
677        }
678        self.last_resort
679            .iter()
680            .for_each(|f| push_unique(&mut out, f));
681        out
682    }
683
684    /// Merges missing configuration values from `defaults` into this config.
685    /// Does not overwrite or reorder existing entries.
686    pub fn merge_defaults(&mut self, defaults: &FcFallbackConfig) {
687        for (generic, families) in &defaults.generic_families {
688            self.generic_families
689                .entry(*generic)
690                .or_insert_with(|| families.clone());
691        }
692        for (family, replacements) in &defaults.substitutions {
693            self.substitutions
694                .entry(family.clone())
695                .or_insert_with(|| replacements.clone());
696        }
697        for entry in &defaults.script_fallbacks {
698            let already = self
699                .script_fallbacks
700                .iter()
701                .any(|e| e.generic == entry.generic && e.range.overlaps(&entry.range));
702            if !already {
703                self.script_fallbacks.push(entry.clone());
704            }
705        }
706        if self.last_resort.is_empty() {
707            self.last_resort = defaults.last_resort.clone();
708        }
709    }
710
711    /// Take over parsed platform aliases (`fonts.conf` `<alias><prefer>`):
712    /// a generic keyword becomes that generic's base candidates, anything
713    /// else a named-family substitution. Keys are normalized family names.
714
715    /// Extract all unique font families listed in this fallback configuration.
716    pub fn extract_all_families(&self) -> Vec<String> {
717        let mut out = Vec::new();
718        for families in self.generic_families.values() {
719            families.iter().for_each(|f| push_unique(&mut out, f));
720        }
721        for replacements in self.substitutions.values() {
722            replacements.iter().for_each(|f| push_unique(&mut out, f));
723        }
724        for entry in &self.script_fallbacks {
725            entry.families.iter().for_each(|f| push_unique(&mut out, f));
726        }
727        self.last_resort
728            .iter()
729            .for_each(|f| push_unique(&mut out, f));
730        out
731    }
732
733    pub fn absorb_system_aliases(&mut self, aliases: BTreeMap<String, Vec<String>>) {
734        for (key, prefs) in aliases {
735            match GenericFamily::from_css(&key) {
736                Some(generic) => {
737                    self.generic_families.insert(generic, prefs);
738                }
739                None => {
740                    self.substitutions.insert(key, prefs);
741                }
742            }
743        }
744    }
745}
746
747/// Static system font directories per OS.
748/// All font directories (system + user-specific).
749pub fn font_directories(os: OperatingSystem) -> Vec<PathBuf> {
750    let mut dirs = Vec::new();
751    match os {
752        OperatingSystem::MacOS => {
753            dirs.push(PathBuf::from("/System/Library/Fonts"));
754            dirs.push(PathBuf::from("/Library/Fonts"));
755            dirs.push(PathBuf::from("/System/Library/AssetsV2"));
756            if let Ok(home) = std::env::var("HOME") {
757                dirs.push(PathBuf::from(format!("{}/Library/Fonts", home)));
758            }
759        }
760        OperatingSystem::Linux => {
761            dirs.push(PathBuf::from("/usr/share/fonts"));
762            dirs.push(PathBuf::from("/usr/local/share/fonts"));
763            if let Ok(home) = std::env::var("HOME") {
764                dirs.push(PathBuf::from(format!("{}/.fonts", home)));
765                dirs.push(PathBuf::from(format!("{}/.local/share/fonts", home)));
766            }
767        }
768        OperatingSystem::Windows => {
769            let system_root = std::env::var("SystemRoot")
770                .or_else(|_| std::env::var("WINDIR"))
771                .unwrap_or_else(|_| "C:\\Windows".to_string());
772            let user_profile =
773                std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\Default".to_string());
774            dirs.push(PathBuf::from(format!("{}\\Fonts", system_root)));
775            dirs.push(PathBuf::from(format!(
776                "{}\\AppData\\Local\\Microsoft\\Windows\\Fonts",
777                user_profile
778            )));
779        }
780        OperatingSystem::Android => {
781            dirs.push(PathBuf::from("/system/fonts"));
782            dirs.push(PathBuf::from("/product/fonts"));
783            dirs.push(PathBuf::from("/system_ext/fonts"));
784            dirs.push(PathBuf::from("/data/fonts"));
785        }
786        OperatingSystem::IOS | OperatingSystem::Wasm => {}
787    }
788
789    dirs
790}
791
792/// Common font families for priority boosting, as human-readable names.
793/// These families will be parsed first so likely-needed fonts are available sooner.
794pub fn common_font_families(os: OperatingSystem) -> &'static [&'static str] {
795    match os {
796        OperatingSystem::MacOS => &[
797            // System UI fonts (actual filenames use SFNS prefix)
798            "San Francisco",
799            "SFNS",
800            "System Font",
801            // Sans-serif
802            "Helvetica Neue",
803            "Helvetica",
804            "Arial",
805            "Lucida Grande",
806            // Serif
807            "Times New Roman",
808            "Georgia",
809            // Monospace
810            "Menlo",
811            "SF Mono",
812            "Courier",
813        ],
814        OperatingSystem::Linux => &[
815            // Sans-serif
816            "DejaVu Sans",
817            "Ubuntu",
818            "Roboto",
819            "Noto Sans",
820            "Liberation Sans",
821            "Droid Sans",
822            "Arial",
823            // Serif
824            "DejaVu Serif",
825            "Noto Serif",
826            // Monospace
827            "DejaVu Sans Mono",
828        ],
829        OperatingSystem::Windows => &[
830            // Sans-serif
831            "Segoe UI",
832            "Arial",
833            "Tahoma",
834            "Verdana",
835            // Serif
836            "Times New Roman",
837            "Calibri",
838            // Monospace
839            "Consolas",
840            "Courier New",
841        ],
842        OperatingSystem::IOS => &[
843            // System UI fonts (filenames use SFNS/SFUI prefix)
844            "San Francisco",
845            "SFNS",
846            "SFNSDisplay",
847            "SFNSText",
848            "SFUI",
849            ".AppleSystemUIFont",
850            "System Font",
851            // Sans-serif
852            "Helvetica Neue",
853            "Helvetica",
854            "Avenir",
855            "Avenir Next",
856            // Serif
857            "Times New Roman",
858            "Georgia",
859            // Monospace
860            "Menlo",
861            "SF Mono",
862            "Courier",
863        ],
864        OperatingSystem::Android => &[
865            // System UI fonts
866            "Roboto",
867            "Roboto Flex",
868            "Roboto Condensed",
869            // Sans-serif
870            "Noto Sans",
871            "Droid Sans",
872            // Serif
873            "Noto Serif",
874            "Roboto Serif",
875            "Droid Serif",
876            // Monospace
877            "Roboto Mono",
878            "Droid Sans Mono",
879            "Noto Sans Mono",
880        ],
881        OperatingSystem::Wasm => &[],
882    }
883}
884
885/// Configuration for the font scanner: directories to search and families to prioritize.
886/// Injected by the embedder via `FcFontRegistry::new_with_config`.
887#[derive(Debug, Clone, PartialEq)]
888pub struct FcScanConfig {
889    /// Directories to scan recursively. Empty = scan nothing.
890    pub font_dirs: Vec<PathBuf>,
891    /// Human-readable family names whose files the scout parses first
892    /// (they become token sets via [`tokenize_lowercase`]).
893    pub priority_families: Vec<String>,
894}
895
896impl FcScanConfig {
897    /// Returns the default OS-specific scan configuration.
898    pub fn os_defaults(os: OperatingSystem) -> Self {
899        Self {
900            font_dirs: font_directories(os),
901            priority_families: FcFallbackConfig::os_defaults(os).extract_all_families(),
902        }
903    }
904    /// Returns an empty scan configuration.
905    pub fn empty() -> Self {
906        Self {
907            font_dirs: Vec::new(),
908            priority_families: Vec::new(),
909        }
910    }
911    /// Pre-tokenizes priority families for faster matching against filenames.
912    pub fn priority_token_sets(&self) -> Vec<Vec<String>> {
913        self.priority_families
914            .iter()
915            .map(|family| tokenize_lowercase(family))
916            .collect()
917    }
918}
919
920/// Pre-tokenize common font families for efficient per-file matching.
921/// Call this once before iterating over font files, then pass the result
922/// to [`matches_common_family_tokens`] for each file.
923pub fn tokenize_common_families(os: OperatingSystem) -> Vec<Vec<String>> {
924    FcScanConfig::os_defaults(os).priority_token_sets()
925}
926
927/// Check if a set of filename tokens matches any pre-tokenized common family.
928///
929/// Both sides are joined into a single normalized string (tokens concatenated),
930/// then checked for substring containment. This handles cases where the tokenizer
931/// produces different splits for the same underlying name (e.g. `"SFMono"` stays
932/// as one token from a filename, but `"SF Mono"` splits into `["sf", "mono"]`).
933pub fn matches_common_family_tokens(
934    file_tokens: &[String],
935    common_token_sets: &[Vec<String>],
936) -> bool {
937    let file_joined: String = file_tokens.concat();
938    common_token_sets.iter().any(|family_tokens| {
939        let family_joined: String = family_tokens.concat();
940        file_joined.contains(&family_joined)
941    })
942}
943
944/// Tokenize a name into lowercase tokens (no style filtering).
945///
946/// Useful for priority scoring where style tokens like "Bold" are still relevant.
947pub fn tokenize_lowercase(name: &str) -> Vec<String> {
948    FcFontCache::extract_font_name_tokens(name)
949        .into_iter()
950        .map(|t| t.to_lowercase())
951        .collect()
952}
953
954/// Extract non-style tokens from a font filename stem.
955///
956/// Tokenizes using CamelCase boundaries, hyphens, underscores, and spaces,
957/// then filters out style tokens (Bold, Italic, Regular, etc.).
958/// Returns lowercased tokens suitable for family name matching.
959///
960/// # Examples
961///
962/// - `"ArialBold"` → `["arial"]`
963/// - `"NotoSansJP-Regular"` → `["noto", "sans", "jp"]`
964/// - `"HelveticaNeue-BoldItalic"` → `["helvetica", "neue"]`
965pub fn tokenize_font_stem(stem: &str) -> Vec<String> {
966    tokenize_lowercase(stem)
967        .into_iter()
968        .filter(|t| !FONT_STYLE_TOKENS.iter().any(|s| s.eq_ignore_ascii_case(t)))
969        .collect()
970}
971
972/// Guess the font family name from a filename, using tokenization.
973///
974/// Extracts non-style tokens from the filename stem and joins them
975/// into a single normalized string (lowercase, no separators).
976///
977/// # Examples
978///
979/// - `"ArialBold.ttf"` → `"arial"`
980/// - `"NotoSansJP-Regular.otf"` → `"notosansjp"`
981/// - `"Helvetica Neue Bold Italic.ttf"` → `"helveticaneue"`
982pub fn guess_family_from_filename(path: &Path) -> String {
983    let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
984    tokenize_font_stem(stem).join("")
985}
986
987#[cfg(test)]
988#[path = "config_test.rs"]
989mod tests;