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"`), or `None` on wasm.
80#[cfg(not(target_arch = "wasm32"))]
81pub fn system_locale() -> Option<String> {
82    sys_locale::get_locale()
83}
84
85#[cfg(target_arch = "wasm32")]
86pub fn system_locale() -> Option<String> {
87    None
88}
89
90/// Maps a locale string (BCP-47 or POSIX style) to a [`FontRegion`].
91///
92/// ```
93/// use system_fonts::{region_from_locale, FontRegion};
94///
95/// assert_eq!(region_from_locale("ko-KR"), FontRegion::Korean);
96/// assert_eq!(region_from_locale("ko_KR.UTF-8"), FontRegion::Korean);
97/// assert_eq!(region_from_locale("zh-Hant-TW"), FontRegion::TraditionalChinese);
98/// assert_eq!(region_from_locale("zh_CN"), FontRegion::SimplifiedChinese);
99/// assert_eq!(region_from_locale("ru-RU"), FontRegion::Cyrillic);
100/// ```
101pub fn region_from_locale(locale: &str) -> FontRegion {
102    let mut s = locale.trim().to_ascii_lowercase().replace('_', "-");
103    if let Some((head, _)) = s.split_once('.') {
104        s = head.to_string();
105    }
106
107    if s.contains("-cyrl") {
108        return FontRegion::Cyrillic;
109    }
110    if s.contains("-latn") {
111        return FontRegion::Latin;
112    }
113
114    if s.starts_with("ko") {
115        return FontRegion::Korean;
116    }
117    if s.starts_with("ja") {
118        return FontRegion::Japanese;
119    }
120    if s.starts_with("zh") {
121        if s.contains("hant") || s.contains("-tw") || s.contains("-hk") || s.contains("-mo") {
122            return FontRegion::TraditionalChinese;
123        }
124        return FontRegion::SimplifiedChinese;
125    }
126
127    if s.starts_with("ru")
128        || s.starts_with("uk")
129        || s.starts_with("be")
130        || s.starts_with("bg")
131        || s.starts_with("mk")
132        || s.starts_with("sr")
133        || s.starts_with("kk")
134        || s.starts_with("ky")
135        || s.starts_with("tg")
136        || s.starts_with("mn")
137    {
138        return FontRegion::Cyrillic;
139    }
140
141    if s.starts_with("en") || s.starts_with("fr") || s.starts_with("de") {
142        return FontRegion::Latin;
143    }
144
145    FontRegion::Unknown
146}
147
148/// Returns the default preset priority list for a region (highest priority first).
149///
150/// ```
151/// use system_fonts::{presets_for_region, FontRegion, FontPreset};
152///
153/// let presets = presets_for_region(FontRegion::Korean);
154/// assert!(matches!(presets.first(), Some(FontPreset::Korean)));
155/// ```
156pub fn presets_for_region(region: FontRegion) -> Vec<FontPreset> {
157    match region {
158        FontRegion::Korean => vec![
159            FontPreset::Korean,
160            FontPreset::Japanese,
161            FontPreset::SimplifiedChinese,
162            FontPreset::TraditionalChinese,
163            FontPreset::Latin,
164        ],
165        FontRegion::Japanese => vec![
166            FontPreset::Japanese,
167            FontPreset::Korean,
168            FontPreset::SimplifiedChinese,
169            FontPreset::TraditionalChinese,
170            FontPreset::Latin,
171        ],
172        FontRegion::SimplifiedChinese => vec![
173            FontPreset::SimplifiedChinese,
174            FontPreset::TraditionalChinese,
175            FontPreset::Korean,
176            FontPreset::Japanese,
177            FontPreset::Latin,
178        ],
179        FontRegion::TraditionalChinese => vec![
180            FontPreset::TraditionalChinese,
181            FontPreset::SimplifiedChinese,
182            FontPreset::Korean,
183            FontPreset::Japanese,
184            FontPreset::Latin,
185        ],
186        FontRegion::Cyrillic => vec![
187            FontPreset::Cyrillic,
188            FontPreset::Latin,
189            FontPreset::Korean,
190            FontPreset::SimplifiedChinese,
191            FontPreset::TraditionalChinese,
192            FontPreset::Japanese,
193        ],
194        FontRegion::Latin | FontRegion::Unknown => vec![
195            FontPreset::Latin,
196            FontPreset::Cyrillic,
197            FontPreset::Korean,
198            FontPreset::SimplifiedChinese,
199            FontPreset::TraditionalChinese,
200            FontPreset::Japanese,
201        ],
202    }
203}
204
205/// Resolves installed system fonts from presets, ordered by priority.
206///
207/// On wasm, always returns an empty list.
208///
209/// ```no_run
210/// use system_fonts::{find_from_presets, FontPreset, FontStyle};
211///
212/// let fonts = find_from_presets([FontPreset::Korean, FontPreset::Latin], FontStyle::Sans);
213/// println!("fonts={}", fonts.len());
214/// ```
215#[cfg(not(target_arch = "wasm32"))]
216pub fn find_from_presets<I>(presets_in_priority: I, style: FontStyle) -> Vec<FoundFont>
217where
218    I: IntoIterator<Item = FontPreset>,
219{
220    let db = font_db();
221
222    let mut targets: Vec<String> = Vec::new();
223    for preset in presets_in_priority {
224        match style {
225            FontStyle::Serif => {
226                targets.extend(preset_targets_serif(&preset));
227                targets.extend(preset_targets_sans(&preset));
228            }
229            FontStyle::Sans => {
230                targets.extend(preset_targets_sans(&preset));
231            }
232        }
233    }
234
235    let mut seen_family = HashSet::<String>::new();
236    let mut out = Vec::<FoundFont>::new();
237
238    for (i, family_name) in targets.into_iter().enumerate() {
239        if !seen_family.insert(family_name.clone()) {
240            continue;
241        }
242
243        if let Some(found) = resolve_one_family(db, &family_name, i) {
244            out.push(found);
245        }
246    }
247
248    out
249}
250
251#[cfg(target_arch = "wasm32")]
252pub fn find_from_presets<I>(_presets_in_priority: I, _style: FontStyle) -> Vec<FoundFont>
253where
254    I: IntoIterator<Item = FontPreset>,
255{
256    vec![]
257}
258
259/// Resolves fonts for the given locale string. On wasm, returns an empty font list.
260///
261/// ```no_run
262/// use system_fonts::{find_for_locale, FontStyle};
263///
264/// let (region, fonts) = find_for_locale("ja-JP", FontStyle::Sans);
265/// println!("region={region:?}, fonts={}", fonts.len());
266/// ```
267#[cfg(not(target_arch = "wasm32"))]
268pub fn find_for_locale(locale: &str, style: FontStyle) -> (FontRegion, Vec<FoundFont>) {
269    let region = region_from_locale(locale);
270    let presets = presets_for_region(region);
271    (region, find_from_presets(presets, style))
272}
273
274#[cfg(target_arch = "wasm32")]
275pub fn find_for_locale(locale: &str, _style: FontStyle) -> (FontRegion, Vec<FoundFont>) {
276    (region_from_locale(locale), vec![])
277}
278
279/// Resolves fonts for the current system locale. On wasm, returns an empty font list.
280///
281/// ```no_run
282/// use system_fonts::{find_for_system_locale, FontStyle};
283///
284/// let (_loc, region, fonts) = find_for_system_locale(FontStyle::Sans);
285/// println!("region={region:?}, fonts={}", fonts.len());
286/// ```
287#[cfg(not(target_arch = "wasm32"))]
288pub fn find_for_system_locale(style: FontStyle) -> (Option<String>, FontRegion, Vec<FoundFont>) {
289    let locale = system_locale();
290    let (region, fonts) = match locale.as_deref() {
291        Some(loc) if !loc.trim().is_empty() => find_for_locale(loc, style),
292        _ => {
293            let fallback = "en-US";
294            find_for_locale(fallback, style)
295        }
296    };
297    (locale, region, fonts)
298}
299
300#[cfg(target_arch = "wasm32")]
301pub fn find_for_system_locale(_style: FontStyle) -> (Option<String>, FontRegion, Vec<FoundFont>) {
302    (None, FontRegion::Unknown, vec![])
303}
304
305#[cfg(not(target_arch = "wasm32"))]
306static FONT_DB: OnceLock<Database> = OnceLock::new();
307
308#[cfg(not(target_arch = "wasm32"))]
309fn font_db() -> &'static Database {
310    FONT_DB.get_or_init(|| {
311        let mut db = Database::new();
312        db.load_system_fonts();
313        db
314    })
315}
316
317#[cfg(not(target_arch = "wasm32"))]
318fn resolve_one_family(db: &Database, family_name: &str, uniq: usize) -> Option<FoundFont> {
319    let families = [Family::Name(family_name)];
320    let query = Query {
321        families: &families,
322        ..Default::default()
323    };
324
325    let id = db.query(&query)?;
326    let face = db.face(id)?;
327
328    let source = match &face.source {
329        Source::File(path) => FoundFontSource::Path(path.to_path_buf()),
330        Source::Binary(bytes) => {
331            let v: Vec<u8> = bytes.as_ref().as_ref().to_vec();
332            FoundFontSource::Bytes(Arc::from(v.into_boxed_slice()))
333        }
334        _ => return None,
335    };
336
337    let key = format!("system:{}:{}", family_name, uniq);
338
339    Some(FoundFont {
340        family: family_name.to_string(),
341        key,
342        source,
343    })
344}
345
346#[cfg(not(target_arch = "wasm32"))]
347fn preset_targets_sans(p: &FontPreset) -> Vec<String> {
348    match p {
349        FontPreset::Latin => vec![
350            "Noto Sans".into(),
351            "Segoe UI".into(),
352            "Arial".into(),
353            "SF Pro Text".into(),
354            "Helvetica Neue".into(),
355            "DejaVu Sans".into(),
356            "Liberation Sans".into(),
357            "Roboto".into(),
358        ],
359        FontPreset::Korean => vec![
360            "Noto Sans KR".into(),
361            "Noto Sans CJK KR".into(),
362            "Malgun Gothic".into(),
363            "Apple SD Gothic Neo".into(),
364            "NanumGothic".into(),
365        ],
366        FontPreset::SimplifiedChinese => vec![
367            "Noto Sans SC".into(),
368            "Noto Sans CJK SC".into(),
369            "Microsoft YaHei".into(),
370            "PingFang SC".into(),
371            "SimHei".into(),
372            "SimSun".into(),
373        ],
374        FontPreset::TraditionalChinese => vec![
375            "Noto Sans TC".into(),
376            "Noto Sans CJK TC".into(),
377            "Microsoft JhengHei".into(),
378            "PingFang TC".into(),
379        ],
380        FontPreset::Japanese => vec![
381            "Noto Sans JP".into(),
382            "Noto Sans CJK JP".into(),
383            "Yu Gothic".into(),
384            "Hiragino Sans".into(),
385            "Meiryo".into(),
386        ],
387        FontPreset::Cyrillic => vec![
388            "Noto Sans".into(),
389            "DejaVu Sans".into(),
390            "Segoe UI".into(),
391            "Arial".into(),
392            "Tahoma".into(),
393            "Times New Roman".into(),
394        ],
395        FontPreset::Custom(list) => list.clone(),
396    }
397}
398
399#[cfg(not(target_arch = "wasm32"))]
400fn preset_targets_serif(p: &FontPreset) -> Vec<String> {
401    match p {
402        FontPreset::Latin => vec![
403            "Noto Serif".into(),
404            "Times New Roman".into(),
405            "Georgia".into(),
406            "Liberation Serif".into(),
407            "DejaVu Serif".into(),
408            "Times".into(),
409        ],
410        FontPreset::Korean => vec![
411            "Noto Serif KR".into(),
412            "Noto Serif CJK KR".into(),
413            "Batang".into(),
414            "AppleMyungjo".into(),
415            "NanumMyeongjo".into(),
416        ],
417        FontPreset::SimplifiedChinese => vec![
418            "Noto Serif SC".into(),
419            "Noto Serif CJK SC".into(),
420            "Songti SC".into(),
421            "SimSun".into(),
422        ],
423        FontPreset::TraditionalChinese => vec![
424            "Noto Serif TC".into(),
425            "Noto Serif CJK TC".into(),
426            "Songti TC".into(),
427            "PMingLiU".into(),
428        ],
429        FontPreset::Japanese => vec![
430            "Noto Serif JP".into(),
431            "Noto Serif CJK JP".into(),
432            "Yu Mincho".into(),
433            "Hiragino Mincho ProN".into(),
434            "MS Mincho".into(),
435        ],
436        FontPreset::Cyrillic => vec![
437            "Noto Serif".into(),
438            "Times New Roman".into(),
439            "Georgia".into(),
440            "Liberation Serif".into(),
441            "DejaVu Serif".into(),
442        ],
443        FontPreset::Custom(list) => list.clone(),
444    }
445}