text: script-aware system font fallback and PostScript / full font names

Japanese (and Chinese, Korean) text in a family that lacks the glyphs, such as the default Inter,
rendered as missing-glyph boxes: `fallback_for` only searched the bundled faces and faces some
other layer had already loaded, so the result depended on render order. Now, after those cheap
candidates, it tries the platform's usual family for the character's writing system (kana and
hangul pick their language; Han ideographs follow the locale, Japanese first when it is unknown,
`EFFECTCRAFT_CJK_LOCALE` overrides) in the preferred face's style, and finally any installed face
that covers the character, reading files without keeping them. Results are cached per 256-character
block and preferred face.

`resolve` also accepts a face's full name (`Yu Gothic Bold`) or PostScript name (`YuGothic-Bold`),
which is how After Effects scripts set `textDocument.font`; the sfnt reader now records name id 6.

Tests: script classification, PostScript / full-name resolution on the bundled Inter, and (native,
skipped when no Japanese family is installed) that kana, ideographs and CJK punctuation in Inter
Bold fall back to an installed Japanese family's bold cut, with the cache returning the same face.
This commit is contained in:
fortesnow 2026-10-06 10:36:48 +09:00 committed by fortesnow
parent 857340036b
commit c8e9efb962
2 changed files with 258 additions and 7 deletions

View File

