Skip to main content

corundum_framework_gui/
fonts.rs

1//! A CJK fallback font from the operating system, so apps ship no font assets.
2//!
3//! egui's built-in fonts have no CJK glyphs. Rather than bundling a 10–20 MB font, this
4//! finds one the OS already has — preferring families with Japanese glyph forms — and
5//! registers it behind egui's defaults, so Latin text keeps egui's fonts and CJK falls
6//! through to the system font. Nothing is redistributed: the font is read at runtime.
7
8use std::path::PathBuf;
9use std::sync::Arc;
10
11/// Families tried in order, Japanese glyph forms first.
12#[cfg(target_os = "windows")]
13pub const PREFERRED_FAMILIES: &[&str] = &[
14    "Yu Gothic UI",
15    "Yu Gothic",
16    "Meiryo UI",
17    "Meiryo",
18    "MS Gothic",
19];
20/// Families tried in order, Japanese glyph forms first.
21#[cfg(any(target_os = "macos", target_os = "ios"))]
22pub const PREFERRED_FAMILIES: &[&str] = &[
23    "Hiragino Sans",
24    "Hiragino Kaku Gothic ProN",
25    "Hiragino Kaku Gothic Pro",
26];
27/// Families tried in order, Japanese glyph forms first. Older Android had Droid Sans
28/// Japanese, or only Droid Sans Fallback.
29#[cfg(target_os = "android")]
30pub const PREFERRED_FAMILIES: &[&str] = &[
31    "Noto Sans CJK JP",
32    "Noto Sans JP",
33    "Droid Sans Japanese",
34    "Droid Sans Fallback",
35];
36/// Families tried in order, Japanese glyph forms first.
37#[cfg(not(any(
38    target_os = "windows",
39    target_os = "macos",
40    target_os = "ios",
41    target_os = "android"
42)))]
43pub const PREFERRED_FAMILIES: &[&str] = &[
44    "Noto Sans CJK JP",
45    "Noto Sans JP",
46    "Source Han Sans JP",
47    "IPAexGothic",
48    "IPAGothic",
49    "Takao Gothic",
50    "VL Gothic",
51];
52
53/// Glyphs a usable fallback must have.
54const PROBE: [char; 2] = ['あ', '漢'];
55
56/// The key the font is registered under in egui's font definitions.
57const FONT_KEY: &str = "system-cjk";
58
59/// A font file found on this system.
60#[derive(Clone)]
61pub struct SystemFont {
62    /// Its family name.
63    pub family: String,
64    /// The whole font file (a collection keeps all its faces).
65    pub data: Vec<u8>,
66    /// Which face of the file to use.
67    pub index: u32,
68    /// Its CSS-style weight (400 is regular).
69    pub weight: u16,
70}
71
72impl std::fmt::Debug for SystemFont {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("SystemFont")
75            .field("family", &self.family)
76            .field("bytes", &self.data.len())
77            .field("index", &self.index)
78            .field("weight", &self.weight)
79            .finish()
80    }
81}
82
83/// One face, reduced to what [`pick`] decides on.
84struct Candidate {
85    id_index: usize,
86    families: Vec<String>,
87    covers_cjk: bool,
88    /// How far the face is from plain regular: |weight - 400|, plus large penalties for a
89    /// non-normal style or stretch. Smaller is better.
90    regularity: u32,
91}
92
93/// Distance of a face from regular weight, style and stretch.
94fn regularity(face: &fontdb::FaceInfo) -> u32 {
95    let mut d = u32::from(face.weight.0.abs_diff(400));
96    if face.style != fontdb::Style::Normal {
97        d += 10_000;
98    }
99    if face.stretch != fontdb::Stretch::Normal {
100        d += 10_000;
101    }
102    d
103}
104
105/// The most regular covering face of the first preferred family (in preference order) that
106/// has one (ties go to database order); failing that, the most regular covering face of
107/// any family.
108fn pick<'a>(faces: &'a [Candidate], preferred: &[&str]) -> Option<&'a Candidate> {
109    // `min_by_key` returns the first of equal minima, which is database order.
110    preferred
111        .iter()
112        .find_map(|want| {
113            faces
114                .iter()
115                .filter(|c| c.covers_cjk && c.families.iter().any(|f| f.eq_ignore_ascii_case(want)))
116                .min_by_key(|c| c.regularity)
117        })
118        .or_else(|| {
119            faces
120                .iter()
121                .filter(|c| c.covers_cjk)
122                .min_by_key(|c| c.regularity)
123        })
124}
125
126/// Find a CJK-capable system font, or `None` when this system has none.
127///
128/// This reads every system font directory once (on Linux, the ones fontconfig's
129/// configuration names), which can take a second or more when the file cache is cold, and
130/// it blocks the caller: call it once at startup. The chosen font file, possibly tens of
131/// megabytes, is held in memory.
132#[cfg(not(target_os = "ios"))]
133pub fn system_cjk_font() -> Option<SystemFont> {
134    let mut db = fontdb::Database::new();
135    db.load_system_fonts();
136    // fontdb knows no Android font directory. The system's fonts, Noto CJK among them, are
137    // in the first; a device maker's own are in the second.
138    #[cfg(target_os = "android")]
139    for dir in ["/system/fonts", "/product/fonts"] {
140        db.load_fonts_dir(dir);
141    }
142    choose(&db)
143}
144
145/// Find a CJK-capable system font, or `None` when this system has none.
146///
147/// iOS's sandbox refuses to list the system font directories, so the files come from
148/// CoreText. The preferred families' files are read first, and every other font only when
149/// none of them covers CJK. Blocks the caller: call it once at startup. The chosen font
150/// file, possibly tens of megabytes, is held in memory.
151#[cfg(target_os = "ios")]
152pub fn system_cjk_font() -> Option<SystemFont> {
153    let (preferred, rest) = split_preferred(&apple::font_files(), PREFERRED_FAMILIES);
154    let mut db = fontdb::Database::new();
155    for path in &preferred {
156        load_file(&mut db, path);
157    }
158    if let Some(font) = choose(&db) {
159        return Some(font);
160    }
161    for path in &rest {
162        load_file(&mut db, path);
163    }
164    choose(&db)
165}
166
167#[cfg(target_os = "ios")]
168fn load_file(db: &mut fontdb::Database, path: &std::path::Path) {
169    if let Err(e) = db.load_font_file(path) {
170        tracing::debug!(path = %path.display(), error = %e, "could not read a system font");
171    }
172}
173
174/// The font files of `files` (family, path), split into those holding a `preferred` family
175/// and the rest. Each file appears once, in its first position.
176#[cfg_attr(not(any(target_os = "ios", test)), allow(dead_code))]
177fn split_preferred(
178    files: &[(String, PathBuf)],
179    preferred: &[&str],
180) -> (Vec<PathBuf>, Vec<PathBuf>) {
181    let is_preferred = |path: &PathBuf| {
182        files.iter().any(|(family, p)| {
183            p == path
184                && preferred
185                    .iter()
186                    .any(|want| want.eq_ignore_ascii_case(family))
187        })
188    };
189    let mut seen = std::collections::HashSet::new();
190    files
191        .iter()
192        .map(|(_, path)| path)
193        .filter(|path| seen.insert(*path))
194        .cloned()
195        .partition(is_preferred)
196}
197
198/// The CJK-capable face to use among `db`'s fonts.
199fn choose(db: &fontdb::Database) -> Option<SystemFont> {
200    let faces: Vec<&fontdb::FaceInfo> = db.faces().collect();
201
202    let candidate = |(i, f): (usize, &&fontdb::FaceInfo)| Candidate {
203        id_index: i,
204        families: f.families.iter().map(|(n, _)| n.clone()).collect(),
205        covers_cjk: db.with_face_data(f.id, covers_cjk).unwrap_or(false),
206        regularity: regularity(f),
207    };
208    let is_preferred = |f: &fontdb::FaceInfo| {
209        f.families.iter().any(|(name, _)| {
210            PREFERRED_FAMILIES
211                .iter()
212                .any(|p| p.eq_ignore_ascii_case(name))
213        })
214    };
215    // Coverage is checked for the preferred families' faces first, and for the whole
216    // database only when none of them covers CJK.
217    let preferred: Vec<Candidate> = faces
218        .iter()
219        .enumerate()
220        .filter(|(_, f)| is_preferred(f))
221        .map(candidate)
222        .collect();
223    let chosen_index = match pick(&preferred, PREFERRED_FAMILIES) {
224        Some(c) => c.id_index,
225        None => {
226            let all: Vec<Candidate> = faces.iter().enumerate().map(candidate).collect();
227            pick(&all, &[])?.id_index
228        }
229    };
230
231    let chosen = faces[chosen_index];
232    let family = chosen
233        .families
234        .first()
235        .map(|(n, _)| n.clone())
236        .unwrap_or_default();
237    let weight = chosen.weight.0;
238    db.with_face_data(chosen.id, |data, index| SystemFont {
239        family,
240        data: data.to_vec(),
241        index,
242        weight,
243    })
244}
245
246/// Whether face `index` of `data` has every [`PROBE`] glyph.
247fn covers_cjk(data: &[u8], index: u32) -> bool {
248    ttf_parser::Face::parse(data, index)
249        .map(|face| PROBE.iter().all(|c| face.glyph_index(*c).is_some()))
250        .unwrap_or(false)
251}
252
253/// The system fonts as CoreText reports them.
254#[cfg(target_os = "ios")]
255mod apple {
256    use std::path::PathBuf;
257
258    use objc2_core_foundation::{CFArray, CFRetained, CFString, CFURL};
259    use objc2_core_text::{
260        CTFontCollection, CTFontDescriptor, kCTFontFamilyNameAttribute, kCTFontURLAttribute,
261    };
262
263    /// Every face available to the app, as (family, font file). A collection file appears
264    /// once per face; faces with no file (made in memory) are left out.
265    pub(super) fn font_files() -> Vec<(String, PathBuf)> {
266        // SAFETY: no options are passed, and CoreText documents the result as an array of
267        // font descriptors.
268        let descriptors = unsafe {
269            CTFontCollection::from_available_fonts(None)
270                .matching_font_descriptors()
271                .map(|a| CFRetained::cast_unchecked::<CFArray<CTFontDescriptor>>(a))
272        };
273        let Some(descriptors) = descriptors else {
274            return Vec::new();
275        };
276        descriptors
277            .iter()
278            .filter_map(|d| {
279                // SAFETY: the attribute keys are CoreText's own constants.
280                let (family, url) = unsafe {
281                    (
282                        d.attribute(kCTFontFamilyNameAttribute)?,
283                        d.attribute(kCTFontURLAttribute)?,
284                    )
285                };
286                let family = family.downcast::<CFString>().ok()?.to_string();
287                let path = url.downcast::<CFURL>().ok()?.to_file_path()?;
288                Some((family, path))
289            })
290            .collect()
291    }
292}
293
294/// Append a system CJK font to `fonts` as a fallback behind everything already in the
295/// proportional and monospace families, keeping whatever else `fonts` holds. Use this to
296/// merge into your own font definitions. Returns the family used, or `None` (logged) when
297/// the system has none. See [`system_cjk_font`] for the cost of the lookup.
298pub fn add_system_cjk_fallback(fonts: &mut egui::FontDefinitions) -> Option<String> {
299    let Some(font) = system_cjk_font() else {
300        tracing::warn!("no CJK-capable system font found; CJK text will not render");
301        return None;
302    };
303    let mut data = egui::FontData::from_owned(font.data);
304    data.index = font.index;
305    fonts.font_data.insert(FONT_KEY.to_owned(), Arc::new(data));
306    for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
307        fonts
308            .families
309            .entry(family)
310            .or_default()
311            .push(FONT_KEY.to_owned());
312    }
313    tracing::info!(family = %font.family, "using a system CJK font");
314    Some(font.family)
315}
316
317/// Install a system CJK font as a fallback behind egui's default fonts. This starts from
318/// egui's defaults and replaces the context's whole font set, so call it before (or
319/// instead of) your own font setup; to merge into your own definitions use
320/// [`add_system_cjk_fallback`]. Returns the family used, or `None` (logged) when the system
321/// has none — CJK text then renders as boxes, nothing fails.
322pub fn install_system_cjk_fallback(ctx: &egui::Context) -> Option<String> {
323    let mut fonts = egui::FontDefinitions::default();
324    let family = add_system_cjk_fallback(&mut fonts)?;
325    ctx.set_fonts(fonts);
326    Some(family)
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use std::path::PathBuf;
333
334    fn cand(i: usize, fam: &str, covers: bool) -> Candidate {
335        Candidate {
336            id_index: i,
337            families: vec![fam.to_owned()],
338            covers_cjk: covers,
339            regularity: 0,
340        }
341    }
342
343    #[test]
344    fn the_earliest_preferred_family_wins_regardless_of_database_order() {
345        let faces = vec![cand(0, "Meiryo", true), cand(1, "Yu Gothic UI", true)];
346        let got = pick(&faces, &["Yu Gothic UI", "Meiryo"]).unwrap();
347        assert_eq!(got.id_index, 1);
348    }
349
350    #[test]
351    fn family_names_match_case_insensitively() {
352        let faces = vec![cand(0, "hiragino sans", true)];
353        assert!(pick(&faces, &["Hiragino Sans"]).is_some());
354    }
355
356    #[test]
357    fn a_preferred_family_without_cjk_glyphs_is_skipped() {
358        let faces = vec![cand(0, "Yu Gothic UI", false), cand(1, "Meiryo", true)];
359        assert_eq!(
360            pick(&faces, &["Yu Gothic UI", "Meiryo"]).unwrap().id_index,
361            1
362        );
363    }
364
365    #[test]
366    fn with_no_preferred_family_any_cjk_face_is_the_fallback() {
367        let faces = vec![
368            cand(0, "DejaVu Sans", false),
369            cand(1, "Some CJK Font", true),
370        ];
371        assert_eq!(pick(&faces, &["Noto Sans CJK JP"]).unwrap().id_index, 1);
372    }
373
374    #[test]
375    fn nothing_covering_cjk_is_none() {
376        let faces = vec![cand(0, "DejaVu Sans", false)];
377        assert!(pick(&faces, &["Noto Sans CJK JP"]).is_none());
378    }
379
380    /// Every supported desktop OS ships at least one of its preferred families; Windows CI
381    /// always has Yu Gothic or Meiryo. macOS/Linux are checked by hand (Task 13).
382    #[cfg(windows)]
383    #[test]
384    fn windows_finds_a_system_cjk_font() {
385        let font = system_cjk_font().expect("Windows ships Yu Gothic / Meiryo");
386        assert!(!font.data.is_empty());
387        assert!(
388            (300..=500).contains(&font.weight),
389            "picked a non-regular face: {font:?}"
390        );
391    }
392
393    #[cfg(windows)]
394    #[test]
395    fn add_keeps_existing_fonts_and_appends_the_fallback_last() {
396        let mut fonts = egui::FontDefinitions::default();
397        fonts.font_data.insert(
398            "mine".to_owned(),
399            Arc::new(egui::FontData::from_static(&[0])),
400        );
401        fonts
402            .families
403            .get_mut(&egui::FontFamily::Proportional)
404            .unwrap()
405            .insert(0, "mine".to_owned());
406        assert!(add_system_cjk_fallback(&mut fonts).is_some());
407        assert!(fonts.font_data.contains_key("mine"));
408        let prop = &fonts.families[&egui::FontFamily::Proportional];
409        assert_eq!(prop.first().map(String::as_str), Some("mine"));
410        assert_eq!(prop.last().map(String::as_str), Some(FONT_KEY));
411    }
412
413    fn file(family: &str, path: &str) -> (String, PathBuf) {
414        (family.to_owned(), PathBuf::from(path))
415    }
416
417    #[test]
418    fn the_preferred_families_files_come_apart_from_the_rest() {
419        let files = vec![
420            file("Helvetica", "/f/Helvetica.ttc"),
421            file("Hiragino Sans", "/f/HiraginoW3.ttc"),
422            file("PingFang SC", "/f/PingFang.ttc"),
423            file("hiragino sans", "/f/HiraginoW6.ttc"),
424        ];
425        let (preferred, rest) = split_preferred(&files, &["Hiragino Sans"]);
426        assert_eq!(
427            preferred,
428            [
429                PathBuf::from("/f/HiraginoW3.ttc"),
430                PathBuf::from("/f/HiraginoW6.ttc")
431            ]
432        );
433        assert_eq!(
434            rest,
435            [
436                PathBuf::from("/f/Helvetica.ttc"),
437                PathBuf::from("/f/PingFang.ttc")
438            ]
439        );
440    }
441
442    /// A collection file lists one face per family it holds; it is loaded once, as preferred
443    /// when any of its families is.
444    #[test]
445    fn a_file_with_several_families_is_listed_once() {
446        let files = vec![
447            file("PingFang SC", "/f/PingFang.ttc"),
448            file("PingFang TC", "/f/PingFang.ttc"),
449            file("Hiragino Sans", "/f/Mixed.ttc"),
450            file("Other", "/f/Mixed.ttc"),
451        ];
452        let (preferred, rest) = split_preferred(&files, &["Hiragino Sans"]);
453        assert_eq!(preferred, [PathBuf::from("/f/Mixed.ttc")]);
454        assert_eq!(rest, [PathBuf::from("/f/PingFang.ttc")]);
455    }
456
457    /// Every phone ships one of its preferred families: Hiragino Sans on iOS, Noto Sans
458    /// CJK on Android. Run on the simulator and the emulator by mobile-ci.yml.
459    #[cfg(any(target_os = "ios", target_os = "android"))]
460    #[test]
461    fn a_phone_finds_a_preferred_family() {
462        let font = system_cjk_font().expect("no CJK-capable system font");
463        assert!(
464            PREFERRED_FAMILIES
465                .iter()
466                .any(|p| p.eq_ignore_ascii_case(&font.family)),
467            "picked a family outside the preferred list: {font:?}"
468        );
469    }
470
471    #[test]
472    fn the_regular_face_beats_a_bold_one_of_the_same_family() {
473        let mut bold = cand(0, "Yu Gothic UI", true);
474        bold.regularity = 300;
475        let regular = cand(1, "Yu Gothic UI", true);
476        let faces = vec![bold, regular];
477        assert_eq!(pick(&faces, &["Yu Gothic UI"]).unwrap().id_index, 1);
478    }
479
480    #[test]
481    fn a_preferred_family_with_no_cjk_face_falls_back_to_any_covering_face() {
482        let faces = vec![cand(0, "Yu Gothic UI", false), cand(1, "Other", true)];
483        assert_eq!(pick(&faces, &["Yu Gothic UI"]).unwrap().id_index, 1);
484    }
485}