Skip to main content

system_fonts/
lib.rs

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