use std::collections::{HashSet, VecDeque};
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CjkScript {
PanCjk,
Japanese,
Korean,
SimplifiedChinese,
TraditionalChinese,
}
impl CjkScript {
const COUNT: usize = 5;
const fn slot(self) -> usize {
match self {
Self::PanCjk => 0,
Self::Japanese => 1,
Self::Korean => 2,
Self::SimplifiedChinese => 3,
Self::TraditionalChinese => 4,
}
}
}
pub const CJK_FONT_STEMS: &[(&str, CjkScript)] = &[
("notosanscjk", CjkScript::PanCjk),
("notoserifcjk", CjkScript::PanCjk),
("sourcehansansjp", CjkScript::Japanese),
("sourcehansanskr", CjkScript::Korean),
("sourcehansanscn", CjkScript::SimplifiedChinese),
("sourcehansanstw", CjkScript::TraditionalChinese),
("sourcehansanshk", CjkScript::TraditionalChinese),
("sourcehanserifjp", CjkScript::Japanese),
("sourcehanserifkr", CjkScript::Korean),
("sourcehanserifcn", CjkScript::SimplifiedChinese),
("sourcehanseriftw", CjkScript::TraditionalChinese),
("sourcehanserifhk", CjkScript::TraditionalChinese),
("sourcehansans", CjkScript::PanCjk),
("sourcehanserif", CjkScript::PanCjk),
("notosansjp", CjkScript::Japanese),
("notosanskr", CjkScript::Korean),
("notosanssc", CjkScript::SimplifiedChinese),
("notosanstc", CjkScript::TraditionalChinese),
("notosanshk", CjkScript::TraditionalChinese),
("droidsansfallbackfull", CjkScript::PanCjk),
("droidsansfallback", CjkScript::SimplifiedChinese),
("droidsansjapanese", CjkScript::Japanese),
("ヒラギノ角ゴシックw3", CjkScript::Japanese),
("ヒラギノ角ゴシックw4", CjkScript::Japanese),
("ヒラギノ角ゴシック", CjkScript::Japanese),
("ヒラギノ丸ゴ", CjkScript::Japanese),
("ヒラギノ明朝", CjkScript::Japanese),
("pingfang", CjkScript::TraditionalChinese),
("hiraginosansgb", CjkScript::SimplifiedChinese),
("stheitimedium", CjkScript::TraditionalChinese),
("stheiti", CjkScript::TraditionalChinese),
("hiragino", CjkScript::Japanese),
("osaka", CjkScript::Japanese),
("applesdgothicneo", CjkScript::Korean),
("meiryo", CjkScript::Japanese),
("yugothm", CjkScript::Japanese),
("yugothr", CjkScript::Japanese),
("yugoth", CjkScript::Japanese),
("msgothic", CjkScript::Japanese),
("malgun", CjkScript::Korean),
("msyh", CjkScript::SimplifiedChinese),
("msjh", CjkScript::TraditionalChinese),
("simsun", CjkScript::SimplifiedChinese),
("simhei", CjkScript::SimplifiedChinese),
("microsoftjhenghei", CjkScript::TraditionalChinese),
];
pub const LATIN_BOLD_FONT_STEMS: &[&str] = &[
"segoeuib",
"arialbd",
"tahomabd",
"verdanab",
"arialbold",
"notosansbold",
"dejavusansbold",
"liberationsansbold",
];
pub const CJK_BOLD_FONT_STEMS: &[(&str, CjkScript)] = &[
("notosanscjkbold", CjkScript::PanCjk),
("sourcehansansbold", CjkScript::PanCjk),
("sourcehanserifbold", CjkScript::PanCjk),
("meiryob", CjkScript::Japanese),
("yugothb", CjkScript::Japanese),
("malgunbd", CjkScript::Korean),
("msyhbd", CjkScript::SimplifiedChinese),
("msjhbd", CjkScript::TraditionalChinese),
];
pub const CJK_STEM_EXCLUSIONS: &[&str] = &["simsunb", "simsunext"];
pub const FONT_EXTENSIONS: &[&str] = &["ttf", "otf", "ttc"];
pub const SFNT_SIGNATURES: &[[u8; 4]] = &[[0x00, 0x01, 0x00, 0x00], *b"OTTO", *b"true"];
pub const TTC_SIGNATURE: [u8; 4] = *b"ttcf";
#[must_use]
pub fn is_sfnt(bytes: &[u8]) -> bool {
let Some(magic) = bytes.get(..4) else {
return false;
};
SFNT_SIGNATURES
.iter()
.any(|signature| signature.as_slice() == magic)
}
#[must_use]
pub fn is_ttc(bytes: &[u8]) -> bool {
bytes.get(..4) == Some(TTC_SIGNATURE.as_slice())
}
pub const MAX_SCAN_DEPTH: usize = 3;
pub const MAX_FONT_BYTES: u64 = 64 * 1024 * 1024;
const TIER_USER: usize = 0;
const TIER_SYSTEM: usize = 1;
#[derive(Debug, Clone)]
struct Candidate {
tier: usize,
rank: usize,
extra: usize,
path: PathBuf,
}
#[must_use]
pub fn read_cjk_font(path: &Path) -> Option<Vec<u8>> {
use std::io::Read as _;
let file = match std::fs::File::open(path) {
Ok(file) => file,
Err(error) => {
tracing::debug!(
path = %path.display(),
%error,
"OxiGIS desktop: a CJK fallback font could not be opened",
);
return None;
}
};
let size = match file.metadata() {
Ok(meta) => meta.len(),
Err(error) => {
tracing::debug!(
path = %path.display(),
%error,
"OxiGIS desktop: a CJK fallback font could not be sized",
);
return None;
}
};
if size == 0 || size > MAX_FONT_BYTES {
tracing::debug!(
path = %path.display(),
size,
"OxiGIS desktop: the CJK candidate's size changed out of bounds; dropped",
);
return None;
}
let mut bytes = Vec::with_capacity(size as usize);
if let Err(error) = file.take(MAX_FONT_BYTES + 1).read_to_end(&mut bytes) {
tracing::debug!(
path = %path.display(),
%error,
"OxiGIS desktop: a CJK fallback font could not be read",
);
return None;
}
if bytes.len() as u64 > MAX_FONT_BYTES || !(is_sfnt(&bytes) || is_ttc(&bytes)) {
tracing::debug!(
path = %path.display(),
bytes = bytes.len(),
"OxiGIS desktop: the CJK candidate changed on disk and is no longer loadable; dropped",
);
return None;
}
tracing::info!(
path = %path.display(),
bytes = bytes.len(),
container = if is_ttc(&bytes) { "ttc (face 0)" } else { "sfnt" },
stem = cjk_stem_rank(path).map(|rank| CJK_FONT_STEMS[rank].0),
"OxiGIS desktop: CJK label fallback font loaded",
);
Some(bytes)
}
#[must_use]
pub fn find_cjk_font_paths() -> Vec<PathBuf> {
let user = oxifont_discovery::user_font_dirs();
let system = oxifont_discovery::system_font_dirs();
let chain = chain_for_tiers(&user, &system);
if chain.is_empty() {
tracing::info!(
user_directories = user.len(),
system_directories = system.len(),
"OxiGIS desktop: no CJK font found; CJK labels will render as .notdef",
);
}
chain.into_iter().map(|candidate| candidate.path).collect()
}
#[must_use]
pub fn find_cjk_bold_font_paths() -> Vec<PathBuf> {
let user = oxifont_discovery::user_font_dirs();
let system = oxifont_discovery::system_font_dirs();
let mut chain = latin_bold_for_tiers(&user, &system)
.into_iter()
.collect::<Vec<_>>();
chain.extend(
chain_for_tiers_of(&user, &system, CJK_BOLD_FONT_STEMS)
.into_iter()
.map(|candidate| candidate.path),
);
if chain.is_empty() {
tracing::info!(
user_directories = user.len(),
system_directories = system.len(),
"OxiGIS desktop: no bold font found; Bold labels will draw Regular",
);
}
chain
}
fn latin_bold_for_tiers(user_dirs: &[PathBuf], system_dirs: &[PathBuf]) -> Option<PathBuf> {
let mut best: Option<Candidate> = None;
for (tier, dirs) in [(TIER_USER, user_dirs), (TIER_SYSTEM, system_dirs)] {
walk_tier(dirs, &mut |path| {
let Some((rank, extra)) = latin_bold_stem_match(path) else {
return;
};
let improves = match best.as_ref() {
None => true,
Some(held) => (tier, rank, extra) < (held.tier, held.rank, held.extra),
};
if !improves || !is_readable_size(path) || !sniffs_as_font(path) {
return;
}
best = Some(Candidate {
tier,
rank,
extra,
path: path.to_path_buf(),
});
});
}
best.map(|candidate| candidate.path)
}
fn latin_bold_stem_match(path: &Path) -> Option<(usize, usize)> {
let normalized = normalized_font_stem(path)?;
LATIN_BOLD_FONT_STEMS
.iter()
.position(|known| normalized.starts_with(known))
.map(|rank| (rank, normalized.len() - LATIN_BOLD_FONT_STEMS[rank].len()))
}
fn chain_for_tiers(user_dirs: &[PathBuf], system_dirs: &[PathBuf]) -> Vec<Candidate> {
chain_for_tiers_of(user_dirs, system_dirs, CJK_FONT_STEMS)
}
fn chain_for_tiers_of(
user_dirs: &[PathBuf],
system_dirs: &[PathBuf],
stems: &[(&str, CjkScript)],
) -> Vec<Candidate> {
let mut slots = scan_tier(user_dirs, TIER_USER, stems);
let system = scan_tier(system_dirs, TIER_SYSTEM, stems);
for (slot, from_system) in slots.iter_mut().zip(system) {
if slot.is_none() {
*slot = from_system;
}
}
assemble_chain(slots, stems)
}
fn assemble_chain(
slots: [Option<Candidate>; CjkScript::COUNT],
stems: &[(&str, CjkScript)],
) -> Vec<Candidate> {
let mut picks: Vec<Candidate> = slots.into_iter().flatten().collect();
picks.sort_by_key(|candidate| (candidate.tier, candidate.rank, candidate.extra));
if let Some(position) = picks
.iter()
.position(|candidate| {
stems
.get(candidate.rank)
.is_some_and(|(_, script)| *script == CjkScript::PanCjk)
})
{
picks.truncate(position + 1);
}
picks
}
fn scan_tier(
dirs: &[PathBuf],
tier: usize,
stems: &[(&str, CjkScript)],
) -> [Option<Candidate>; CjkScript::COUNT] {
let mut slots: [Option<Candidate>; CjkScript::COUNT] = [None, None, None, None, None];
walk_tier(dirs, &mut |path| consider(&mut slots, tier, path, stems));
slots
}
fn walk_tier(dirs: &[PathBuf], visit: &mut dyn FnMut(&Path)) {
let mut queue: VecDeque<(PathBuf, usize)> = dirs.iter().map(|dir| (dir.clone(), 0)).collect();
let mut visited: HashSet<PathBuf> = HashSet::new();
while let Some((dir, depth)) = queue.pop_front() {
let identity = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
if !visited.insert(identity) {
continue;
}
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(error) => {
tracing::debug!(path = %dir.display(), %error, "OxiGIS desktop: font directory skipped");
continue;
}
};
for entry in entries.flatten() {
let path = entry.path();
match entry.file_type() {
Ok(kind) if kind.is_dir() => {
if depth < MAX_SCAN_DEPTH {
queue.push_back((path, depth + 1));
}
}
Ok(kind) if kind.is_file() => visit(&path),
_ => match std::fs::metadata(&path) {
Ok(meta) if meta.is_dir() && depth < MAX_SCAN_DEPTH => {
queue.push_back((path, depth + 1));
}
Ok(meta) if meta.is_file() => visit(&path),
_ => {}
},
}
}
}
}
fn consider(
slots: &mut [Option<Candidate>; CjkScript::COUNT],
tier: usize,
path: &Path,
stems: &[(&str, CjkScript)],
) {
let Some((rank, extra)) = stem_match_in(path, stems) else {
return;
};
let slot = &mut slots[stems[rank].1.slot()];
let improves = match slot.as_ref() {
None => true,
Some(held) => (rank, extra) < (held.rank, held.extra),
};
if !improves || !is_readable_size(path) {
return;
}
if !sniffs_as_font(path) {
tracing::debug!(
path = %path.display(),
"OxiGIS desktop: a CJK-named file is not SFNT/TTC data; ignored",
);
return;
}
let rank = if is_thin_default_variable_file(path) {
tracing::debug!(
path = %path.display(),
"OxiGIS desktop: thin-default variable face demoted below the static candidates",
);
rank + stems.len()
} else {
rank
};
let improves = match slot.as_ref() {
None => true,
Some(held) => (rank, extra) < (held.rank, held.extra),
};
if !improves {
return;
}
*slot = Some(Candidate {
tier,
rank,
extra,
path: path.to_path_buf(),
});
}
#[cfg(test)]
const THIN_DEFAULT_VF_DEMOTION: usize = CJK_FONT_STEMS.len();
const MAX_PROBED_RECORDS: u16 = 256;
fn is_thin_default_variable_file(path: &Path) -> bool {
fvar_default_weight(path).is_some_and(|weight| weight < 300.0)
}
fn fvar_default_weight(path: &Path) -> Option<f32> {
use std::io::{Read, Seek, SeekFrom};
let mut file = std::fs::File::open(path).ok()?;
let mut head = [0u8; 12];
file.read_exact(&mut head).ok()?;
let sfnt_offset: u64 = if is_ttc(&head) {
let mut first_face = [0u8; 4];
file.read_exact(&mut first_face).ok()?;
u32::from_be_bytes(first_face).into()
} else if is_sfnt(&head) {
0
} else {
return None;
};
file.seek(SeekFrom::Start(sfnt_offset)).ok()?;
let mut sfnt_head = [0u8; 12];
file.read_exact(&mut sfnt_head).ok()?;
let num_tables = u16::from_be_bytes([sfnt_head[4], sfnt_head[5]]);
let mut fvar_table: Option<(u32, u32)> = None;
for _ in 0..num_tables.min(MAX_PROBED_RECORDS) {
let mut record = [0u8; 16];
file.read_exact(&mut record).ok()?;
if record.get(..4) == Some(b"fvar".as_slice()) {
fvar_table = Some((
u32::from_be_bytes([record[8], record[9], record[10], record[11]]),
u32::from_be_bytes([record[12], record[13], record[14], record[15]]),
));
break;
}
}
let (fvar_offset, fvar_length) = fvar_table?;
if fvar_length < 10 {
return None;
}
file.seek(SeekFrom::Start(fvar_offset.into())).ok()?;
let mut fvar_head = [0u8; 10];
file.read_exact(&mut fvar_head).ok()?;
let version = u32::from_be_bytes([fvar_head[0], fvar_head[1], fvar_head[2], fvar_head[3]]);
if version != 0x0001_0000 {
return None;
}
let axes_array_offset = u16::from_be_bytes([fvar_head[4], fvar_head[5]]);
let axis_count = u16::from_be_bytes([fvar_head[8], fvar_head[9]]);
let axes_start = u64::from(fvar_offset).saturating_add(axes_array_offset.into());
file.seek(SeekFrom::Start(axes_start)).ok()?;
const AXIS_RECORD_SIZE: i64 = 20;
let mut probe = [0u8; 12];
for _ in 0..axis_count.min(MAX_PROBED_RECORDS) {
file.read_exact(&mut probe).ok()?;
if probe.get(..4) == Some(b"wght".as_slice()) {
let default_fixed = i32::from_be_bytes([probe[8], probe[9], probe[10], probe[11]]);
return Some(default_fixed as f32 / 65536.0);
}
file.seek(SeekFrom::Current(AXIS_RECORD_SIZE - probe.len() as i64))
.ok()?;
}
None
}
#[cfg(test)]
fn is_thin_default_variable(bytes: &[u8]) -> bool {
let Ok(face) = ttf_parser::Face::parse(bytes, 0) else {
return false;
};
face.is_variable()
&& face
.variation_axes()
.into_iter()
.find(|axis| axis.tag == ttf_parser::Tag::from_bytes(b"wght"))
.is_some_and(|axis| axis.def_value < 300.0)
}
fn sniffs_as_font(path: &Path) -> bool {
use std::io::Read as _;
let mut magic = [0u8; 4];
match std::fs::File::open(path).and_then(|mut file| file.read_exact(&mut magic)) {
Ok(()) => is_sfnt(&magic) || is_ttc(&magic),
Err(_) => false,
}
}
#[must_use]
pub fn cjk_stem_rank(path: &Path) -> Option<usize> {
stem_match(path).map(|(rank, _)| rank)
}
fn stem_match(path: &Path) -> Option<(usize, usize)> {
stem_match_in(path, CJK_FONT_STEMS)
}
fn stem_match_in(path: &Path, stems: &[(&str, CjkScript)]) -> Option<(usize, usize)> {
let normalized = normalized_font_stem(path)?;
stems
.iter()
.position(|(known, _)| normalized.starts_with(known))
.map(|rank| (rank, normalized.len() - stems[rank].0.len()))
}
fn normalized_font_stem(path: &Path) -> Option<String> {
let stem = path.file_stem().and_then(|stem| stem.to_str())?;
let extension = path.extension().and_then(|ext| ext.to_str())?;
let extension = extension.to_ascii_lowercase();
if !FONT_EXTENSIONS.contains(&extension.as_str()) {
return None;
}
let normalized: String = recompose_kana(stem)
.chars()
.filter(|ch| ch.is_alphanumeric())
.collect::<String>()
.to_ascii_lowercase();
if CJK_STEM_EXCLUSIONS
.iter()
.any(|excluded| normalized.starts_with(excluded))
{
return None;
}
Some(normalized)
}
fn recompose_kana(stem: &str) -> String {
let mut composed = String::with_capacity(stem.len());
let mut chars = stem.chars().peekable();
while let Some(ch) = chars.next() {
let voiced = match chars.peek() {
Some('\u{3099}') => dakuten(ch),
Some('\u{309A}') => handakuten(ch),
_ => None,
};
match voiced {
Some(fused) => {
composed.push(fused);
chars.next(); }
None => composed.push(ch),
}
}
composed
}
const DAKUTEN_PAIRS: &str = "かがきぎくぐけげこごさざしじすずせぜそぞただちぢつづてでとどはばひびふぶへべほぼうゔカガキギクグケゲコゴサザシジスズセゼソゾタダチヂツヅテデトドハバヒビフブヘベホボウヴワヷヰヸヱヹヲヺ";
const HANDAKUTEN_PAIRS: &str = "はぱひぴふぷへぺほぽハパヒピフプヘペホポ";
fn dakuten(base: char) -> Option<char> {
voiced_form(DAKUTEN_PAIRS, base)
}
fn handakuten(base: char) -> Option<char> {
voiced_form(HANDAKUTEN_PAIRS, base)
}
fn voiced_form(pairs: &str, base: char) -> Option<char> {
let mut chars = pairs.chars();
while let Some(candidate) = chars.next() {
let voiced = chars.next()?;
if candidate == base {
return Some(voiced);
}
}
None
}
fn is_readable_size(path: &Path) -> bool {
match std::fs::metadata(path) {
Ok(meta) => meta.len() > 0 && meta.len() <= MAX_FONT_BYTES,
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::{
CJK_FONT_STEMS, CjkScript, FONT_EXTENSIONS, TIER_USER, assemble_chain, chain_for_tiers,
cjk_stem_rank, find_cjk_font_paths, is_sfnt, is_ttc, read_cjk_font, scan_tier,
};
use std::path::{Path, PathBuf};
fn is_cjk_font_file(path: &Path) -> bool {
cjk_stem_rank(path).is_some()
}
fn find_cjk_fonts() -> Vec<(PathBuf, Vec<u8>)> {
find_cjk_font_paths()
.into_iter()
.filter_map(|path| read_cjk_font(&path).map(|bytes| (path, bytes)))
.collect()
}
fn script_of(name: &str) -> CjkScript {
CJK_FONT_STEMS[rank(name)].1
}
#[test]
fn the_common_cjk_faces_are_recognised_whatever_the_separators() {
for name in [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.otf",
"/usr/share/fonts/truetype/NotoSansJP-Regular.ttf",
"/Library/Fonts/Hiragino Sans W3.otf",
"C:/Windows/Fonts/msgothic.ttc",
"C:/Windows/Fonts/meiryo.ttc",
"C:/Windows/Fonts/YuGothM.ttc",
"C:/Windows/Fonts/msyh.ttc",
"C:/Windows/Fonts/msjh.ttc",
"/system/fonts/DroidSansFallback.ttf",
"/home/user/.fonts/source_han_sans_jp.otf",
] {
assert!(is_cjk_font_file(Path::new(name)), "must match: {name}");
}
}
#[test]
fn latin_faces_and_unknown_extensions_are_not_mistaken_for_cjk() {
for name in [
"/usr/share/fonts/truetype/DejaVuSans.ttf",
"/usr/share/fonts/truetype/NotoSans-Regular.ttf",
"/usr/share/fonts/NotoSansCJK-Regular.woff2",
"/usr/share/fonts/NotoSansCJK-Regular",
"/usr/share/fonts/README",
"C:/Windows/Fonts/GOTHIC.TTF",
] {
assert!(!is_cjk_font_file(Path::new(name)), "must not match: {name}");
}
}
#[test]
fn collections_are_accepted_and_rank_like_any_other_candidate() {
assert!(is_cjk_font_file(Path::new(
"/fonts/NotoSansCJK-Regular.ttc"
)));
assert!(FONT_EXTENSIONS.contains(&"ttc"));
assert_eq!(
cjk_stem_rank(Path::new("/fonts/NotoSansCJK-Regular.ttc")),
Some(0)
);
}
#[test]
fn supplementary_plane_only_faces_are_excluded_by_name() {
assert_eq!(
cjk_stem_rank(Path::new("C:/Windows/Fonts/simsunb.ttf")),
None
);
assert_eq!(
cjk_stem_rank(Path::new("C:/Windows/Fonts/SimsunExtG.ttf")),
None
);
assert!(is_cjk_font_file(Path::new("C:/Windows/Fonts/simsun.ttc")));
}
fn rank(name: &str) -> usize {
cjk_stem_rank(Path::new(name)).unwrap_or_else(|| panic!("{name} must match a CJK stem"))
}
#[test]
fn the_stem_order_prefers_modern_faces_and_regular_weights() {
assert!(rank("NotoSansCJK-Regular.otf") < rank("NotoSansJP-Regular.ttf"));
assert!(rank("NotoSansJP-VF.ttf") < rank("meiryo.ttc"));
assert!(rank("meiryo.ttc") < rank("msgothic.ttc"));
assert!(rank("YuGothM.ttc") < rank("msgothic.ttc"));
assert!(rank("YuGothM.ttc") < rank("YuGothB.ttc"));
assert!(rank("YuGothR.ttc") < rank("YuGothL.ttc"));
assert_eq!(rank("YuGothB.ttc"), rank("YuGothL.ttc"));
assert_eq!(script_of("msjh.ttc"), CjkScript::TraditionalChinese);
assert_eq!(cjk_stem_rank(Path::new("DejaVuSans.ttf")), None);
}
#[test]
fn a_thin_default_variable_face_is_demoted_below_every_stem() {
assert!(!super::is_thin_default_variable(
oxifont_bundled::NOTO_SANS_REGULAR
));
let best_named = 0;
let worst_named = CJK_FONT_STEMS.len() - 1;
assert!(best_named + super::THIN_DEFAULT_VF_DEMOTION > worst_named);
assert!(!super::is_thin_default_variable(&[0xDE, 0xAD, 0xBE, 0xEF]));
}
#[test]
fn ground_truth_classifications_hold_for_the_tricky_faces() {
assert_eq!(
script_of("/System/Library/Fonts/Hiragino Sans GB.ttc"),
CjkScript::SimplifiedChinese
);
assert_eq!(script_of("Hiragino Sans W3.otf"), CjkScript::Japanese);
assert_eq!(script_of("Osaka.ttf"), CjkScript::Japanese);
assert_eq!(
script_of("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc"),
CjkScript::Japanese
);
assert_eq!(
script_of("/System/Library/Fonts/ヒラギノ明朝 ProN.ttc"),
CjkScript::Japanese
);
assert_eq!(
script_of("/System/Library/Fonts/ヒラギノ丸ゴ ProN W4.ttc"),
CjkScript::Japanese
);
assert_eq!(
script_of("/System/Library/Fonts/STHeiti Medium.ttc"),
CjkScript::TraditionalChinese
);
assert_eq!(
script_of("/System/Library/Fonts/STHeiti Light.ttc"),
CjkScript::TraditionalChinese
);
assert_eq!(script_of("PingFang.ttc"), CjkScript::TraditionalChinese);
assert_eq!(
script_of("SourceHanSansJP-Regular.otf"),
CjkScript::Japanese
);
assert_eq!(script_of("SourceHanSansKR-Bold.otf"), CjkScript::Korean);
assert_eq!(script_of("SourceHanSansSC-Regular.otf"), CjkScript::PanCjk);
assert_eq!(script_of("DroidSansFallbackFull.ttf"), CjkScript::PanCjk);
assert_eq!(
script_of("DroidSansFallback.ttf"),
CjkScript::SimplifiedChinese
);
}
#[test]
fn matching_is_case_insensitive_on_both_halves() {
assert!(is_cjk_font_file(Path::new("/fonts/NOTOSANSCJK-BOLD.OTF")));
assert!(is_cjk_font_file(Path::new("/fonts/notosanscjk-bold.otf")));
assert!(is_cjk_font_file(Path::new("/fonts/MSGOTHIC.TTC")));
}
fn nfd_hiragino_kaku_gothic_w3() -> String {
[
'ヒ', 'ラ', '\u{30AD}', '\u{3099}', 'ノ', '角', '\u{30B3}', '\u{3099}', 'シ', 'ッ',
'ク', ' ', 'W', '3', '.', 't', 't', 'c',
]
.into_iter()
.collect()
}
#[test]
fn the_voicing_tables_are_well_formed_pairs() {
for table in [super::DAKUTEN_PAIRS, super::HANDAKUTEN_PAIRS] {
assert_eq!(table.chars().count() % 2, 0, "{table:?} must pair up");
}
}
#[test]
fn recompose_kana_matches_the_verified_unicode_pairs() {
use super::recompose_kana;
assert_eq!(recompose_kana("\u{304B}\u{3099}"), "\u{304C}"); assert_eq!(recompose_kana("\u{30AD}\u{3099}"), "\u{30AE}"); assert_eq!(recompose_kana("\u{30B3}\u{3099}"), "\u{30B4}"); assert_eq!(recompose_kana("\u{306F}\u{309A}"), "\u{3071}"); assert_eq!(recompose_kana("\u{30DB}\u{309A}"), "\u{30DD}"); assert_eq!(recompose_kana("\u{30A2}\u{3099}"), "\u{30A2}\u{3099}");
assert_eq!(recompose_kana("\u{30AE}\u{30B4}"), "\u{30AE}\u{30B4}");
assert_eq!(recompose_kana(""), "");
assert_eq!(recompose_kana("\u{3099}"), "\u{3099}");
assert_eq!(recompose_kana("a\u{3099}"), "a\u{3099}");
}
#[test]
fn kana_recomposition_matches_regardless_of_input_normalisation_form() {
let nfc_name = "ヒラギノ角ゴシック W3.ttc";
let nfd_name = nfd_hiragino_kaku_gothic_w3();
assert!(
nfd_name.chars().count() > nfc_name.chars().count(),
"the NFD spelling must genuinely add the two combining marks, not collapse to the same bytes",
);
assert_eq!(script_of(nfc_name), CjkScript::Japanese);
assert_eq!(
cjk_stem_rank(Path::new(&nfd_name)),
cjk_stem_rank(Path::new(nfc_name)),
"NFC and NFD spellings of the same name must resolve to the same table entry",
);
}
#[test]
fn a_canonically_decomposed_file_name_is_still_recognised() {
let nfd_name = nfd_hiragino_kaku_gothic_w3();
let dir = TempFontDir::new("nfd-roundtrip");
dir.put(&nfd_name, TTC_HEAD);
assert_eq!(
file_names(&dir.chain()),
[nfd_name],
"the NFD-named Hiragino file must win the Japanese slot",
);
}
#[test]
fn the_bold_stems_match_the_real_windows_bold_files() {
use super::{CJK_BOLD_FONT_STEMS, latin_bold_stem_match, stem_match_in};
let bold_rank = |name: &str| stem_match_in(Path::new(name), CJK_BOLD_FONT_STEMS);
for name in [
"C:/Windows/Fonts/meiryob.ttc",
"C:/Windows/Fonts/YuGothB.ttc",
"C:/Windows/Fonts/malgunbd.ttf",
"C:/Windows/Fonts/msyhbd.ttc",
"C:/Windows/Fonts/msjhbd.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
] {
assert!(bold_rank(name).is_some(), "bold stem must match: {name}");
}
for name in [
"C:/Windows/Fonts/segoeuib.ttf",
"C:/Windows/Fonts/arialbd.ttf",
"C:/Windows/Fonts/tahomabd.ttf",
"/usr/share/fonts/truetype/DejaVuSans-Bold.ttf",
] {
assert!(
latin_bold_stem_match(Path::new(name)).is_some(),
"Latin bold stem must match: {name}",
);
}
assert_eq!(bold_rank("C:/Windows/Fonts/meiryo.ttc"), None);
assert_eq!(latin_bold_stem_match(Path::new("arial.ttf")), None);
assert_eq!(bold_rank("C:/Windows/Fonts/msgothic.ttc"), None);
assert_eq!(bold_rank("C:/Windows/Fonts/simsun.ttc"), None);
assert_eq!(bold_rank("C:/Windows/Fonts/simsunb.ttf"), None);
let upright = latin_bold_stem_match(Path::new("arialbd.ttf")).expect("upright");
let italic = latin_bold_stem_match(Path::new("arialbdi.ttf")).expect("italic");
assert!(upright < italic, "{upright:?} must beat {italic:?}");
}
#[test]
fn the_bold_stems_are_normalised_and_pan_faces_lead() {
use super::{CJK_BOLD_FONT_STEMS, LATIN_BOLD_FONT_STEMS};
for stem in LATIN_BOLD_FONT_STEMS
.iter()
.copied()
.chain(CJK_BOLD_FONT_STEMS.iter().map(|(stem, _)| *stem))
{
assert!(
stem.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit()),
"stem {stem:?} would never match a normalised file name",
);
}
let first_pan = CJK_BOLD_FONT_STEMS
.iter()
.position(|(_, script)| *script == CjkScript::PanCjk)
.expect("a pan entry exists");
let last_pan = CJK_BOLD_FONT_STEMS
.iter()
.rposition(|(_, script)| *script == CjkScript::PanCjk)
.expect("a pan entry exists");
assert_eq!(first_pan, 0);
assert!(
CJK_BOLD_FONT_STEMS[..=last_pan]
.iter()
.all(|(_, script)| *script == CjkScript::PanCjk),
"the pan block must be a prefix",
);
}
#[test]
fn the_bold_scan_is_total_on_this_machine() {
for path in super::find_cjk_bold_font_paths() {
assert!(
super::latin_bold_stem_match(&path).is_some()
|| super::stem_match_in(&path, super::CJK_BOLD_FONT_STEMS).is_some(),
"every returned path matched a bold stem: {}",
path.display(),
);
assert!(read_cjk_font(&path).is_some(), "and reads back as a font");
}
}
#[test]
fn the_known_stems_are_normalised_lower_case_alphanumerics() {
for (stem, _) in CJK_FONT_STEMS {
assert!(
stem.chars()
.all(|ch| ch.is_alphanumeric() && ch.to_ascii_lowercase() == ch),
"stem {stem:?} would never match a normalised file name",
);
}
}
#[test]
fn specific_stems_precede_every_stem_that_prefixes_them() {
for (i, (specific, _)) in CJK_FONT_STEMS.iter().enumerate() {
for (j, (general, _)) in CJK_FONT_STEMS.iter().enumerate() {
if i != j && specific.starts_with(general) {
assert!(
j > i,
"{general:?} (index {j}) must come after {specific:?} (index {i})",
);
}
}
}
}
#[test]
fn signatures_split_single_faces_collections_and_junk_three_ways() {
assert!(is_sfnt(&[0x00, 0x01, 0x00, 0x00, 0x99]));
assert!(is_sfnt(b"OTTO...."));
assert!(is_sfnt(b"true...."));
assert!(!is_sfnt(b"ttcf...."));
assert!(is_ttc(b"ttcf...."));
assert!(!is_ttc(b"OTTO...."));
for junk in [b"wOF2....".as_slice(), b"<!DOCTYPE html>", b"abc", b""] {
assert!(!is_sfnt(junk));
assert!(!is_ttc(junk));
}
}
#[test]
fn the_bundled_latin_face_is_recognised_as_an_sfnt() {
assert!(is_sfnt(oxifont_bundled::NOTO_SANS_REGULAR));
}
#[test]
fn the_scan_answers_without_panicking_on_this_machine() {
let _ = find_cjk_font_paths();
}
const SFNT_HEAD: &[u8] = &[0x00, 0x01, 0x00, 0x00, 0, 0, 0, 0];
const TTC_HEAD: &[u8] = b"ttcf\x00\x02\x00\x00";
struct TempFontDir {
root: PathBuf,
}
impl TempFontDir {
fn new(tag: &str) -> Self {
let root =
std::env::temp_dir().join(format!("oxigis-font-scan-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create temp font dir");
Self { root }
}
fn put(&self, relative: &str, contents: &[u8]) -> PathBuf {
let path = self.root.join(relative);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent dirs");
}
std::fs::write(&path, contents).expect("write fake font");
path
}
fn chain(&self) -> Vec<PathBuf> {
assemble_chain(
scan_tier(std::slice::from_ref(&self.root), TIER_USER, CJK_FONT_STEMS),
CJK_FONT_STEMS,
)
.into_iter()
.map(|candidate| candidate.path)
.collect()
}
}
impl Drop for TempFontDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn file_names(paths: &[PathBuf]) -> Vec<String> {
paths
.iter()
.map(|path| {
path.file_name()
.and_then(|name| name.to_str())
.expect("test paths are unicode")
.to_owned()
})
.collect()
}
#[test]
fn rank_beats_directory_order_within_a_script() {
let dir = TempFontDir::new("rank");
dir.put("meiryo.ttc", TTC_HEAD);
dir.put("msgothic.ttc", TTC_HEAD);
assert_eq!(file_names(&dir.chain()), ["meiryo.ttc"]);
}
#[test]
fn junk_content_never_poisons_the_chain() {
let dir = TempFontDir::new("junk");
dir.put("NotoSansCJK-Regular.otf", b"<!DOCTYPE html><html>...");
dir.put("meiryo.ttc", TTC_HEAD);
assert_eq!(file_names(&dir.chain()), ["meiryo.ttc"]);
}
#[test]
fn bold_variants_lose_the_rank_tie_to_the_shorter_name() {
let dir = TempFontDir::new("weights");
dir.put("meiryob.ttc", TTC_HEAD);
dir.put("meiryo.ttc", TTC_HEAD);
dir.put("msyhbd.ttc", TTC_HEAD);
dir.put("msyhl.ttc", TTC_HEAD);
dir.put("msyh.ttc", TTC_HEAD);
assert_eq!(file_names(&dir.chain()), ["meiryo.ttc", "msyh.ttc"]);
}
#[test]
fn macos_native_weight_variants_lose_the_tie_to_the_preferred_weight() {
let dir = TempFontDir::new("hiragino-weights");
dir.put("ヒラギノ角ゴシック W6.ttc", TTC_HEAD);
dir.put("ヒラギノ角ゴシック W0.ttc", TTC_HEAD);
dir.put("ヒラギノ角ゴシック W3.ttc", TTC_HEAD);
dir.put("ヒラギノ角ゴシック W9.ttc", TTC_HEAD);
assert_eq!(file_names(&dir.chain()), ["ヒラギノ角ゴシック W3.ttc"]);
let dir = TempFontDir::new("stheiti-weights");
dir.put("STHeiti Light.ttc", TTC_HEAD);
dir.put("STHeiti Medium.ttc", TTC_HEAD);
assert_eq!(file_names(&dir.chain()), ["STHeiti Medium.ttc"]);
}
#[test]
fn the_chain_collects_one_face_per_script_in_rank_order() {
let dir = TempFontDir::new("chain");
dir.put("msjh.ttc", TTC_HEAD);
dir.put("malgun.ttf", SFNT_HEAD);
dir.put("msyh.ttc", TTC_HEAD);
dir.put("meiryo.ttc", TTC_HEAD);
assert_eq!(
file_names(&dir.chain()),
["meiryo.ttc", "malgun.ttf", "msyh.ttc", "msjh.ttc"]
);
}
#[test]
fn a_pan_cjk_face_truncates_everything_ranked_after_it() {
let dir = TempFontDir::new("pan");
dir.put("NotoSansCJK-Regular.otf", SFNT_HEAD);
dir.put("malgun.ttf", SFNT_HEAD);
assert_eq!(file_names(&dir.chain()), ["NotoSansCJK-Regular.otf"]);
let dir = TempFontDir::new("pan2");
dir.put("NotoSansJP-Regular.ttf", SFNT_HEAD);
dir.put("DroidSansFallbackFull.ttf", SFNT_HEAD);
dir.put("malgun.ttf", SFNT_HEAD);
assert_eq!(
file_names(&dir.chain()),
["NotoSansJP-Regular.ttf", "DroidSansFallbackFull.ttf"]
);
let dir = TempFontDir::new("pan3");
dir.put("DroidSansFallback.ttf", SFNT_HEAD);
dir.put("malgun.ttf", SFNT_HEAD);
assert_eq!(
file_names(&dir.chain()),
["DroidSansFallback.ttf", "malgun.ttf"]
);
let dir = TempFontDir::new("pan4");
dir.put("SourceHanSansJP-Regular.otf", SFNT_HEAD);
dir.put("malgun.ttf", SFNT_HEAD);
assert_eq!(
file_names(&dir.chain()),
["SourceHanSansJP-Regular.otf", "malgun.ttf"]
);
let dir = TempFontDir::new("pan5");
dir.put("SourceHanSansSC-Regular.otf", SFNT_HEAD);
dir.put("malgun.ttf", SFNT_HEAD);
assert_eq!(file_names(&dir.chain()), ["SourceHanSansSC-Regular.otf"]);
}
#[test]
fn empty_files_are_not_candidates() {
let dir = TempFontDir::new("empty");
dir.put("meiryo.ttc", b"");
assert!(dir.chain().is_empty());
}
#[test]
fn the_walk_stops_at_max_scan_depth() {
let dir = TempFontDir::new("depth");
dir.put("a/b/c/meiryo.ttc", TTC_HEAD);
assert_eq!(file_names(&dir.chain()), ["meiryo.ttc"]);
let dir = TempFontDir::new("depth2");
dir.put("a/b/c/d/meiryo.ttc", TTC_HEAD);
assert!(dir.chain().is_empty());
}
#[test]
fn a_shallow_file_wins_a_full_tie_against_a_deep_one() {
let dir = TempFontDir::new("bfs");
let deep = dir.put("aaa/meiryo.ttc", TTC_HEAD);
let shallow = dir.put("meiryo.ttc", TTC_HEAD);
let chain = dir.chain();
assert_eq!(chain, [shallow]);
assert_ne!(chain, [deep]);
}
#[test]
fn symlinked_font_files_are_followed() {
let dir = TempFontDir::new("symlink");
let target = dir.put("real/target-bytes.dat", TTC_HEAD);
let link = dir.root.join("meiryo.ttc");
#[cfg(windows)]
let made = std::os::windows::fs::symlink_file(&target, &link).is_ok();
#[cfg(unix)]
let made = std::os::unix::fs::symlink(&target, &link).is_ok();
if !made {
eprintln!("skipped: creating symlinks needs privileges on this machine");
return;
}
assert_eq!(file_names(&dir.chain()), ["meiryo.ttc"]);
}
#[test]
fn a_directory_symlink_cycle_does_not_revisit_files() {
let dir = TempFontDir::new("cycle");
dir.put("meiryo.ttc", TTC_HEAD);
let link = dir.root.join("loop");
#[cfg(windows)]
let made = std::os::windows::fs::symlink_dir(&dir.root, &link).is_ok();
#[cfg(unix)]
let made = std::os::unix::fs::symlink(&dir.root, &link).is_ok();
if !made {
eprintln!("skipped: creating symlinks needs privileges on this machine");
return;
}
let visits = std::cell::Cell::new(0usize);
super::walk_tier(std::slice::from_ref(&dir.root), &mut |_path| {
visits.set(visits.get() + 1);
});
assert_eq!(
visits.get(),
1,
"meiryo.ttc must be visited exactly once despite the ancestor-cycling symlink",
);
}
#[test]
fn a_user_face_wins_its_slot_and_the_system_fills_the_rest() {
let user = TempFontDir::new("tier-user");
let system = TempFontDir::new("tier-system");
user.put("msgothic.ttc", TTC_HEAD);
system.put("meiryo.ttc", TTC_HEAD);
system.put("malgun.ttf", SFNT_HEAD);
let chain = chain_for_tiers(
std::slice::from_ref(&user.root),
std::slice::from_ref(&system.root),
);
let paths: Vec<PathBuf> = chain.into_iter().map(|c| c.path).collect();
assert_eq!(file_names(&paths), ["msgothic.ttc", "malgun.ttf"]);
}
#[test]
fn a_user_face_precedes_a_system_pan_face_instead_of_vanishing() {
let user = TempFontDir::new("tier-user2");
let system = TempFontDir::new("tier-system2");
user.put("malgun.ttf", SFNT_HEAD);
system.put("NotoSansCJK-Regular.otf", SFNT_HEAD);
let chain = chain_for_tiers(
std::slice::from_ref(&user.root),
std::slice::from_ref(&system.root),
);
let paths: Vec<PathBuf> = chain.into_iter().map(|c| c.path).collect();
assert_eq!(
file_names(&paths),
["malgun.ttf", "NotoSansCJK-Regular.otf"]
);
}
#[test]
fn read_cjk_font_rejects_what_is_no_longer_a_font() {
let dir = TempFontDir::new("read");
let good = dir.put("meiryo.ttc", TTC_HEAD);
let junk = dir.put("msyh.ttc", b"<!DOCTYPE html>");
let empty = dir.put("msjh.ttc", b"");
assert_eq!(read_cjk_font(&good).as_deref(), Some(TTC_HEAD));
assert_eq!(read_cjk_font(&junk), None);
assert_eq!(read_cjk_font(&empty), None);
assert_eq!(read_cjk_font(Path::new("does/not/exist.ttc")), None);
}
fn synthetic_sfnt(base_offset: u32, tables: &[(&[u8; 4], &[u8])]) -> Vec<u8> {
let dir_start = 12_usize;
let bodies_start = dir_start + tables.len() * 16;
let mut head_and_dir = vec![0u8; bodies_start];
head_and_dir[0..4].copy_from_slice(&0x0001_0000u32.to_be_bytes());
head_and_dir[4..6].copy_from_slice(&(tables.len() as u16).to_be_bytes());
let mut bodies = Vec::new();
for (i, (tag, data)) in tables.iter().enumerate() {
let record = dir_start + i * 16;
let table_offset = base_offset + bodies_start as u32 + bodies.len() as u32;
head_and_dir[record..record + 4].copy_from_slice(tag.as_slice());
head_and_dir[record + 8..record + 12].copy_from_slice(&table_offset.to_be_bytes());
head_and_dir[record + 12..record + 16]
.copy_from_slice(&(data.len() as u32).to_be_bytes());
bodies.extend_from_slice(data);
}
head_and_dir.extend_from_slice(&bodies);
head_and_dir
}
fn synthetic_fvar(default_wght: f32) -> Vec<u8> {
let mut out = vec![0u8; 16];
out[0..4].copy_from_slice(&0x0001_0000u32.to_be_bytes());
out[4..6].copy_from_slice(&16u16.to_be_bytes()); out[8..10].copy_from_slice(&1u16.to_be_bytes()); out[10..12].copy_from_slice(&20u16.to_be_bytes()); let fixed = (default_wght * 65536.0).round() as i32;
let mut axis = vec![0u8; 20];
axis[0..4].copy_from_slice(b"wght");
axis[4..8].copy_from_slice(&fixed.to_be_bytes()); axis[8..12].copy_from_slice(&fixed.to_be_bytes()); axis[12..16].copy_from_slice(&fixed.to_be_bytes()); out.extend_from_slice(&axis);
out
}
fn synthetic_ttc(face0: &[u8]) -> Vec<u8> {
let mut out = vec![0u8; 16];
out[0..4].copy_from_slice(b"ttcf");
out[4..6].copy_from_slice(&1u16.to_be_bytes());
out[8..12].copy_from_slice(&1u32.to_be_bytes()); out[12..16].copy_from_slice(&16u32.to_be_bytes()); out.extend_from_slice(face0);
out
}
#[test]
fn fvar_default_weight_reads_the_wght_default_with_bounded_io() {
use super::{fvar_default_weight, is_thin_default_variable_file};
let dir = TempFontDir::new("fvar-probe");
let thin = dir.put(
"thin.ttf",
&synthetic_sfnt(0, &[(b"fvar", &synthetic_fvar(100.0))]),
);
let regular = dir.put(
"regular.ttf",
&synthetic_sfnt(0, &[(b"fvar", &synthetic_fvar(400.0))]),
);
let no_fvar = dir.put("static.ttf", &synthetic_sfnt(0, &[(b"head", &[0u8; 4])]));
let thin_collection = dir.put(
"thin.ttc",
&synthetic_ttc(&synthetic_sfnt(16, &[(b"fvar", &synthetic_fvar(100.0))])),
);
assert_eq!(fvar_default_weight(&thin).map(f32::round), Some(100.0));
assert_eq!(fvar_default_weight(®ular).map(f32::round), Some(400.0));
assert_eq!(fvar_default_weight(&no_fvar), None);
assert_eq!(
fvar_default_weight(&thin_collection).map(f32::round),
Some(100.0)
);
assert_eq!(fvar_default_weight(Path::new("does/not/exist.ttf")), None);
assert_eq!(fvar_default_weight(&dir.put("junk.ttf", b"nope")), None);
assert!(is_thin_default_variable_file(&thin));
assert!(!is_thin_default_variable_file(®ular));
assert!(!is_thin_default_variable_file(&no_fvar));
assert!(is_thin_default_variable_file(&thin_collection));
}
fn probe_text_for(path: &Path) -> &'static str {
let rank = cjk_stem_rank(path).expect("the scan only returns stem matches");
match CJK_FONT_STEMS[rank].1 {
CjkScript::Korean => "서울",
CjkScript::SimplifiedChinese => "北京",
CjkScript::TraditionalChinese => "台北",
CjkScript::Japanese => "東京",
CjkScript::PanCjk => "東京서울北京台北",
}
}
#[test]
fn the_found_fonts_shape_real_cjk_through_the_label_engine() {
let paths = find_cjk_font_paths();
if paths.is_empty() {
eprintln!("skipped: no CJK font on this machine");
return;
}
let fonts = find_cjk_fonts();
for path in &paths {
let still_valid = std::fs::read(path)
.map(|bytes| is_sfnt(&bytes) || is_ttc(&bytes))
.unwrap_or(false);
assert!(
!still_valid || fonts.iter().any(|(loaded, _)| loaded == path),
"{} sniffed valid but read_cjk_font dropped it",
path.display(),
);
}
if fonts.is_empty() {
eprintln!("skipped: every selected font vanished mid-test");
return;
}
let mut engine =
oxigis_render::label::LabelEngine::new(oxifont_bundled::NOTO_SANS_REGULAR.to_vec())
.expect("bundled Noto Sans parses");
for (_, bytes) in &fonts {
engine.add_fallback_font(bytes.clone());
}
for (path, _) in &fonts {
let probe = probe_text_for(path);
let label = engine
.shape(probe, 24.0)
.unwrap_or_else(|e| panic!("{} must shape {probe}: {e}", path.display()));
assert!(
!label.is_empty(),
"{} shaped {probe} to an inkless label",
path.display(),
);
assert!(
label.glyphs().iter().all(|glyph| glyph.key.gid != 0),
"{} shaped {probe} with .notdef glyphs",
path.display(),
);
}
}
#[test]
#[cfg(target_os = "macos")]
fn macos_fills_the_japanese_and_traditional_chinese_slots_with_real_ink() {
let fonts = find_cjk_fonts();
let mut engine =
oxigis_render::label::LabelEngine::new(oxifont_bundled::NOTO_SANS_REGULAR.to_vec())
.expect("bundled Noto Sans parses");
for (_, bytes) in &fonts {
engine.add_fallback_font(bytes.clone());
}
let tagged_index = |script: CjkScript| {
fonts.iter().position(|(path, _)| {
cjk_stem_rank(path).is_some_and(|rank| CJK_FONT_STEMS[rank].1 == script)
})
};
for (script, probe) in [
(CjkScript::Japanese, "東京"),
(CjkScript::TraditionalChinese, "台北"),
] {
assert!(
fonts
.iter()
.any(|(path, _)| cjk_stem_rank(path).is_some_and(|rank| {
let tagged = CJK_FONT_STEMS[rank].1;
tagged == script || tagged == CjkScript::PanCjk
})),
"{script:?} slot is empty on a stock macOS install",
);
let label = engine
.shape(probe, 24.0)
.unwrap_or_else(|error| panic!("{probe} must shape: {error}"));
assert!(
label.glyphs().iter().all(|glyph| glyph.key.gid != 0),
"{probe} shaped with .notdef glyphs — the {script:?} face the scan picked cannot cover it",
);
}
if let (Some(japanese), Some(simplified)) = (
tagged_index(CjkScript::Japanese),
tagged_index(CjkScript::SimplifiedChinese),
) {
assert!(
japanese < simplified,
"the Japanese face must precede the Simplified Chinese face in the fallback chain",
);
}
}
}