1use std::path::PathBuf;
9use std::sync::Arc;
10
11#[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#[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#[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#[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
53const PROBE: [char; 2] = ['あ', '漢'];
55
56const FONT_KEY: &str = "system-cjk";
58
59#[derive(Clone)]
61pub struct SystemFont {
62 pub family: String,
64 pub data: Vec<u8>,
66 pub index: u32,
68 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
83struct Candidate {
85 id_index: usize,
86 families: Vec<String>,
87 covers_cjk: bool,
88 regularity: u32,
91}
92
93fn 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
105fn pick<'a>(faces: &'a [Candidate], preferred: &[&str]) -> Option<&'a Candidate> {
109 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#[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 #[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#[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#[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
198fn 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 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
246fn 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#[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 pub(super) fn font_files() -> Vec<(String, PathBuf)> {
266 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 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
294pub 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
317pub 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 #[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 #[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 #[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}