@ -3,7 +3,11 @@
//!
//! Faces are addressed by [`FaceId`] (an index that never changes for the life of the process).
//! [`resolve`] maps a family + style name (as stored in projects) to a face and the synthetic
//! bold/italic needed when the family lacks that style. Unknown families fall back to Inter.
//! bold/italic needed when the family lacks that style; a full name (`Yu Gothic Bold`) or
//! PostScript name (`YuGothic-Bold`, how After Effects scripts name fonts) is accepted too.
//! Unknown families fall back to Inter. [`fallback_for`] finds a face for a character the chosen
//! face lacks: bundled faces first, then the system's usual family for the character's script
//! (CJK), then any installed face that covers it.
use std::path::PathBuf;
use std::sync::{Arc, OnceLock, RwLock};
@ -50,6 +54,10 @@ pub struct FaceInfo {
pub origin: &'static str,
/// The family name in the font's own (non-English) language, when it has one.
pub native_family: Option<String>,
/// The full name (`Yu Gothic Bold`) and PostScript name (`YuGothic-Bold`), so a face can also
/// be requested the way After Effects scripts name fonts.
pub full_name: String,
pub postscript: Option<String>,
}
/// Something that can list fonts (system folders, a web font picker, a project's font folder…).
@ -123,7 +131,18 @@ impl FontSource for DirectorySource {
}
fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo {
FaceInfo { family: n.family, style: n.style, weight: n.weight, italic: n.italic, index: n.index, data, origin, native_family: n.native_family }
FaceInfo {
family: n.family,
style: n.style,
weight: n.weight,
italic: n.italic,
index: n.index,
data,
origin,
native_family: n.native_family,
full_name: n.full_name,
postscript: n.postscript,
}
}
/// A loaded (or loadable) face.
@ -382,12 +401,12 @@ fn style_wants(style: &str) -> (u16, bool) {
/// Resolve a family + style name. Family matching is case-insensitive; a style that the family
/// lacks picks the nearest weight and synthesises bold (≥ 600 requested, ≤ 500 found) or italic.
pub fn resolve(family: &str, style: &str) -> Resolved {
if let Some(r) = resolve_in(family, style) {
if let Some(r) = resolve_in(family, style).or_else(|| resolve_by_name(family)) {
return r;
}
if !system_scanned() && !family.is_empty() && !family.eq_ignore_ascii_case(DEFAULT_FAMILY) {
scan_system();
if let Some(r) = resolve_in(family, style) {
if let Some(r) = resolve_in(family, style).or_else(|| resolve_by_name(family)) {
return r;
}
}
@ -414,7 +433,140 @@ fn resolve_in(family: &str, style: &str) -> Option<Resolved> {
Some(Resolved { face: best.id, synth_bold: w >= 600 && best.info.weight <= 500, synth_italic: it && !best.info.italic, missing: false })
}
/// A face that covers `c`, preferring `prefer`; bundled and already-registered faces are searched.
/// A face whose full name or PostScript name is `name` (case-insensitive), the way After Effects
/// scripts (`textDocument.font = "YuGothic-Bold"`) and `.aep`-derived data refer to fonts.
fn resolve_by_name(name: &str) -> Option<Resolved> {
let name = name.trim();
if name.is_empty() {
return None;
}
let faces = all_faces();
let f = faces
.iter()
.find(|f| f.info.postscript.as_deref().is_some_and(|p| p.eq_ignore_ascii_case(name)))
.or_else(|| faces.iter().find(|f| f.info.full_name.eq_ignore_ascii_case(name)))?;
Some(Resolved { face: f.id, synth_bold: false, synth_italic: false, missing: false })
}
/// The writing systems that need a dedicated fallback family: the bundled fonts cover none of
/// them, and picking "any face with the glyph" gives Chinese glyph shapes to Japanese text (or the
/// other way round), since Han ideographs are shared.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum FallbackScript {
/// Kana: unambiguously Japanese.
Japanese,
/// Hangul: unambiguously Korean.
Korean,
/// Han ideographs, CJK punctuation and full-width forms: shared by Japanese, Chinese and
/// Korean; the locale decides which family to try first.
Han,
Other,
}
fn script_of(c: char) -> FallbackScript {
match c as u32 {
0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F => FallbackScript::Japanese,
0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF => FallbackScript::Korean,
0x2E80..=0x2FDF
| 0x3000..=0x303F
| 0x3100..=0x312F
| 0x3190..=0x31EF
| 0x3200..=0x9FFF
| 0xF900..=0xFAFF
| 0xFE30..=0xFE4F
| 0xFF00..=0xFFEF
| 0x20000..=0x3FFFF => FallbackScript::Han,
_ => FallbackScript::Other,
}
}
/// Families usually installed for each CJK language, in the order the platforms' own fallback
/// tables prefer them (Windows, macOS, then the Linux/Noto names). Missing ones cost one name
/// lookup each.
const JAPANESE_FAMILIES: &[&str] = &[
"Yu Gothic",
"Meiryo",
"BIZ UDPGothic",
"MS Gothic",
"Yu Gothic UI",
"Hiragino Sans",
"Hiragino Kaku Gothic ProN",
"Noto Sans CJK JP",
"Noto Sans JP",
"Source Han Sans JP",
"Source Han Sans",
"IPAGothic",
"VL Gothic",
];
const CHINESE_FAMILIES: &[&str] = &[
"Microsoft YaHei",
"Microsoft JhengHei",
"SimSun",
"MingLiU",
"PingFang SC",
"PingFang TC",
"Noto Sans CJK SC",
"Noto Sans CJK TC",
"Noto Sans SC",
"Noto Sans TC",
"Source Han Sans SC",
"Source Han Sans TC",
"WenQuanYi Micro Hei",
];
const KOREAN_FAMILIES: &[&str] = &["Malgun Gothic", "Gulim", "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans KR", "NanumGothic"];
/// The CJK language to try first for Han ideographs, from the locale environment (`LANG`,
/// `LC_ALL`, `LC_CTYPE`; `EFFECTCRAFT_CJK_LOCALE` overrides them). Japanese when nothing says
/// otherwise: the ambiguity only matters for ideographs, and kana / hangul in the same text pick
/// their language regardless.
fn han_order() -> [FallbackScript; 3] {
let tag = ["EFFECTCRAFT_CJK_LOCALE", "LC_ALL", "LC_CTYPE", "LANG"]
.iter()
.filter_map(|k| std::env::var(k).ok())
.map(|v| v.trim().to_ascii_lowercase())
.find(|v| !v.is_empty() && v != "c" && v != "posix")
.unwrap_or_default();
if tag.starts_with("zh") {
[FallbackScript::Han, FallbackScript::Japanese, FallbackScript::Korean]
} else if tag.starts_with("ko") {
[FallbackScript::Korean, FallbackScript::Japanese, FallbackScript::Han]
} else {
[FallbackScript::Japanese, FallbackScript::Han, FallbackScript::Korean]
}
}
fn families_for(s: FallbackScript) -> &'static [&'static str] {
match s {
FallbackScript::Japanese => JAPANESE_FAMILIES,
FallbackScript::Han => CHINESE_FAMILIES,
FallbackScript::Korean => KOREAN_FAMILIES,
FallbackScript::Other => &[],
}
}
/// Whether a not-yet-loaded face covers `c`, reading its file without keeping the bytes (the
/// last-resort search may look at every installed font once).
fn file_covers(f: &Face, c: char) -> bool {
if f.bytes.get().is_some() {
return f.has_char(c);
}
let FaceData::File(p) = &f.info.data else { return f.has_char(c) };
let Ok(data) = std::fs::read(p) else { return false };
FontRef::from_index(&data, f.info.index).ok().and_then(|font| font.charmap().map(c)).is_some_and(|g| g.to_u32() != 0)
}
/// Results of the expensive part of [`fallback_for`], per 256-character block and preferred face
/// (so bold text gets the bold cut of the fallback family). `None` records that nothing installed
/// covers the block, so the search over every font runs once per block, not once per character.
fn fallback_cache() -> &'static RwLock<std::collections::HashMap<(u32, FaceId), Option<FaceId>>> {
static CACHE: OnceLock<RwLock<std::collections::HashMap<(u32, FaceId), Option<FaceId>>>> = OnceLock::new();
CACHE.get_or_init(Default::default)
}
/// A face that covers `c`, preferring `prefer`. Bundled faces and faces already loaded are tried
/// first (cheap); then, for CJK characters, the platform's usual family for that language, in the
/// preferred face's style; then any installed face that covers the character. `prefer` when
/// nothing does (the glyph renders as the missing-glyph box).
pub fn fallback_for(c: char, prefer: FaceId) -> FaceId {
let p = face(prefer);
if p.has_char(c) || c.is_control() {
@ -432,7 +584,49 @@ pub fn fallback_for(c: char, prefer: FaceId) -> FaceId {
return f.id;
}
}
prefer
let key = (c as u32 >> 8, prefer);
if let Some(cached) = fallback_cache().read().unwrap_or_else(|e| e.into_inner()).get(&key).copied() {
match cached {
Some(id) if face(id).has_char(c) => return id,
Some(_) => {}
None => return prefer,
}
}
if cfg!(not(target_arch = "wasm32")) && !system_scanned() {
scan_system();
}
let found = system_fallback(c, &p.info.style);
fallback_cache().write().unwrap_or_else(|e| e.into_inner()).insert(key, found);
found.unwrap_or(prefer)
}
/// The script-aware then exhaustive search behind [`fallback_for`].
fn system_fallback(c: char, style: &str) -> Option<FaceId> {
let script = script_of(c);
let order: Vec<FallbackScript> = match script {
FallbackScript::Other => vec![],
FallbackScript::Han => han_order().to_vec(),
s => {
let mut v = vec![s];
v.extend(han_order().iter().copied().filter(|o| *o != s));
v
}
};
for s in order {
for fam in families_for(s) {
if let Some(r) = resolve_in(fam, style)
&& face(r.face).has_char(c)
{
return Some(r.face);
}
}
}
// Anything installed, in scan order; the style's weight and slant break ties among the faces
// of the first covering family.
let faces = all_faces();
let first = faces.iter().find(|f| f.info.origin != "bundled" && file_covers(f, c))?;
let r = resolve_in(&first.info.family, style)?;
Some(if face(r.face).has_char(c) { r.face } else { first.id })
}
#[cfg(test)]
@ -484,6 +678,59 @@ mod tests {
}
}
#[test]
fn postscript_and_full_names_resolve() {
// Bundled Inter: name id 6 is `Inter-Bold`, name id 4 is `Inter Bold`.
let ps = resolve("Inter-Bold", "");
assert_eq!(face(ps.face).info.style, "Bold");
assert!(!ps.missing && !ps.synth_bold);
let full = resolve("inter semibold", "");
assert_eq!(face(full.face).info.style, "SemiBold");
assert!(!full.missing);
// A family name still wins over a name lookup.
assert_eq!(face(resolve("Inter", "Bold").face).info.style, "Bold");
}
#[test]
fn scripts_are_classified() {
assert_eq!(script_of('あ'), FallbackScript::Japanese);
assert_eq!(script_of('カ'), FallbackScript::Japanese);
assert_eq!(script_of('한'), FallbackScript::Korean);
assert_eq!(script_of('水'), FallbackScript::Han);
assert_eq!(script_of('、'), FallbackScript::Han);
assert_eq!(script_of('A'), FallbackScript::Han);
assert_eq!(script_of('\u{20000}'), FallbackScript::Han);
assert_eq!(script_of('A'), FallbackScript::Other);
assert_eq!(script_of('\u{5d0}'), FallbackScript::Other);
}
/// Native: Japanese text in a Latin-only family falls back to an installed Japanese family
/// (whichever the platform ships) instead of the missing-glyph box, without any layer having
/// loaded a CJK font first. Skipped where no family from the list is installed.
#[test]
fn cjk_falls_back_to_a_system_family() {
scan_system();
let inter = resolve("Inter", "Bold").face;
let installed = JAPANESE_FAMILIES.iter().any(|f| resolve_in(f, "Regular").is_some());
if !installed {
eprintln!("no Japanese family installed; skipping");
return;
}
for c in ['水', 'あ', 'ア', '。'] {
let id = fallback_for(c, inter);
let f = face(id);
assert!(f.has_char(c), "{c}: {} {} lacks it", f.info.family, f.info.style);
assert!(JAPANESE_FAMILIES.iter().any(|n| f.info.family.eq_ignore_ascii_case(n)), "{c} → {}", f.info.family);
}
// The bold cut of the fallback family is chosen for bold text when the family has one.
let f = face(fallback_for('水', inter));
let (w, _) = style_wants(&f.info.style);
let has_bold = all_faces().iter().any(|g| g.info.family == f.info.family && style_wants(&g.info.style).0 >= 600);
assert!(!has_bold || w >= 600, "{} {}", f.info.family, f.info.style);
// Cached: the second call is a lookup.
assert_eq!(fallback_for('水', inter), f.id);
}
#[test]
fn metrics_and_coverage() {
let f = face(resolve("Inter", "Regular").face);

View File

@ -16,6 +16,9 @@ pub struct FaceNames {
/// The family name in another language than English (CJK, Arabic… fonts), when the font
/// has one (Settings ▸ Type ▸ Show Font Names in English off shows it).
pub native_family: Option<String>,
/// The PostScript name (name id 6, e.g. `YuGothic-Bold`): what After Effects scripts and
/// `.aep`-derived data call a font.
pub postscript: Option<String>,
}
fn be16(b: &[u8], o: usize) -> Option<u16> {
@ -79,6 +82,7 @@ fn read_face<R: Read + Seek>(r: &mut R, off: u64, index: u32) -> Option<FaceName
let style = get(17).or_else(|| get(2)).unwrap_or_else(|| "Regular".into());
let full_name = get(4).unwrap_or_else(|| format!("{family} {style}"));
let native_family = native_name(&nb, 16).or_else(|| native_name(&nb, 1)).filter(|n| *n != family);
let postscript = get(6).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let (mut weight, mut italic) = (400u16, false);
if let Some((oo, ol)) = os2
&& let Some(ob) = read_at(r, oo, ol.min(78))
@ -88,7 +92,7 @@ fn read_face<R: Read + Seek>(r: &mut R, off: u64, index: u32) -> Option<FaceName
}
let sl = style.to_ascii_lowercase();
italic |= sl.contains("italic") || sl.contains("oblique");
Some(FaceNames { index, family, style, full_name, weight, italic, native_family })
Some(FaceNames { index, family, style, full_name, weight, italic, native_family, postscript })
}
/// A Windows Unicode name-table string by id in a language other than US English.