Compare commits

...
6 changed files with 820 additions and 115 deletions

No files matched your search

+476
View File
@@ -0,0 +1,476 @@
//! CJK font fallback: which installed system fonts cover Japanese, Simplified Chinese,
//! Traditional Chinese and Korean, and in which order to try them for the user's locale.
//!
//! Han characters are shared between the four, but their preferred glyph forms differ, so the
//! font for the locale's script goes first: Japanese forms only for a Japanese locale, Chinese
//! forms for zh-Hans / zh-Hant, Korean (Hanja-capable) fonts for ko. Kana always prefers a
//! Japanese font, Hangul a Korean one and Bopomofo a Traditional Chinese one.
//!
//! Used by the egui shell (UI fallback fonts, loaded lazily) and by [`crate::fonts::FontDb`]
//! (Type tool fallback families).
/// A CJK writing system with its own preferred fonts.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CjkScript {
Japanese,
SimplifiedChinese,
TraditionalChinese,
Korean,
}
/// The kind of CJK character that needs a fallback font.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CjkChar {
/// Ideographs, CJK punctuation and full-width forms: font choice follows the locale.
Han,
Kana,
Hangul,
Bopomofo,
}
impl CjkChar {
/// The script whose fonts should be tried first for this character.
pub fn preferred(self, order: &[CjkScript; 4]) -> CjkScript {
match self {
CjkChar::Kana => CjkScript::Japanese,
CjkChar::Hangul => CjkScript::Korean,
CjkChar::Bopomofo => CjkScript::TraditionalChinese,
CjkChar::Han => order[0],
}
}
}
/// Classifies a character that needs a CJK font, or `None` for everything else.
pub fn classify(c: char) -> Option<CjkChar> {
let u = c as u32;
Some(match u {
0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7AF | 0xD7B0..=0xD7FF | 0xFFA0..=0xFFDC => CjkChar::Hangul,
0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF65..=0xFF9F | 0x1B000..=0x1B16F => CjkChar::Kana,
0x3100..=0x312F | 0x31A0..=0x31BF => CjkChar::Bopomofo,
0x2E80..=0x2FDF | 0x3000..=0x303F | 0x3190..=0x319F | 0x31C0..=0x31EF | 0x3200..=0x33FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF => CjkChar::Han,
0xF900..=0xFAFF | 0xFE30..=0xFE4F | 0xFF00..=0xFF64 | 0xFFE0..=0xFFEF | 0x20000..=0x3FFFF => CjkChar::Han,
_ => return None,
})
}
/// Script order for a locale tag such as `ja_JP.UTF-8`, `zh-Hant-TW`, `zh_CN` or `ko-KR`.
/// Non-CJK (or unknown) locales get Simplified Chinese first, so Japanese forms only win
/// for shared Han when the locale is Japanese.
pub fn script_order(locale: Option<&str>) -> [CjkScript; 4] {
use CjkScript::*;
let tag = locale.unwrap_or("").split(['.', '@']).next().unwrap_or("").to_ascii_lowercase().replace('_', "-");
let mut parts = tag.split('-');
let lang = parts.next().unwrap_or("");
let rest: Vec<&str> = parts.collect();
match lang {
"ja" => [Japanese, SimplifiedChinese, TraditionalChinese, Korean],
"ko" => [Korean, TraditionalChinese, SimplifiedChinese, Japanese],
"zh" | "yue" => {
let hant = rest.iter().any(|p| matches!(*p, "hant" | "tw" | "hk" | "mo"));
let hans = rest.iter().any(|p| matches!(*p, "hans" | "cn" | "sg"));
if hant && !hans || lang == "yue" && !hans {
[TraditionalChinese, SimplifiedChinese, Japanese, Korean]
} else {
[SimplifiedChinese, TraditionalChinese, Japanese, Korean]
}
}
_ => [SimplifiedChinese, TraditionalChinese, Japanese, Korean],
}
}
/// The user's UI locale (cached). `PHOTOCRAFT_LOCALE` overrides; then macOS's preferred
/// language list, the Windows user locale, and finally `LC_ALL` / `LC_MESSAGES` / `LANG` /
/// `LANGUAGE`. `None` on the web or when nothing is set.
pub fn ui_locale() -> Option<&'static str> {
static LOCALE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
LOCALE.get_or_init(detect_locale).as_deref()
}
/// [`script_order`] for [`ui_locale`].
pub fn ui_script_order() -> [CjkScript; 4] {
script_order(ui_locale())
}
fn detect_locale() -> Option<String> {
let env = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty() && v != "C" && v != "POSIX" && !v.starts_with("C."));
if let Some(v) = env("PHOTOCRAFT_LOCALE") {
return Some(v);
}
if let Some(v) = os_locale() {
return Some(v);
}
["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"].iter().find_map(|k| env(k)).map(|v| v.split(':').next().unwrap_or("").to_string())
}
#[cfg(target_os = "macos")]
fn os_locale() -> Option<String> {
let home = std::env::var_os("HOME")?;
let path = std::path::Path::new(&home).join("Library/Preferences/.GlobalPreferences.plist");
let meta = std::fs::metadata(&path).ok()?;
if meta.len() > 4 << 20 {
return None;
}
let bytes = std::fs::read(path).ok()?;
bplist_first_language(&bytes)
}
#[cfg(target_os = "windows")]
fn os_locale() -> Option<String> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let out = std::process::Command::new("reg")
.args(["query", "HKCU\\Control Panel\\International", "/v", "LocaleName"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
// " LocaleName REG_SZ ja-JP"
text.lines().find(|l| l.contains("LocaleName")).and_then(|l| l.split_whitespace().last()).map(str::to_string)
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn os_locale() -> Option<String> {
None
}
/// First entry of `AppleLanguages` in a binary property list (macOS `.GlobalPreferences.plist`).
/// A minimal, bounds-checked reader of the documented `bplist00` format; `None` on anything else.
pub fn bplist_first_language(b: &[u8]) -> Option<String> {
if b.len() < 40 || !b.starts_with(b"bplist00") {
return None;
}
let t = b.get(b.len() - 32..)?;
let offset_size = *t.get(6)? as usize;
let ref_size = *t.get(7)? as usize;
let be = |s: &[u8]| s.iter().fold(0u64, |a, &x| (a << 8) | x as u64);
let num_objects = usize::try_from(be(t.get(8..16)?)).ok()?;
let top = usize::try_from(be(t.get(16..24)?)).ok()?;
let table = usize::try_from(be(t.get(24..32)?)).ok()?;
if !(1..=8).contains(&offset_size) || !(1..=8).contains(&ref_size) || num_objects > b.len() {
return None;
}
let offset = |obj: usize| -> Option<usize> {
if obj >= num_objects {
return None;
}
let at = table.checked_add(obj.checked_mul(offset_size)?)?;
usize::try_from(be(b.get(at..at.checked_add(offset_size)?)?)).ok()
};
// (marker high nibble, count, start of payload)
let header = |obj: usize| -> Option<(u8, usize, usize)> {
let at = offset(obj)?;
let m = *b.get(at)?;
let (kind, low) = (m >> 4, (m & 0x0F) as usize);
if low != 0x0F || !matches!(kind, 0x5 | 0x6 | 0xA | 0xD) {
return Some((kind, low, at + 1));
}
let im = *b.get(at + 1)?;
if im >> 4 != 0x1 {
return None;
}
let n = 1usize.checked_shl((im & 0x0F) as u32)?;
let count = usize::try_from(be(b.get(at + 2..at.checked_add(2 + n)?)?)).ok()?;
Some((kind, count, at + 2 + n))
};
let string = |obj: usize| -> Option<String> {
let (kind, n, start) = header(obj)?;
match kind {
0x5 => b.get(start..start.checked_add(n)?).map(|s| String::from_utf8_lossy(s).into_owned()),
0x6 => {
let s = b.get(start..start.checked_add(n.checked_mul(2)?)?)?;
Some(String::from_utf16_lossy(&s.as_chunks::<2>().0.iter().map(|p| u16::from_be_bytes(*p)).collect::<Vec<_>>()))
}
_ => None,
}
};
let obj_ref = |at: usize| -> Option<usize> { usize::try_from(be(b.get(at..at.checked_add(ref_size)?)?)).ok() };
let (kind, n, start) = header(top)?;
if kind != 0xD {
return None;
}
for i in 0..n {
let key = obj_ref(start.checked_add(i.checked_mul(ref_size)?)?)?;
if string(key).as_deref() != Some("AppleLanguages") {
continue;
}
let val = obj_ref(start.checked_add(n.checked_add(i)?.checked_mul(ref_size)?)?)?;
let (akind, an, astart) = header(val)?;
if akind != 0xA || an == 0 {
return None;
}
return string(obj_ref(astart)?).filter(|s| !s.is_empty());
}
None
}
/// Family names (as the Type tool's font database knows them) per script, most preferred first.
pub fn families(script: CjkScript) -> &'static [&'static str] {
match script {
CjkScript::Japanese => &["Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", "Meiryo", "Noto Sans CJK JP", "Noto Sans JP", "IPAexGothic"],
CjkScript::SimplifiedChinese => {
&["PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Noto Sans SC", "WenQuanYi Micro Hei", "WenQuanYi Zen Hei", "Heiti SC"]
}
CjkScript::TraditionalChinese => &["PingFang TC", "Microsoft JhengHei", "Noto Sans CJK TC", "Noto Sans TC", "Hiragino Sans CNS", "Heiti TC"],
CjkScript::Korean => &["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR", "NanumGothic", "UnDotum"],
}
}
/// An installed font file to try: its path and the family of the wanted face in a collection
/// (empty: face 0). Matching by family name keeps working when a `.ttc` reorders its faces.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FontFile {
pub path: std::path::PathBuf,
pub family: &'static str,
}
/// System font files for `script`, most preferred first. Paths that don't exist are included;
/// callers skip them.
#[cfg(not(target_arch = "wasm32"))]
pub fn font_files(script: CjkScript) -> Vec<FontFile> {
use CjkScript::*;
let f = |p: &str, family: &'static str| FontFile { path: p.into(), family };
if cfg!(target_os = "macos") {
let pingfang = |family| mac_pingfang().map(|path| FontFile { path, family });
match script {
Japanese => vec![f("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", ""), f("/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc", "")],
SimplifiedChinese => pingfang("PingFang SC")
.into_iter()
.chain([f("/System/Library/Fonts/Hiragino Sans GB.ttc", ""), f("/System/Library/Fonts/STHeiti Light.ttc", "Heiti SC")])
.collect(),
TraditionalChinese => pingfang("PingFang TC").into_iter().chain([f("/System/Library/Fonts/STHeiti Light.ttc", "Heiti TC")]).collect(),
Korean => vec![f("/System/Library/Fonts/AppleSDGothicNeo.ttc", "Apple SD Gothic Neo")],
}
} else if cfg!(target_os = "windows") {
let dir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".into());
let w = |file: &str, family: &'static str| FontFile { path: std::path::Path::new(&dir).join("Fonts").join(file), family };
match script {
Japanese => vec![w("YuGothR.ttc", "Yu Gothic"), w("YuGothM.ttc", "Yu Gothic"), w("meiryo.ttc", "Meiryo"), w("msgothic.ttc", "")],
SimplifiedChinese => vec![w("msyh.ttc", "Microsoft YaHei"), w("msyh.ttf", ""), w("simsun.ttc", "")],
TraditionalChinese => vec![w("msjh.ttc", "Microsoft JhengHei"), w("msjh.ttf", ""), w("mingliu.ttc", "")],
Korean => vec![w("malgun.ttf", ""), w("gulim.ttc", "")],
}
} else {
// One Noto Sans CJK collection covers all four scripts; the face picks the glyph forms.
let noto = match script {
Japanese => "Noto Sans CJK JP",
SimplifiedChinese => "Noto Sans CJK SC",
TraditionalChinese => "Noto Sans CJK TC",
Korean => "Noto Sans CJK KR",
};
let mut v: Vec<FontFile> = [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-noto-sans-cjk-vf-fonts/NotoSansCJK-VF.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-VF.ttc",
]
.iter()
.map(|p| f(p, noto))
.collect();
v.extend(match script {
Japanese => vec![
f("/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf", ""),
f("/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", ""),
f("/usr/share/fonts/truetype/takao-gothic/TakaoPGothic.ttf", ""),
],
SimplifiedChinese | TraditionalChinese => vec![
f("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", ""),
f("/usr/share/fonts/wenquanyi/wqy-microhei/wqy-microhei.ttc", ""),
f("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", ""),
f("/usr/share/fonts/wenquanyi/wqy-zenhei/wqy-zenhei.ttc", ""),
],
Korean => vec![
f("/usr/share/fonts/truetype/nanum/NanumGothic.ttf", ""),
f("/usr/share/fonts/nanum/NanumGothic.ttf", ""),
f("/usr/share/fonts/truetype/unfonts-core/UnDotum.ttf", ""),
f("/usr/share/fonts/un-core/UnDotum.ttf", ""),
],
});
v
}
}
/// Broad-coverage fonts tried after every script's own fonts (they cover Han, kana and Hangul
/// but look worse than the script fonts).
#[cfg(not(target_arch = "wasm32"))]
pub fn last_resort_files() -> Vec<FontFile> {
let f = |p: &str| FontFile { path: p.into(), family: "" };
if cfg!(target_os = "macos") {
vec![f("/System/Library/Fonts/Supplemental/Arial Unicode.ttf"), f("/Library/Fonts/Arial Unicode.ttf")]
} else if cfg!(target_os = "windows") {
let dir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".into());
vec![FontFile { path: std::path::Path::new(&dir).join("Fonts").join("ARIALUNI.TTF"), family: "" }]
} else {
vec![f("/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf"), f("/usr/share/fonts/google-droid-sans-fonts/DroidSansFallbackFull.ttf")]
}
}
/// macOS ships PingFang as a pre-installed "mobile asset" outside `/System/Library/Fonts`
/// (`/System/Library/AssetsV2/com_apple_MobileAsset_Font*/<hash>.asset/AssetData/PingFang.ttc`).
/// Looks there (a few small directory listings) and in the pre-Catalina location.
#[cfg(not(target_arch = "wasm32"))]
pub fn mac_pingfang() -> Option<std::path::PathBuf> {
use std::path::{Path, PathBuf};
let old = PathBuf::from("/System/Library/Fonts/PingFang.ttc");
if old.is_file() {
return Some(old);
}
let roots = [Path::new("/System/Library/AssetsV2"), Path::new("/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs")];
for root in roots {
let Ok(rd) = std::fs::read_dir(root) else { continue };
for e in rd.flatten().take(512) {
if !e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") {
continue;
}
let Ok(assets) = std::fs::read_dir(e.path()) else { continue };
for a in assets.flatten().take(4096) {
let p = a.path().join("AssetData/PingFang.ttc");
if p.is_file() {
return Some(p);
}
}
}
}
None
}
/// Face index in a font file (or collection) whose family name is `family`; 0 when `family` is
/// empty or no face matches. Never panics on malformed data.
pub fn face_index_for_family(bytes: &[u8], family: &str) -> u32 {
use skrifa::{MetadataProvider, raw::FileRef, string::StringId};
if family.is_empty() {
return 0;
}
let Ok(FileRef::Collection(c)) = FileRef::new(bytes) else { return 0 };
for (i, f) in c.iter().enumerate() {
let Ok(f) = f else { continue };
let named = [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME].iter().any(|id| f.localized_strings(*id).any(|s| s.chars().eq(family.chars())));
if named {
return i as u32;
}
}
0
}
#[cfg(test)]
mod tests {
use super::*;
use CjkScript::*;
#[test]
fn locale_orders() {
assert_eq!(script_order(Some("ja_JP.UTF-8"))[0], Japanese);
assert_eq!(script_order(Some("ja"))[0], Japanese);
assert_eq!(script_order(Some("zh_CN.UTF-8"))[0], SimplifiedChinese);
assert_eq!(script_order(Some("zh-Hans-CN"))[0], SimplifiedChinese);
assert_eq!(script_order(Some("zh-Hant-TW"))[0], TraditionalChinese);
assert_eq!(script_order(Some("zh_TW"))[0], TraditionalChinese);
assert_eq!(script_order(Some("zh-HK"))[0], TraditionalChinese);
assert_eq!(script_order(Some("zh-Hans-HK"))[0], SimplifiedChinese);
assert_eq!(script_order(Some("ko-KR"))[0], Korean);
// Non-CJK and unknown: Japanese forms are never first.
for l in [None, Some(""), Some("en_US.UTF-8"), Some("C"), Some("de"), Some("!!@@")] {
let o = script_order(l);
assert_eq!(o[0], SimplifiedChinese);
assert_ne!(o[0], Japanese);
}
// Every order is a permutation of all four scripts.
for l in ["ja", "ko", "zh-TW", "zh", "en"] {
let o = script_order(Some(l));
for s in [Japanese, SimplifiedChinese, TraditionalChinese, Korean] {
assert!(o.contains(&s), "{l}: {o:?}");
}
}
}
#[test]
fn type_tool_fallback_follows_locale() {
let pos = |v: &[&str], f: &str| v.iter().position(|x| *x == f).unwrap();
let ja = crate::fonts::fallback_candidates(&script_order(Some("ja_JP")));
assert!(pos(&ja, "Hiragino Sans") < pos(&ja, "PingFang SC"));
assert!(pos(&ja, "Noto Sans CJK JP") < pos(&ja, "Noto Sans CJK SC"));
let sc = crate::fonts::fallback_candidates(&script_order(Some("zh_CN")));
assert!(pos(&sc, "PingFang SC") < pos(&sc, "Hiragino Sans"));
assert!(pos(&sc, "Microsoft YaHei") < pos(&sc, "Microsoft JhengHei"));
let tc = crate::fonts::fallback_candidates(&script_order(Some("zh-Hant")));
assert!(pos(&tc, "PingFang TC") < pos(&tc, "PingFang SC"));
let ko = crate::fonts::fallback_candidates(&script_order(Some("ko")));
assert!(pos(&ko, "Apple SD Gothic Neo") < pos(&ko, "PingFang SC"));
assert!(pos(&ko, "Malgun Gothic") < pos(&ko, "Yu Gothic"));
// Arial Unicode covers Han too: it must not pre-empt the script fonts.
for v in [&ja, &sc, &tc, &ko] {
assert!(pos(v, "Arial Unicode MS") > pos(v, "Noto Sans CJK KR"));
assert!(pos(v, "Noto Sans") < pos(v, "Hiragino Sans"));
}
}
#[test]
fn classify_chars() {
let order = script_order(Some("en"));
assert_eq!(classify('a'), None);
assert_eq!(classify('é'), None);
assert_eq!(classify('图'), Some(CjkChar::Han));
assert_eq!(classify('圖'), Some(CjkChar::Han));
assert_eq!(classify('レ'), Some(CjkChar::Kana));
assert_eq!(classify('ー'), Some(CjkChar::Kana));
assert_eq!(classify('카'), Some(CjkChar::Hangul));
assert_eq!(classify('ㄅ'), Some(CjkChar::Bopomofo));
assert_eq!(CjkChar::Kana.preferred(&order), Japanese);
assert_eq!(CjkChar::Hangul.preferred(&order), Korean);
assert_eq!(CjkChar::Han.preferred(&order), SimplifiedChinese);
assert_eq!(CjkChar::Han.preferred(&script_order(Some("ja"))), Japanese);
}
/// `plutil -convert binary1` of a dict with AKLastLocale, AppleLanguages
/// [zh-Hant-TW, en-US] and AppleLocale.
const SAMPLE: &[u8] = &[
0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xd3, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x5b, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x4c, 0x6f, 0x63, 0x61,
0x6c, 0x65, 0x5c, 0x41, 0x4b, 0x4c, 0x61, 0x73, 0x74, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x5e, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x4c, 0x61, 0x6e, 0x67,
0x75, 0x61, 0x67, 0x65, 0x73, 0x55, 0x7a, 0x68, 0x5f, 0x54, 0x57, 0x55, 0x65, 0x6e, 0x5f, 0x55, 0x53, 0xa2, 0x07, 0x08, 0x5a, 0x7a, 0x68, 0x2d, 0x48,
0x61, 0x6e, 0x74, 0x2d, 0x54, 0x57, 0x55, 0x65, 0x6e, 0x2d, 0x55, 0x53, 0x08, 0x0f, 0x1b, 0x28, 0x37, 0x3d, 0x43, 0x46, 0x51, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x57,
];
#[test]
fn reads_apple_languages_and_rejects_garbage() {
assert_eq!(bplist_first_language(SAMPLE).as_deref(), Some("zh-Hant-TW"));
assert_eq!(script_order(bplist_first_language(SAMPLE).as_deref())[0], TraditionalChinese);
assert_eq!(bplist_first_language(b""), None);
assert_eq!(bplist_first_language(b"bplist00"), None);
assert_eq!(bplist_first_language(&[0xFF; 64]), None);
// Every truncation and every single-byte corruption must fail gracefully, never panic.
for n in 0..SAMPLE.len() {
let _ = bplist_first_language(&SAMPLE[..n]);
}
for i in 0..SAMPLE.len() {
for v in [0x00, 0x0F, 0x5F, 0xAF, 0xDF, 0xFF] {
let mut b = SAMPLE.to_vec();
b[i] = v;
let _ = bplist_first_language(&b);
}
}
}
#[test]
fn face_index_handles_bad_data() {
assert_eq!(face_index_for_family(b"", "PingFang SC"), 0);
assert_eq!(face_index_for_family(b"ttcf\0\0\0\0garbage", "PingFang SC"), 0);
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
assert_eq!(face_index_for_family(inter, "Inter"), 0);
assert_eq!(face_index_for_family(inter, ""), 0);
}
/// On a Mac with PingFang, the SC and TC faces are found by name (not a hard-coded index).
#[cfg(target_os = "macos")]
#[test]
fn pingfang_faces_by_name() {
let Some(p) = mac_pingfang() else { return };
let Ok(bytes) = std::fs::read(p) else { return };
let sc = face_index_for_family(&bytes, "PingFang SC");
let tc = face_index_for_family(&bytes, "PingFang TC");
assert_ne!(sc, tc);
}
}
+22 -25
View File
@@ -23,30 +23,22 @@ pub const BUNDLED: &[(&str, &[u8])] = &[
("JetBrainsMono-Regular.ttf", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")),
];
/// Families tried (if installed) after the requested one, for missing glyphs.
const FALLBACK_CANDIDATES: &[&str] = &[
"Noto Sans",
"Arial Unicode MS",
"Segoe UI",
"DejaVu Sans",
"Geeza Pro",
"Arial Hebrew",
"Noto Sans Arabic",
"Noto Sans Hebrew",
// Japanese first, so shared Han characters get Japanese forms before the SC fonts are tried.
"Hiragino Sans",
"Hiragino Kaku Gothic ProN",
"Yu Gothic",
"Meiryo",
"Noto Sans CJK JP",
"Noto Sans JP",
"IPAexGothic",
"Noto Sans CJK SC",
"PingFang SC",
"Apple Color Emoji",
"Segoe UI Emoji",
"Noto Color Emoji",
];
/// Families tried (if installed) after the requested one, for missing glyphs. The CJK families
/// ([`crate::cjk::families`]) go between these two lists, in the UI locale's script order.
const FALLBACK_CANDIDATES: &[&str] = &["Noto Sans", "Segoe UI", "DejaVu Sans", "Geeza Pro", "Arial Hebrew", "Noto Sans Arabic", "Noto Sans Hebrew"];
/// Broad-coverage and emoji fonts, tried after the CJK script fonts so shared Han characters get
/// the locale's forms instead of Arial Unicode's.
const FALLBACK_LAST: &[&str] = &["Arial Unicode MS", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"];
/// Every fallback family candidate, CJK ordered for `order` (see [`crate::cjk::script_order`]).
pub fn fallback_candidates(order: &[crate::cjk::CjkScript; 4]) -> Vec<&'static str> {
let mut v = FALLBACK_CANDIDATES.to_vec();
for s in order {
v.extend_from_slice(crate::cjk::families(*s));
}
v.extend_from_slice(FALLBACK_LAST);
v
}
/// One face in the database (for font menus).
#[derive(Clone, Debug, PartialEq)]
@@ -121,6 +113,10 @@ impl FontDb {
for d in system_font_dirs() {
collect_font_files(&d, 0, &mut files);
}
// PingFang (the macOS Chinese UI font) lives outside the font folders.
if cfg!(target_os = "macos") {
files.extend(crate::cjk::mac_pingfang());
}
for f in files {
self.fcx.collection.load_fonts_from_paths([f]);
}
@@ -161,7 +157,8 @@ impl FontDb {
}
}
}
self.fallbacks = FALLBACK_CANDIDATES.iter().filter(|f| c.family_id(f).is_some()).map(|s| s.to_string()).collect();
let order = crate::cjk::ui_script_order();
self.fallbacks = fallback_candidates(&order).into_iter().filter(|f| c.family_id(f).is_some()).map(str::to_string).collect();
}
/// Families available after the requested one (bundled default + installed coverage fonts).
+1
View File
@@ -14,6 +14,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod cjk;
pub mod engine_data;
pub mod fonts;
pub mod glyphs;
+318
View File
@@ -0,0 +1,318 @@
//! Lazy CJK fallback fonts for the UI.
//!
//! The bundled Inter / JetBrains Mono have no Japanese, Chinese or Korean glyphs, and the OS
//! fonts that do are large (Hiragino ~10 MB, Apple SD Gothic Neo 28 MB, PingFang 78 MB, Noto
//! Sans CJK ~20 MB). Instead of reading them at startup, an egui plugin scans each frame's text
//! for CJK characters no registered font covers, and registers the next system font for that
//! character's script (`ctx.add_font`, active from the next frame, which it requests). Fonts are
//! appended at the lowest priority to every family, so Latin text keeps Inter.
//!
//! Script order follows the UI locale ([`photocraft_text::cjk::script_order`]): Kana prefers a
//! Japanese font, Hangul a Korean one, Bopomofo a Traditional Chinese one, and Han the
//! locale's script (Japanese forms only for a Japanese locale). At most one font is read per
//! frame, each file at most once, and once every script has been tried the scan stops.
use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily};
use egui::{FontData, FontFamily, FontId, Shape};
use photocraft_text::cjk::{self, CjkChar, CjkScript, FontFile};
use std::path::PathBuf;
/// Skip absurdly large files (a corrupt or non-font path must not eat memory).
pub const MAX_FONT_BYTES: u64 = 128 << 20;
/// Name prefix of the registered fallback fonts.
pub const FONT_PREFIX: &str = "system-cjk";
/// Where fonts come from; swapped out in tests.
#[derive(Clone)]
pub struct Sources {
pub locale: fn() -> Option<String>,
pub files: fn(CjkScript) -> Vec<FontFile>,
pub last_resort: fn() -> Vec<FontFile>,
}
impl Sources {
#[cfg(not(target_arch = "wasm32"))]
pub fn system() -> Self {
Self { locale: || cjk::ui_locale().map(str::to_string), files: cjk::font_files, last_resort: cjk::last_resort_files }
}
#[cfg(target_arch = "wasm32")]
pub fn system() -> Self {
Self { locale: || None, files: |_| Vec::new(), last_resort: Vec::new }
}
}
/// The lazy loader's state: which scripts were tried and which files were read.
pub struct CjkFallback {
sources: Sources,
order: Option<[CjkScript; 4]>,
tried: Vec<CjkScript>,
last_resort_tried: bool,
loaded: Vec<PathBuf>,
/// Fonts registered so far: (name, path, face index).
pub registered: Vec<(String, PathBuf, u32)>,
}
impl CjkFallback {
pub fn new(sources: Sources) -> Self {
Self { sources, order: None, tried: Vec::new(), last_resort_tried: false, loaded: Vec::new(), registered: Vec::new() }
}
/// Script order for the UI locale (resolved on first use, not at startup).
pub fn order(&mut self) -> [CjkScript; 4] {
*self.order.get_or_insert_with(|| cjk::script_order((self.sources.locale)().as_deref()))
}
/// Every script and the last-resort fonts have been tried: nothing more to load.
pub fn exhausted(&self) -> bool {
self.tried.len() >= 4 && self.last_resort_tried
}
/// The next font to register for a character of kind `kind` that no current font covers:
/// the first readable file of the first untried script (the character's preferred script,
/// then the locale order), then the last-resort fonts. `None` when nothing is left.
pub fn next_font(&mut self, kind: CjkChar) -> Option<(String, FontData)> {
let order = self.order();
let scripts: Vec<CjkScript> = std::iter::once(kind.preferred(&order)).chain(order).collect();
for s in scripts {
if self.tried.contains(&s) {
continue;
}
self.tried.push(s);
let files = (self.sources.files)(s);
if let Some(f) = self.load_first(&files) {
return Some(f);
}
}
if !self.last_resort_tried {
self.last_resort_tried = true;
let files = (self.sources.last_resort)();
return self.load_first(&files);
}
None
}
fn load_first(&mut self, files: &[FontFile]) -> Option<(String, FontData)> {
for f in files {
if self.loaded.contains(&f.path) {
continue;
}
let Ok(meta) = std::fs::metadata(&f.path) else { continue };
if !meta.is_file() || meta.len() == 0 || meta.len() > MAX_FONT_BYTES {
continue;
}
let Ok(bytes) = std::fs::read(&f.path) else { continue };
let index = cjk::face_index_for_family(&bytes, f.family);
self.loaded.push(f.path.clone());
let name = format!("{FONT_PREFIX}-{}", self.registered.len());
log::info!("UI font fallback: registered {} (face {index}) as {name}", f.path.display());
self.registered.push((name.clone(), f.path.clone(), index));
let mut data = FontData::from_owned(bytes);
data.index = index;
return Some((name, data));
}
None
}
}
/// First CJK character in the frame's text that the UI fonts can't draw.
fn missing_char(ctx: &egui::Context, shapes: &[egui::epaint::ClippedShape]) -> Option<char> {
fn collect(shape: &Shape, out: &mut Vec<char>) {
match shape {
Shape::Text(t) => {
let text = &t.galley.job.text;
if !text.is_ascii() {
for c in text.chars().filter(|c| cjk::classify(*c).is_some()) {
if out.len() >= 256 {
return;
}
if !out.contains(&c) {
out.push(c);
}
}
}
}
Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
_ => {}
}
}
let mut chars = Vec::new();
for s in shapes {
collect(&s.shape, &mut chars);
}
if chars.is_empty() {
return None;
}
let font = FontId::proportional(12.0);
ctx.fonts_mut(|f| chars.into_iter().find(|c| !f.has_glyph(&font, *c)))
}
/// Registers `name` at the lowest priority in every font family.
fn add_to_all_families(ctx: &egui::Context, name: String, data: FontData) {
let mut families: Vec<FontFamily> = ctx.fonts(|f| f.definitions().families.keys().cloned().collect());
for f in [FontFamily::Proportional, FontFamily::Monospace] {
if !families.contains(&f) {
families.push(f);
}
}
let families = families.into_iter().map(|family| InsertFontFamily { family, priority: FontPriority::Lowest }).collect();
ctx.add_font(FontInsert { name, data, families });
}
/// The egui plugin that watches drawn text and loads fallback fonts on demand.
pub struct CjkFontPlugin(pub CjkFallback);
impl egui::Plugin for CjkFontPlugin {
fn debug_name(&self) -> &'static str {
"photocraft-cjk-fonts"
}
fn output_hook(&mut self, ctx: &egui::Context, output: &mut egui::FullOutput) {
if self.0.exhausted() {
return;
}
let Some(c) = missing_char(ctx, &output.shapes) else { return };
let Some(kind) = cjk::classify(c) else { return };
if let Some((name, data)) = self.0.next_font(kind) {
add_to_all_families(ctx, name, data);
ctx.request_repaint();
} else if !self.0.exhausted() {
// Nothing readable for this script; try the next one on the next frame.
ctx.request_repaint();
}
}
}
/// Installs the lazy CJK fallback (system fonts; nothing on the web).
pub fn install(ctx: &egui::Context) {
install_with(ctx, Sources::system());
}
pub fn install_with(ctx: &egui::Context, sources: Sources) {
ctx.add_plugin(CjkFontPlugin(CjkFallback::new(sources)));
}
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests {
use super::*;
use std::sync::Mutex;
static DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
fn fake_dir() -> PathBuf {
let mut g = DIR.lock().unwrap();
g.get_or_insert_with(|| {
let dir = std::env::temp_dir().join(format!("photocraft-cjk-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("font.ttf"), include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap();
std::fs::write(dir.join("empty.ttf"), b"").unwrap();
dir
})
.clone()
}
fn fake(name: &str) -> FontFile {
FontFile { path: fake_dir().join(name), family: "" }
}
fn fake_sources(locale: fn() -> Option<String>) -> Sources {
Sources {
locale,
// Korean has a readable "font", Japanese only broken files, Chinese none.
files: |s| match s {
CjkScript::Korean => vec![fake("missing.ttc"), fake("empty.ttf"), fake("font.ttf")],
CjkScript::Japanese => vec![fake("empty.ttf")],
_ => vec![],
},
last_resort: || vec![fake("font.ttf")],
}
}
#[test]
fn picks_preferred_script_skips_unreadable_and_exhausts() {
let mut fb = CjkFallback::new(fake_sources(|| Some("en_US".into())));
assert!(!fb.exhausted());
// Hangul: Korean first; missing and empty files are skipped.
let (name, data) = fb.next_font(CjkChar::Hangul).expect("korean font");
assert!(name.starts_with(FONT_PREFIX));
assert_eq!(data.index, 0);
assert_eq!(fb.tried, vec![CjkScript::Korean]);
// Han on an English locale: SC, TC, JA have nothing readable, and the last-resort file was
// already loaded, so nothing new; everything is now tried.
assert!(fb.next_font(CjkChar::Han).is_none());
assert!(fb.exhausted());
assert_eq!(fb.tried, vec![CjkScript::Korean, CjkScript::SimplifiedChinese, CjkScript::TraditionalChinese, CjkScript::Japanese]);
assert_eq!(fb.registered.len(), 1, "a file is read at most once");
assert!(fb.next_font(CjkChar::Kana).is_none());
}
#[test]
fn han_follows_locale() {
for (loc, first) in [
("ja_JP.UTF-8", CjkScript::Japanese),
("zh-Hans-CN", CjkScript::SimplifiedChinese),
("zh-Hant-TW", CjkScript::TraditionalChinese),
("ko_KR", CjkScript::Korean),
] {
let mut fb = CjkFallback::new(Sources { locale: || None, files: |_| vec![], last_resort: Vec::new });
fb.order = Some(cjk::script_order(Some(loc)));
assert!(fb.next_font(CjkChar::Han).is_none());
assert_eq!(fb.tried[0], first, "{loc}");
}
}
/// Runs frames showing `text` until fonts settle; returns whether every glyph is covered.
fn render(ctx: &egui::Context, text: &str) -> bool {
for _ in 0..12 {
let mut out = ctx.run_ui(Default::default(), |ui| {
ui.label(text);
});
out.textures_delta.clear();
}
ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(12.0), &text.replace(' ', "")))
}
#[test]
fn lazy_registration_triggers_only_for_cjk() {
let ctx = egui::Context::default();
install_with(&ctx, fake_sources(|| Some("ko".into())));
let _ = render(&ctx, "Layer 1 – café");
let n = ctx.fonts(|f| f.definitions().font_data.keys().filter(|k| k.starts_with(FONT_PREFIX)).count());
assert_eq!(n, 0, "Latin text loads nothing");
// Hangul isn't in the stand-in font, but the Korean candidate must have been registered.
let _ = render(&ctx, "카드 배경");
let fonts = ctx.fonts(|f| f.definitions().clone());
let name = format!("{FONT_PREFIX}-0");
assert!(fonts.font_data.contains_key(&name));
for (fam, stack) in &fonts.families {
assert_eq!(stack.last(), Some(&name), "{fam:?}: appended last so Latin keeps Inter");
}
}
/// With the real system fonts, every CJK sample renders (skipped per script when the
/// machine has no font for it, e.g. CI Linux without Noto CJK).
#[test]
fn system_fonts_cover_cjk_samples() {
let available = |s: CjkScript| cjk::font_files(s).iter().any(|f| f.path.is_file());
let samples = [
("图层", CjkScript::SimplifiedChinese),
("圖層", CjkScript::TraditionalChinese),
("카드 배경", CjkScript::Korean),
("レイヤー", CjkScript::Japanese),
];
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
for (text, script) in samples {
if !available(script) {
eprintln!("skipping {text}: no {script:?} system font");
continue;
}
assert!(render(&ctx, text), "{text} still has missing glyphs");
}
// All at once in a fresh context too (several scripts in one frame).
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let all: String = samples.iter().filter(|(_, s)| available(*s)).map(|(t, _)| *t).collect::<Vec<_>>().join(" ");
assert!(render(&ctx, &all), "{all}");
}
}
+1
View File
@@ -23,6 +23,7 @@ pub mod canvas;
pub mod channel_view;
pub mod channels_panel;
pub mod chrome_ui;
pub mod cjk_fonts;
pub mod color_picker_ui;
pub mod color_range_ui;
pub mod comps_ui;
+2 -90
View File
@@ -290,68 +290,9 @@ pub fn install_fonts(ctx: &egui::Context) {
stack.extend(fallback.iter().cloned());
fonts.families.insert(FontFamily::Name(fam.into()), stack);
}
#[cfg(not(target_arch = "wasm32"))]
add_cjk_fallback(&mut fonts, &cjk_font_candidates());
ctx.set_fonts(fonts);
}
/// Name under which the OS Japanese/CJK font is registered.
const CJK_FONT: &str = "system-cjk";
/// Skip absurdly large files (a corrupt or non-font path must not eat memory).
const CJK_MAX_BYTES: u64 = 128 << 20;
/// OS-installed fonts with Japanese coverage, most preferred first: (path, face index in a collection).
#[cfg(not(target_arch = "wasm32"))]
fn cjk_font_candidates() -> Vec<(std::path::PathBuf, u32)> {
use std::path::PathBuf;
let list: &[(&str, u32)] = if cfg!(target_os = "macos") {
&[
("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", 0),
("/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc", 0),
("/Library/Fonts/Arial Unicode.ttf", 0),
("/System/Library/Fonts/Hiragino Sans GB.ttc", 0),
]
} else if cfg!(target_os = "windows") {
&[
("C:\\Windows\\Fonts\\YuGothM.ttc", 0),
("C:\\Windows\\Fonts\\YuGothR.ttc", 0),
("C:\\Windows\\Fonts\\meiryo.ttc", 0),
("C:\\Windows\\Fonts\\msgothic.ttc", 0),
]
} else {
&[
("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 0),
("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", 0),
("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", 0),
("/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", 0),
("/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf", 0),
("/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", 0),
("/usr/share/fonts/truetype/takao-gothic/TakaoPGothic.ttf", 0),
]
};
list.iter().map(|(p, i)| (PathBuf::from(p), *i)).collect()
}
/// Appends the first readable font from `candidates` to the end of every family's stack, so
/// Latin text keeps using Inter / JetBrains Mono and only missing glyphs (Japanese) fall through.
/// Without it egui draws CJK as tofu. Returns whether a font was added; none found is not an error.
#[cfg(not(target_arch = "wasm32"))]
fn add_cjk_fallback(fonts: &mut FontDefinitions, candidates: &[(std::path::PathBuf, u32)]) -> bool {
for (path, index) in candidates {
let Ok(meta) = std::fs::metadata(path) else { continue };
if !meta.is_file() || meta.len() == 0 || meta.len() > CJK_MAX_BYTES {
continue;
}
let Ok(bytes) = std::fs::read(path) else { continue };
let mut data = FontData::from_owned(bytes);
data.index = *index;
fonts.font_data.insert(CJK_FONT.to_owned(), Arc::new(data));
for stack in fonts.families.values_mut() {
stack.push(CJK_FONT.to_owned());
}
return true;
}
false
// Japanese / Chinese / Korean system fonts are registered on demand (cjk_fonts.rs).
crate::cjk_fonts::install(ctx);
}
pub fn medium(size: f32) -> FontId {
@@ -596,32 +537,3 @@ pub mod live {
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod cjk_tests {
use super::*;
#[test]
fn fallback_is_appended_to_every_family_and_missing_files_are_skipped() {
let dir = std::env::temp_dir().join(format!("photocraft-cjk-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let font = dir.join("f.ttf");
std::fs::write(&font, include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap();
let empty = dir.join("empty.ttf");
std::fs::write(&empty, b"").unwrap();
let mut fonts = FontDefinitions::default();
let before: Vec<usize> = fonts.families.values().map(Vec::len).collect();
assert!(add_cjk_fallback(&mut fonts, &[(dir.join("missing.ttc"), 0), (empty, 0), (dir.join("nope"), 0), (font, 0)]));
assert!(fonts.font_data.contains_key(CJK_FONT));
for (stack, n) in fonts.families.values().zip(before) {
assert_eq!(stack.len(), n + 1);
assert_eq!(stack.last().map(String::as_str), Some(CJK_FONT), "must be last so Latin keeps its own font");
}
// Nothing usable: untouched.
let mut fonts = FontDefinitions::default();
assert!(!add_cjk_fallback(&mut fonts, &[(dir.join("missing.ttc"), 0)]));
assert!(!fonts.font_data.contains_key(CJK_FONT));
let _ = std::fs::remove_dir_all(&dir);
}
}