mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-16 20:26:53 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02b27d566c |
No files matched your search
@@ -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
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user