//! An installed CJK font for the interface, after the embedded craft-fonts faces, when they leave //! characters of the interface text without a glyph: a build without craft-fonts, one whose //! craft-fonts checkout lacks the Chinese face (#241), or one whose faces lack characters the //! catalogs use (#486) would otherwise draw Chinese menus with boxes and `?`. Native only; wasm //! has no system fonts to read. //! //! It looks for a short list of well-known files in the platform's font folders //! ([`system_font_dirs`](crate::system_font_dirs)) rather than waiting for the full system font //! scan, since the interface fonts are installed on the first frame. use skrifa::MetadataProvider; use skrifa::raw::FileRef; use crate::CraftFont; /// An installed font file chosen for the interface. pub struct SystemUiFont { /// The face's family name, e.g. `Microsoft YaHei`. pub family: String, /// The whole font file. pub bytes: Vec, /// The face's index in the file (0 unless it is a collection). pub index: u32, } /// Is `c` drawn from a CJK font: Han ideographs, kana, bopomofo, Hangul, CJK punctuation or a /// full-width form? (Latin, Cyrillic and symbols come from the interface's Latin faces.) pub fn is_cjk(c: char) -> bool { matches!(c, '\u{2E80}'..='\u{2FDF}' // radicals | '\u{3000}'..='\u{33FF}' // punctuation, kana, bopomofo, compatibility | '\u{3400}'..='\u{4DBF}' // extension A | '\u{4E00}'..='\u{9FFF}' // unified ideographs | '\u{AC00}'..='\u{D7AF}' // Hangul syllables | '\u{F900}'..='\u{FAFF}' // compatibility ideographs | '\u{FE30}'..='\u{FE4F}' // vertical forms | '\u{FF00}'..='\u{FFEF}' // full-width forms | '\u{20000}'..='\u{3FFFF}' // extensions B and later ) } /// The distinct CJK characters of `texts` (see [`is_cjk`]) that none of the `embedded` faces /// maps, in code point order. A craft-fonts face may lack characters the interface catalogs use /// (an older or subset file), and those would draw as boxes or `?` (#486). pub fn ui_uncovered_cjk(texts: &[&str], embedded: &[&CraftFont]) -> Vec { let faces: Vec<_> = embedded.iter().filter_map(|f| skrifa::FontRef::from_index(f.bytes, 0).ok()).map(|f| f.charmap()).collect(); let chars: std::collections::BTreeSet = texts.iter().flat_map(|t| t.chars()).filter(|c| is_cjk(*c)).collect(); chars.into_iter().filter(|c| !faces.iter().any(|m| m.map(*c).is_some())).collect() } /// Do the embedded interface faces (`ui_cjk_fonts`) leave CJK characters of `texts` (the /// interface catalogs and language names that can be on screen) without a glyph? Then an /// installed CJK font goes after them. No embedded face (a build without craft-fonts) leaves /// every CJK character uncovered; a Japanese face alone lacks the simplified-only hanzi (页, 选, /// 删 …, #241) and some traditional ones (啟, 內 …, #486). pub fn ui_needs_system_cjk(texts: &[&str], embedded: &[&CraftFont]) -> bool { !ui_uncovered_cjk(texts, embedded).is_empty() } /// Files larger than this are skipped (the big system CJK collections are around 20–80 MB). const MAX_FILE: u64 = 128 << 20; /// Faces looked at in one collection. const MAX_FACES: u32 = 64; /// Entries looked at in one macOS font asset folder (each font is one `.asset` folder). const MAX_ASSETS: usize = 8192; /// Characters a face's coverage is measured on. const MAX_NEEDED: usize = 4096; /// The tables skrifa reads glyph outlines from: TrueType, CFF, CFF2 and VARC. A face with /// outlines only in Apple's `hvgl` table has none of them, and the interface draws its characters /// as blank space. pub(crate) const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"]; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Region { Hans, Hant, Other, } impl Region { fn from_hans(hans: bool) -> Region { if hans { Region::Hans } else { Region::Other } } fn rank(self, family: &str) -> u8 { match self { Region::Hans => u8::from(family.ends_with(" SC") || family.ends_with(" GB") || family.contains("YaHei")), Region::Hant => { let taiwan = [" TC", " TW"].iter().any(|s| family.ends_with(s)) || ["JhengHei", "CNS", "LiHei", "MingLiU"].iter().any(|s| family.contains(s)); if taiwan { 2 } else { u8::from(family.ends_with(" HK")) } } Region::Other => 1, } } } /// A character only a font for the language has: a simplified-only hanzi, kana, or Hangul. fn probe(hans: bool, korean: bool) -> char { if korean { '한' } else if hans { '页' } else { 'の' } } /// Font files to look for, relative to a font folder (or absolute), best first. fn candidates(hans: bool, korean: bool) -> Vec<&'static str> { let (zh, ja, ko): (&[&str], &[&str], &[&str]) = if cfg!(windows) { ( &["Deng.ttf", "msyh.ttc", "simhei.ttf", "simsun.ttc", "NotoSansSC-VF.ttf"], &["YuGothR.ttc", "meiryo.ttc", "msgothic.ttc"], &["malgun.ttf", "malgunsl.ttf", "gulim.ttc", "batang.ttc"], ) } else if cfg!(target_os = "macos") { ( &[ // An asset on macOS 10.15 and later (see `with_asset_data`). "PingFang.ttc", "Hiragino Sans GB.ttc", "STHeiti Light.ttc", "STHeiti Medium.ttc", // The system interface's own copy, present when the PingFang asset isn't. "/System/Library/PrivateFrameworks/FontServices.framework/Resources/Reserved/PingFangUI.ttc", ], &["ヒラギノ角ゴシック W3.ttc", "Hiragino Sans W3.ttc"], &["/System/Library/Fonts/AppleSDGothicNeo.ttc", "/System/Library/Fonts/Supplemental/AppleGothic.ttf"], ) } else { ( &[ "noto-cjk/NotoSansCJK-Regular.ttc", "opentype/noto/NotoSansCJK-Regular.ttc", "google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc", "google-noto-cjk/NotoSansCJK-Regular.ttc", "noto-cjk/NotoSansCJKsc-Regular.otf", "opentype/noto/NotoSansCJKsc-Regular.otf", "adobe-source-han-sans/SourceHanSansCN-Regular.otf", "wenquanyi/wqy-microhei/wqy-microhei.ttc", "truetype/wqy/wqy-microhei.ttc", "wenquanyi/wqy-zenhei/wqy-zenhei.ttc", "truetype/wqy/wqy-zenhei.ttc", "truetype/droid/DroidSansFallbackFull.ttf", ], &["noto-cjk/NotoSansCJKjp-Regular.otf", "opentype/noto/NotoSansCJKjp-Regular.otf"], &[ "noto-cjk/NotoSansCJKkr-Regular.otf", "opentype/noto/NotoSansCJKkr-Regular.otf", "google-noto-sans-cjk-fonts/NotoSansCJKkr-Regular.otf", "adobe-source-han-sans/SourceHanSansK-Regular.otf", "opentype/adobe-source-han-sans/SourceHanSansK-Regular.otf", "truetype/nanum/NanumGothic.ttf", ], ) }; if korean { ko.iter().chain(ja).chain(zh).copied().collect() } else if hans { zh.to_vec() } else { ja.iter().chain(zh).copied().collect() } } fn hant_candidates() -> &'static [&'static str] { if cfg!(windows) { &["msjh.ttc", "msjh.ttf", "mingliu.ttc"] } else if cfg!(target_os = "macos") { &[ "PingFang.ttc", "Hiragino Sans CNS.ttc", "LiHeiPro.ttf", "/System/Library/PrivateFrameworks/FontServices.framework/Resources/Reserved/PingFangUI.ttc", ] } else { &[ "noto-cjk/NotoSansCJK-Regular.ttc", "opentype/noto/NotoSansCJK-Regular.ttc", "google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc", "google-noto-cjk/NotoSansCJK-Regular.ttc", "noto-cjk/NotoSansCJKtc-Regular.otf", "opentype/noto/NotoSansCJKtc-Regular.otf", "google-noto-cjk/NotoSansCJKtc-Regular.otf", "adobe-source-han-sans/SourceHanSansTW-Regular.otf", "adobe-source-han-sans/SourceHanSansTC-Regular.otf", "opentype/adobe-source-han-sans/SourceHanSansTW-Regular.otf", "NotoSansCJK-Regular.ttc", "NotoSansCJKtc-Regular.otf", "SourceHanSansTW-Regular.otf", "SourceHanSansTC-Regular.otf", ] } } pub fn system_hant_ui_font(needed: &[char]) -> Option { let probe = ['體']; let needed = if needed.is_empty() { &probe[..] } else { needed.get(..MAX_NEEDED).unwrap_or(needed) }; hant_font_in(&with_asset_data(crate::system_font_dirs()), needed) } fn hant_font_in(dirs: &[std::path::PathBuf], needed: &[char]) -> Option { find_ui_font(dirs, hant_candidates(), needed, Region::Hant).filter(|f| Region::Hant.rank(&f.family) > 0) } /// The installed CJK font for a Chinese (`hans`) or other interface that best covers `needed` /// (the characters the embedded faces lack; empty: a character typical of the language), if one /// is found. Never panics on damaged files. pub fn system_cjk_ui_font(hans: bool, needed: &[char]) -> Option { system_cjk_ui_font_for(hans, false, needed) } /// The installed CJK font for a Chinese, Korean or Japanese interface. pub fn system_cjk_ui_font_for(hans: bool, korean: bool, needed: &[char]) -> Option { let probe = [probe(hans, korean)]; let needed = if needed.is_empty() { &probe[..] } else { needed.get(..MAX_NEEDED).unwrap_or(needed) }; find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, Region::from_hans(hans)) } /// `dirs` and, for each macOS font asset folder among them (`com_apple_MobileAsset_Font`), /// the `AssetData` folder of every `.asset` in it: macOS ships PingFang there, e.g. /// `/System/Library/AssetsV2/com_apple_MobileAsset_Font7/.asset/AssetData/PingFang.ttc` /// (#486). One level, at most [`MAX_ASSETS`] entries per folder. pub(crate) fn with_asset_data(dirs: Vec) -> Vec { let mut out = Vec::with_capacity(dirs.len()); for d in dirs { let assets = d.file_name().is_some_and(|n| n.to_string_lossy().starts_with("com_apple_MobileAsset_Font")); if assets && let Ok(entries) = std::fs::read_dir(&d) { let mut found: Vec<_> = entries.flatten().take(MAX_ASSETS).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "asset")).collect(); found.sort(); out.extend(found.into_iter().map(|p| p.join("AssetData"))); } out.push(d); } out } /// The font file among `names` (tried in order, each in every folder of `dirs`) whose best face /// maps all of `needed`, or failing that the one mapping the most of them (the earliest on a /// tie). A file that maps none of them is never chosen. pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed: &[char], region: Region) -> Option { let mut tried = std::collections::HashSet::new(); let mut best: Option<((u8, usize), SystemUiFont)> = None; for path in names.iter().flat_map(|n| dirs.iter().map(move |d| d.join(n))) { if !tried.insert(path.clone()) { continue; } let Some(size) = std::fs::metadata(&path).ok().filter(std::fs::Metadata::is_file).map(|m| m.len()) else { continue }; if size > MAX_FILE { continue; } let Ok(bytes) = std::fs::read(&path) else { continue }; let Some((index, family, covered)) = pick_face(&bytes, needed, region) else { continue }; let fit = if region == Region::Hant { region.rank(&family) } else { 0 }; if best.as_ref().is_some_and(|(b, _)| *b >= (fit, covered)) { continue; } log::debug!("interface CJK fallback: {family} ({}), {covered} of {} characters", path.display(), needed.len()); let done = covered >= needed.len() && (region != Region::Hant || fit == 2); best = Some(((fit, covered), SystemUiFont { family, bytes, index })); if done { break; } } best.map(|(_, f)| f) } /// The face of a font file (or collection) to use and how many of `needed` it maps: the one /// mapping the most, then preferring a Regular style and, for Chinese, a Simplified Chinese /// family (`… SC`, `… GB`, as in a pan-CJK collection). Faces with none of the /// [`OUTLINE_TABLES`] are skipped. `None` if the header isn't a font's or no face with outlines /// maps any of `needed`. pub(crate) fn pick_face(data: &[u8], needed: &[char], region: Region) -> Option<(u32, String, usize)> { let magic = data.get(..4)?; if ![&[0, 1, 0, 0][..], b"OTTO", b"ttcf"].contains(&magic) { return None; } let count = match FileRef::new(data).ok()? { FileRef::Font(_) => 1, FileRef::Collection(c) => c.len().min(MAX_FACES), }; let mut best: Option<((u8, usize, bool, u8), u32, String)> = None; for i in 0..count { let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue }; if !OUTLINE_TABLES.iter().any(|t| f.table_data(skrifa::Tag::new(t)).is_some()) { continue; } let charmap = f.charmap(); let covered = needed.iter().filter(|c| charmap.map(**c).is_some()).count(); if covered == 0 { continue; } let Some((family, style, _)) = crate::fontdb::face_names(&f) else { continue }; let rank = region.rank(&family); let first = if region == Region::Hant { rank } else { 0 }; let score = (first, covered, style.eq_ignore_ascii_case("Regular"), rank); if best.as_ref().is_none_or(|(s, ..)| score > *s) { best = Some((score, i, family)); } } best.map(|((_, covered, ..), i, family)| (i, family, covered)) } #[cfg(test)] mod tests { use super::*; #[test] fn korean_interface_uses_korean_first_system_font_candidates() { let names = candidates(false, true); assert_eq!( names.first(), Some(&if cfg!(windows) { "malgun.ttf" } else if cfg!(target_os = "macos") { "/System/Library/Fonts/AppleSDGothicNeo.ttc" } else { "noto-cjk/NotoSansCJKkr-Regular.otf" }) ); assert_eq!(probe(false, true), '한'); } #[test] #[cfg(not(target_arch = "wasm32"))] fn traditional_chinese_interface_picks_the_taiwan_face() { use crate::fontdb::tests::sysfonts::{collection, renamed_to}; assert_eq!( hant_candidates().first(), Some(&if cfg!(windows) { "msjh.ttc" } else if cfg!(target_os = "macos") { "PingFang.ttc" } else { "noto-cjk/NotoSansCJK-Regular.ttc" }) ); let face = |family| renamed_to("SourceSans3-Regular.ttf", family); let pan = collection(&[face("Probe Sans JP"), face("Probe Sans KR"), face("Probe Sans SC"), face("Probe Sans TC"), face("Probe Sans HK")]); assert_eq!(pick_face(&pan, &['A'], Region::Hant), Some((3, "Probe Sans TC".to_string(), 1))); assert_eq!(pick_face(&pan, &['A'], Region::Hans), Some((2, "Probe Sans SC".to_string(), 1))); assert_eq!(pick_face(&pan, &['A'], Region::Other), Some((0, "Probe Sans JP".to_string(), 1))); let hk = collection(&[face("Probe Sans SC"), face("Probe Sans HK")]); assert_eq!(pick_face(&hk, &['A'], Region::Hant).map(|f| f.0), Some(1)); assert_eq!(Region::Hant.rank("Microsoft JhengHei"), 2); assert_eq!(Region::Hant.rank("Microsoft YaHei"), 0); let dir = std::env::temp_dir().join(format!("wc-uifallback-hant-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let names = ["missing.ttc", "damaged.ttc", "sc.ttc", "pan.ttc"]; std::fs::write(dir.join("damaged.ttc"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap(); std::fs::write(dir.join("sc.ttc"), collection(&[face("Probe Sans SC"), face("Probe Sans JP")])).unwrap(); std::fs::write(dir.join("pan.ttc"), &pan).unwrap(); let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hant).unwrap(); assert_eq!((found.family.as_str(), found.index), ("Probe Sans TC", 3)); assert_eq!(find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hans).unwrap().family, "Probe Sans SC"); let only_sc = dir.join("only-sc"); std::fs::create_dir_all(only_sc.join("noto-cjk")).unwrap(); std::fs::write(only_sc.join("noto-cjk/NotoSansCJK-Regular.ttc"), collection(&[face("Probe Sans SC")])).unwrap(); assert!(hant_font_in(std::slice::from_ref(&only_sc), &['A']).is_none()); std::fs::write(only_sc.join("noto-cjk/NotoSansCJK-Regular.ttc"), &pan).unwrap(); if !cfg!(windows) && !cfg!(target_os = "macos") { assert_eq!(hant_font_in(std::slice::from_ref(&only_sc), &['A']).map(|f| f.index), Some(3)); } assert!(hant_font_in(std::slice::from_ref(&only_sc), &['體']).is_none()); let user = dir.join("user-fonts"); std::fs::create_dir_all(&user).unwrap(); std::fs::write(user.join("NotoSansCJKtc-Regular.otf"), face("Probe Sans TC")).unwrap(); if !cfg!(windows) && !cfg!(target_os = "macos") { assert_eq!(hant_font_in(std::slice::from_ref(&user), &['A']).map(|f| f.family), Some("Probe Sans TC".to_string())); } let _ = std::fs::remove_dir_all(&dir); } #[test] fn needs_system_cjk_when_an_embedded_face_lacks_an_interface_character() { // A face without CJK glyphs stands in for a craft-fonts face that lacks some (#486). let latin = CraftFont { family: "Source Sans 3", style: "Regular", scripts: &["Hans"], bytes: crate::bundled()[0] }; assert_eq!(ui_uncovered_cjk(&["选项 Options", "選項:选项"], &[&latin]), ['选', '選', '項', '项', ':']); assert!(ui_needs_system_cjk(&["简体中文"], &[])); assert!(ui_needs_system_cjk(&["Save", "开"], &[&latin])); // Latin, Cyrillic and symbols are the Latin faces' business. assert!(!ui_needs_system_cjk(&["Українська Español → ¶"], &[])); // A damaged embedded face counts as covering nothing; it never panics. let broken = CraftFont { family: "x", style: "Regular", scripts: &["Hans"], bytes: b"OTTO" }; assert!(ui_needs_system_cjk(&["页"], &[&broken])); // The real faces, when built with craft-fonts: the Chinese face covers simplified and // traditional hanzi the Japanese one lacks (#241, #486). let embedded = crate::ui_cjk_fonts(true); if crate::chinese_fonts().next().is_some() { assert!(!ui_needs_system_cjk(&["页选项另存为啟內"], &embedded)); } } #[test] fn finds_the_best_covering_font_also_in_macos_asset_folders() { let dir = std::env::temp_dir().join(format!("wc-uifallback-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let assets = dir.join("com_apple_MobileAsset_Font7"); std::fs::create_dir_all(dir.join("sub")).unwrap(); std::fs::create_dir_all(assets.join("0a1b.asset/AssetData")).unwrap(); std::fs::create_dir_all(assets.join("not-an-asset/AssetData")).unwrap(); let sans = crate::bundled()[0]; // Missing, damaged and non-font files are skipped without panicking. std::fs::write(dir.join("Deng.ttf"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap(); std::fs::write(dir.join("msyh.ttc"), b"not a font").unwrap(); std::fs::write(dir.join("sub/good.ttf"), sans).unwrap(); let names = ["missing.ttf", "Deng.ttf", "msyh.ttc", "sub/good.ttf"]; let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hans).unwrap(); assert_eq!((found.family.as_str(), found.index, found.bytes.len()), ("Source Sans 3", 0, sans.len())); // No candidate maps a CJK character: nothing is chosen. assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['页'], Region::Hans).is_none()); assert!(pick_face(b"OTT", &['A'], Region::Other).is_none()); // macOS 12 keeps PingFang in `AssetsV2/com_apple_MobileAsset_Font7/.asset/AssetData`. std::fs::write(assets.join("0a1b.asset/AssetData/PingFang.ttc"), sans).unwrap(); std::fs::write(assets.join("not-an-asset/AssetData/Hidden.ttc"), sans).unwrap(); let dirs = with_asset_data(vec![dir.clone(), assets.clone()]); assert_eq!(dirs, [dir.clone(), assets.join("0a1b.asset/AssetData"), assets.clone()]); let found = find_ui_font(&dirs, &["PingFang.ttc"], &['A'], Region::Hans).unwrap(); assert_eq!(found.family, "Source Sans 3"); assert!(find_ui_font(&dirs, &["Hidden.ttc"], &['A'], Region::Hans).is_none()); // No file maps every needed character: the one mapping the most is used. let found = find_ui_font(&dirs, &["sub/good.ttf", "PingFang.ttc"], &['A', '页'], Region::Hans).unwrap(); assert_eq!(found.family, "Source Sans 3"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn skips_faces_without_outlines() { // Renaming the `glyf` table to `hvgl` gives a face like those of `PingFangUI.ttc` on // macOS 27.0.1. Its cmap maps characters, and skrifa reads no outlines from it. let sans = crate::bundled()[0]; let mut hvgl = sans.to_vec(); let tables = u16::from_be_bytes([hvgl[4], hvgl[5]]) as usize; let glyf = (0..tables).map(|r| 12 + r * 16).find(|&at| &hvgl[at..at + 4] == b"glyf").unwrap(); hvgl[glyf..glyf + 4].copy_from_slice(b"hvgl"); assert!(skrifa::FontRef::new(&hvgl).unwrap().charmap().map('A').is_some()); assert!(pick_face(&hvgl, &['A'], Region::Hans).is_none()); assert_eq!(pick_face(sans, &['A'], Region::Hans), Some((0, "Source Sans 3".to_string(), 1))); // `find_ui_font` uses the next file, and returns `None` when no other file is listed. let dir = std::env::temp_dir().join(format!("wc-uifallback-outlines-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("hvgl.ttf"), &hvgl).unwrap(); std::fs::write(dir.join("glyf.ttf"), sans).unwrap(); let found = find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf", "glyf.ttf"], &['A'], Region::Hans).unwrap(); assert!(found.bytes == sans); assert!(find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf"], &['A'], Region::Hans).is_none()); let _ = std::fs::remove_dir_all(&dir); } }