Skip to main content

system_fonts/
lib.rs

1//! System font discovery and locale-based preset selection.
2//!
3//! This crate provides a small API for:
4//! - Detecting the current system locale
5//! - Mapping a locale to a `FontRegion`
6//! - Resolving a prioritized list of installed system fonts using `fontdb`
7//!
8//! The output (`FoundFont`) is designed to be consumed by UI toolkits (e.g. `egui_system_fonts`),
9//! where the caller loads the font bytes and registers them into the UI font system.
10//!
11//! # Quick start
12//!
13//! Resolve fonts from the current system locale:
14//!
15//! ```no_run
16//! use system_fonts::{find_for_system_locale, FontStyle};
17//!
18//! let (_locale, region, fonts) = find_for_system_locale(FontStyle::Sans);
19//! println!("region={region:?}, fonts={}", fonts.len());
20//! ```
21//!
22//! Force a locale or region policy manually:
23//!
24//! ```no_run
25//! use system_fonts::{find_for_locale, FontStyle};
26//!
27//! let (region, fonts) = find_for_locale("ko-KR", FontStyle::Sans);
28//! println!("region={region:?}, fonts={}", fonts.len());
29//! ```
30//!
31//! # Notes
32//! - Font resolution is best-effort: unknown families are skipped.
33//! - The internal font database is cached (loaded once per process).
34//! - `FoundFont::key` is unique within a single run; do not persist it across runs.
35use std::collections::HashSet;
36use std::path::PathBuf;
37use std::sync::{Arc, OnceLock};
38
39use fontdb::{Database, Family, Query, Source};
40
41/// Font preference used when selecting system fonts.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum FontStyle {
44    Sans,
45    Serif,
46}
47
48/// Writing system/locale region used to decide fallback priority.
49#[non_exhaustive]
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum FontRegion {
52    Korean,
53    Japanese,
54    SimplifiedChinese,
55    TraditionalChinese,
56    Cyrillic,
57    Latin,
58    Unknown,
59}
60
61/// A preset represents a prioritized group of candidate font families.
62#[non_exhaustive]
63#[derive(Clone, Debug)]
64pub enum FontPreset {
65    Latin,
66    Korean,
67    SimplifiedChinese,
68    TraditionalChinese,
69    Japanese,
70    Cyrillic,
71    /// Custom font family names, in priority order.
72    Custom(Vec<String>),
73}
74
75/// A resolved system font entry usable by UI code.
76///
77/// `family` is the human-readable family name used for lookup.
78/// `key` is a unique identifier suitable as a UI font key within the current process.
79/// It is not guaranteed to be stable across machines or across runs.
80#[derive(Clone, Debug)]
81pub struct FoundFont {
82    pub family: String,
83    pub key: String,
84    pub source: FoundFontSource,
85}
86
87/// Font bytes source resolved from the system font database.
88///
89/// `Path` points to an on-disk font file.
90/// `Bytes` contains the font data copied into memory (can be large).
91#[derive(Clone, Debug)]
92pub enum FoundFontSource {
93    Path(PathBuf),
94    Bytes(Arc<[u8]>),
95}
96
97/// Returns the current system locale string (e.g. "ko-KR", "en-US") if available.
98///
99/// # Examples
100///
101/// ```
102/// let loc = system_fonts::system_locale();
103/// // May be None depending on platform/environment.
104/// println!("{loc:?}");
105/// ```
106pub fn system_locale() -> Option<String> {
107    sys_locale::get_locale()
108}
109
110/// Converts a locale string into a `FontRegion`.
111///
112/// Supports common BCP-47 tags as well as "ko_KR.UTF-8"-style strings.
113///
114/// # Examples
115///
116/// ```
117/// use system_fonts::{region_from_locale, FontRegion};
118///
119/// assert_eq!(region_from_locale("ko-KR"), FontRegion::Korean);
120/// assert_eq!(region_from_locale("ko_KR.UTF-8"), FontRegion::Korean);
121/// assert_eq!(region_from_locale("zh-Hant-TW"), FontRegion::TraditionalChinese);
122/// assert_eq!(region_from_locale("zh_CN"), FontRegion::SimplifiedChinese);
123/// assert_eq!(region_from_locale("ru-RU"), FontRegion::Cyrillic);
124/// ```
125pub fn region_from_locale(locale: &str) -> FontRegion {
126    let mut s = locale.trim().to_ascii_lowercase().replace('_', "-");
127    if let Some((head, _)) = s.split_once('.') {
128        s = head.to_string();
129    }
130
131    if s.contains("-cyrl") {
132        return FontRegion::Cyrillic;
133    }
134    if s.contains("-latn") {
135        return FontRegion::Latin;
136    }
137
138    if s.starts_with("ko") {
139        return FontRegion::Korean;
140    }
141    if s.starts_with("ja") {
142        return FontRegion::Japanese;
143    }
144    if s.starts_with("zh") {
145        if s.contains("hant") || s.contains("-tw") || s.contains("-hk") || s.contains("-mo") {
146            return FontRegion::TraditionalChinese;
147        }
148        return FontRegion::SimplifiedChinese;
149    }
150
151    if s.starts_with("ru")
152        || s.starts_with("uk")
153        || s.starts_with("be")
154        || s.starts_with("bg")
155        || s.starts_with("mk")
156        || s.starts_with("sr")
157        || s.starts_with("kk")
158        || s.starts_with("ky")
159        || s.starts_with("tg")
160        || s.starts_with("mn")
161    {
162        return FontRegion::Cyrillic;
163    }
164
165    if s.starts_with("en") || s.starts_with("fr") || s.starts_with("de") {
166        return FontRegion::Latin;
167    }
168
169    FontRegion::Unknown
170}
171
172/// Returns the default preset priority list for a given region.
173///
174/// The returned presets are ordered from highest to lowest priority.
175///
176/// # Examples
177///
178/// ```
179/// use system_fonts::{presets_for_region, FontRegion, FontPreset};
180///
181/// let presets = presets_for_region(FontRegion::Korean);
182/// assert!(matches!(presets.first(), Some(FontPreset::Korean)));
183/// ```
184pub fn presets_for_region(region: FontRegion) -> Vec<FontPreset> {
185    match region {
186        FontRegion::Korean => vec![
187            FontPreset::Korean,
188            FontPreset::Japanese,
189            FontPreset::SimplifiedChinese,
190            FontPreset::TraditionalChinese,
191            FontPreset::Latin,
192        ],
193        FontRegion::Japanese => vec![
194            FontPreset::Japanese,
195            FontPreset::Korean,
196            FontPreset::SimplifiedChinese,
197            FontPreset::TraditionalChinese,
198            FontPreset::Latin,
199        ],
200        FontRegion::SimplifiedChinese => vec![
201            FontPreset::SimplifiedChinese,
202            FontPreset::TraditionalChinese,
203            FontPreset::Korean,
204            FontPreset::Japanese,
205            FontPreset::Latin,
206        ],
207        FontRegion::TraditionalChinese => vec![
208            FontPreset::TraditionalChinese,
209            FontPreset::SimplifiedChinese,
210            FontPreset::Korean,
211            FontPreset::Japanese,
212            FontPreset::Latin,
213        ],
214        FontRegion::Cyrillic => vec![
215            FontPreset::Cyrillic,
216            FontPreset::Latin,
217            FontPreset::Korean,
218            FontPreset::SimplifiedChinese,
219            FontPreset::TraditionalChinese,
220            FontPreset::Japanese,
221        ],
222        FontRegion::Latin | FontRegion::Unknown => vec![
223            FontPreset::Latin,
224            FontPreset::Cyrillic,
225            FontPreset::Korean,
226            FontPreset::SimplifiedChinese,
227            FontPreset::TraditionalChinese,
228            FontPreset::Japanese,
229        ],
230    }
231}
232
233/// Resolves system fonts from presets and style.
234///
235/// The returned list is ordered by priority (highest to lowest).
236/// For `FontStyle::Serif`, serif candidates are tried first, then sans candidates are appended
237/// as a fallback.
238///
239/// Notes:
240/// - Duplicate families are removed (first match wins).
241/// - Families not installed on the system are skipped.
242///
243/// # Examples
244///
245/// ```no_run
246/// use system_fonts::{find_from_presets, FontPreset, FontStyle};
247///
248/// let presets = [FontPreset::Korean, FontPreset::Latin];
249/// let fonts = find_from_presets(presets, FontStyle::Sans);
250/// println!("fonts={}", fonts.len());
251/// ```
252pub fn find_from_presets<I>(presets_in_priority: I, style: FontStyle) -> Vec<FoundFont>
253where
254    I: IntoIterator<Item = FontPreset>,
255{
256    let db = font_db();
257
258    let mut targets: Vec<String> = Vec::new();
259    for preset in presets_in_priority {
260        match style {
261            FontStyle::Serif => {
262                targets.extend(preset_targets_serif(&preset));
263                targets.extend(preset_targets_sans(&preset));
264            }
265            FontStyle::Sans => {
266                targets.extend(preset_targets_sans(&preset));
267            }
268        }
269    }
270
271    let mut seen_family = HashSet::<String>::new();
272    let mut out = Vec::<FoundFont>::new();
273
274    for (i, family_name) in targets.into_iter().enumerate() {
275        if !seen_family.insert(family_name.clone()) {
276            continue;
277        }
278
279        if let Some(found) = resolve_one_family(db, &family_name, i) {
280            out.push(found);
281        }
282    }
283
284    out
285}
286
287/// Detects a region from the given locale string, then resolves fonts using that region's presets.
288///
289/// # Examples
290///
291/// ```no_run
292/// use system_fonts::{find_for_locale, FontStyle};
293///
294/// let (region, fonts) = find_for_locale("ja-JP", FontStyle::Sans);
295/// println!("region={region:?}, fonts={}", fonts.len());
296/// ```
297pub fn find_for_locale(locale: &str, style: FontStyle) -> (FontRegion, Vec<FoundFont>) {
298    let region = region_from_locale(locale);
299    let presets = presets_for_region(region);
300    (region, find_from_presets(presets, style))
301}
302
303/// Resolves fonts based on the current system locale.
304///
305/// If locale is unavailable, falls back to "en-US".
306///
307/// # Examples
308///
309/// ```no_run
310/// use system_fonts::{find_for_system_locale, FontStyle};
311///
312/// let (_loc, region, fonts) = find_for_system_locale(FontStyle::Sans);
313/// println!("region={region:?}, fonts={}", fonts.len());
314/// ```
315pub fn find_for_system_locale(style: FontStyle) -> (Option<String>, FontRegion, Vec<FoundFont>) {
316    let locale = system_locale();
317    let (region, fonts) = match locale.as_deref() {
318        Some(loc) if !loc.trim().is_empty() => find_for_locale(loc, style),
319        _ => {
320            let fallback = "en-US";
321            find_for_locale(fallback, style)
322        }
323    };
324    (locale, region, fonts)
325}
326
327static FONT_DB: OnceLock<Database> = OnceLock::new();
328
329fn font_db() -> &'static Database {
330    FONT_DB.get_or_init(|| {
331        let mut db = Database::new();
332        db.load_system_fonts();
333        db
334    })
335}
336
337fn resolve_one_family(db: &Database, family_name: &str, uniq: usize) -> Option<FoundFont> {
338    let families = [Family::Name(family_name)];
339    let query = Query {
340        families: &families,
341        ..Default::default()
342    };
343
344    let id = db.query(&query)?;
345    let face = db.face(id)?;
346
347    let source = match &face.source {
348        Source::File(path) => FoundFontSource::Path(path.to_path_buf()),
349        Source::Binary(bytes) => {
350            let v: Vec<u8> = bytes.as_ref().as_ref().to_vec();
351            FoundFontSource::Bytes(Arc::from(v.into_boxed_slice()))
352        }
353        _ => return None,
354    };
355
356    let key = format!("system:{}:{}", family_name, uniq);
357
358    Some(FoundFont {
359        family: family_name.to_string(),
360        key,
361        source,
362    })
363}
364
365fn preset_targets_sans(p: &FontPreset) -> Vec<String> {
366    match p {
367        FontPreset::Latin => vec![
368            "Noto Sans".into(),
369            "Segoe UI".into(),
370            "Arial".into(),
371            "SF Pro Text".into(),
372            "Helvetica Neue".into(),
373            "DejaVu Sans".into(),
374            "Liberation Sans".into(),
375            "Roboto".into(),
376        ],
377        FontPreset::Korean => vec![
378            "Noto Sans KR".into(),
379            "Noto Sans CJK KR".into(),
380            "Malgun Gothic".into(),
381            "Apple SD Gothic Neo".into(),
382            "NanumGothic".into(),
383        ],
384        FontPreset::SimplifiedChinese => vec![
385            "Noto Sans SC".into(),
386            "Noto Sans CJK SC".into(),
387            "Microsoft YaHei".into(),
388            "PingFang SC".into(),
389            "SimHei".into(),
390            "SimSun".into(),
391        ],
392        FontPreset::TraditionalChinese => vec![
393            "Noto Sans TC".into(),
394            "Noto Sans CJK TC".into(),
395            "Microsoft JhengHei".into(),
396            "PingFang TC".into(),
397        ],
398        FontPreset::Japanese => vec![
399            "Noto Sans JP".into(),
400            "Noto Sans CJK JP".into(),
401            "Yu Gothic".into(),
402            "Hiragino Sans".into(),
403            "Meiryo".into(),
404        ],
405        FontPreset::Cyrillic => vec![
406            "Noto Sans".into(),
407            "DejaVu Sans".into(),
408            "Segoe UI".into(),
409            "Arial".into(),
410            "Tahoma".into(),
411            "Times New Roman".into(),
412        ],
413        FontPreset::Custom(list) => list.clone(),
414    }
415}
416
417fn preset_targets_serif(p: &FontPreset) -> Vec<String> {
418    match p {
419        FontPreset::Latin => vec![
420            "Noto Serif".into(),
421            "Times New Roman".into(),
422            "Georgia".into(),
423            "Liberation Serif".into(),
424            "DejaVu Serif".into(),
425            "Times".into(),
426        ],
427        FontPreset::Korean => vec![
428            "Noto Serif KR".into(),
429            "Noto Serif CJK KR".into(),
430            "Batang".into(),
431            "AppleMyungjo".into(),
432            "NanumMyeongjo".into(),
433        ],
434        FontPreset::SimplifiedChinese => vec![
435            "Noto Serif SC".into(),
436            "Noto Serif CJK SC".into(),
437            "Songti SC".into(),
438            "SimSun".into(),
439        ],
440        FontPreset::TraditionalChinese => vec![
441            "Noto Serif TC".into(),
442            "Noto Serif CJK TC".into(),
443            "Songti TC".into(),
444            "PMingLiU".into(),
445        ],
446        FontPreset::Japanese => vec![
447            "Noto Serif JP".into(),
448            "Noto Serif CJK JP".into(),
449            "Yu Mincho".into(),
450            "Hiragino Mincho ProN".into(),
451            "MS Mincho".into(),
452        ],
453        FontPreset::Cyrillic => vec![
454            "Noto Serif".into(),
455            "Times New Roman".into(),
456            "Georgia".into(),
457            "Liberation Serif".into(),
458            "DejaVu Serif".into(),
459        ],
460        FontPreset::Custom(list) => list.clone(),
461    }
462}