Files
designcraft/crates/fonts/src/tests_fallback.rs
T
Marco Arnulfo de31bd5647 fix(fonts): fall back to a font made for Malayalam (part of #628)
Malayalam took the first installed file mapping it: on macOS Arial Unicode MS, which has no Malayalam shaping, so conjuncts like ക്ഷ fell apart. Fonts made for Malayalam are tried first, loaded on demand through the CJK chain lookup.
2026-10-12 23:03:22 +02:00

160 lines
7.7 KiB
Rust

//! CJK fallback follows the character's language (and the script where it is unambiguous).
use super::*;
use crate::testing::font_with;
const HAN: char = '直';
const HANGUL: char = '한';
const KANA: char = 'あ';
const BOPOMOFO: char = 'ㄅ';
/// A database of the bundled fonts (and the craft-fonts Japanese faces, when built with them)
/// only, no system fonts, plus `fonts` as (family, chars).
fn db_with(fonts: &[(&str, &[char])]) -> FontDb {
let db = FontDb::with_font_dirs(vec![]);
for (family, chars) in fonts {
assert_eq!(db.add_font(font_with(family, chars).unwrap()), 1, "{family}");
}
db
}
fn family(db: &FontDb, c: char, language: Option<&str>) -> Option<String> {
db.fallback_for(c, 0, language).map(|f| f.family.clone())
}
/// The families the Japanese chain finds, in order: the craft-fonts Japanese faces (none without
/// craft-fonts), then `installed`, the stand-ins for system fonts (in chain order).
fn japanese_chain_with(installed: &[&'static str]) -> Vec<&'static str> {
let mut v: Vec<&'static str> = Vec::new();
for f in crate::japanese_document_fonts() {
if !v.contains(&f.family) {
v.push(f.family);
}
}
v.extend_from_slice(installed);
v
}
/// What the language-blind search finds first for CJK text when `added` is the first added font
/// covering it: the craft-fonts Japanese faces load ahead of added fonts.
fn first_loaded(added: &'static str) -> &'static str {
japanese_chain_with(&[added])[0]
}
#[test]
fn ideographs_follow_the_language() {
let db = db_with(&[("Songti SC", &[HAN]), ("Songti TC", &[HAN]), ("AppleMyungjo", &[HAN]), ("Hiragino Mincho ProN", &[HAN])]);
assert_eq!(family(&db, HAN, Some("zh-Hans")).as_deref(), Some("Songti SC"));
assert_eq!(family(&db, HAN, Some("zh")).as_deref(), Some("Songti SC"), "plain Chinese is Simplified");
assert_eq!(family(&db, HAN, Some("zh-Hant")).as_deref(), Some("Songti TC"));
assert_eq!(family(&db, HAN, Some("ko")).as_deref(), Some("AppleMyungjo"));
assert_eq!(family(&db, HAN, Some("ko-KR")).as_deref(), Some("AppleMyungjo"), "the language decides, whatever the region");
let ja = japanese_chain_with(&["Hiragino Mincho ProN"])[0];
assert_eq!(family(&db, HAN, Some("ja")).as_deref(), Some(ja), "the craft-fonts faces lead the Japanese chain, then system fonts");
assert_eq!(family(&db, HAN, Some("ja-JP")).as_deref(), Some(ja));
// CJK punctuation follows the language too.
let db = db_with(&[("Other CJK", &['、']), ("Songti SC", &['、'])]);
assert_eq!(family(&db, '、', Some("zh-Hans")).as_deref(), Some("Songti SC"));
assert_eq!(family(&db, '、', None).as_deref(), Some(first_loaded("Other CJK")));
}
#[test]
fn ideographs_without_a_cjk_language_fall_back_as_before() {
let db = db_with(&[("Other CJK", &[HAN]), ("Songti SC", &[HAN]), ("AppleMyungjo", &[HAN])]);
for language in [None, Some("en-US"), Some("tr"), Some("not a tag")] {
assert_eq!(family(&db, HAN, language).as_deref(), Some(first_loaded("Other CJK")), "{language:?}");
}
}
#[test]
fn the_script_decides_where_it_is_unambiguous() {
// A non-chain font loaded first would win the language-blind search.
let db = db_with(&[
("Other CJK", &[HANGUL, KANA, BOPOMOFO]),
("AppleMyungjo", &[HANGUL]),
("Songti TC", &[BOPOMOFO]),
("Yu Mincho", &[KANA]),
("Hiragino Mincho ProN", &[KANA]),
]);
for language in [None, Some("en-US"), Some("ja"), Some("zh-Hans")] {
assert_eq!(family(&db, HANGUL, language).as_deref(), Some("AppleMyungjo"), "Hangul is Korean: {language:?}");
assert_eq!(family(&db, BOPOMOFO, language).as_deref(), Some("Songti TC"), "Bopomofo is Traditional Chinese: {language:?}");
}
// Hiragino Mincho ProN comes before Yu Mincho in the chain, whatever order they loaded in.
let chain = japanese_chain_with(&["Hiragino Mincho ProN", "Yu Mincho"]);
for language in [None, Some("zh-Hans"), Some("ko")] {
assert_eq!(family(&db, KANA, language).as_deref(), Some(chain[0]), "kana is Japanese: {language:?}");
}
// Past the faces the chain can't use (here its first, as the primary font), the next.
let first = db.face(chain[0], "Regular");
assert_eq!(db.fallback_for(KANA, first.id(), None).map(|f| f.family.clone()).as_deref(), Some(chain[1]));
}
#[test]
fn without_a_chain_font_the_fallback_is_unchanged() {
let db = db_with(&[("Other CJK", &[HANGUL])]);
// Without craft-fonts nothing covers the ideograph; with it, its first Japanese face does.
let craft = japanese_chain_with(&[]).first().copied();
for language in ["zh-Hans", "zh-Hant", "ko", "ja"] {
assert_eq!(family(&db, HAN, Some(language)).as_deref(), craft, "{language}");
}
assert_eq!(family(&db, HANGUL, Some("ko")).as_deref(), Some("Other CJK"));
assert_eq!(family(&db, BOPOMOFO, Some("zh-Hant")), None, "nothing covers it");
}
#[test]
fn chain_fonts_load_from_the_font_folders_when_first_needed() {
let dir = std::env::temp_dir().join(format!("dc-cjk-fallback-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("songti.ttf"), font_with("Songti SC", &[HAN]).unwrap()).unwrap();
std::fs::write(dir.join("myungjo.ttf"), font_with("AppleMyungjo", &[HAN]).unwrap()).unwrap();
// An added font covers the ideograph, so the language-blind search never scans the folder.
let other = || font_with("Other CJK", &[HAN]).unwrap();
let db = FontDb::with_font_dirs(vec![dir.clone()]);
db.add_font(other());
assert_eq!(family(&db, HAN, None).as_deref(), Some(first_loaded("Other CJK")));
assert!(!db.is_loaded("Songti SC"), "no language, no chain font loaded");
assert_eq!(family(&db, HAN, Some("zh-Hans")).as_deref(), Some("Songti SC"));
assert!(!db.is_loaded("AppleMyungjo"), "only what the chain needed");
assert_eq!(family(&db, HAN, None).as_deref(), Some(first_loaded("Other CJK")), "once loaded, still not used without the language");
// With the system fallback off, installed chain fonts aren't loaded.
let db = FontDb::with_font_dirs(vec![dir]);
db.add_font(other());
db.set_system_fallback(false);
assert_eq!(family(&db, HAN, Some("ko")).as_deref(), Some(first_loaded("Other CJK")));
assert!(!db.is_loaded("AppleMyungjo"));
}
const KA: char = 'ക';
const VIRAMA: char = '\u{0D4D}';
const AA: char = '\u{0D3E}';
#[test]
fn malayalam_takes_a_font_made_for_it() {
// Arial Unicode MS maps Malayalam but can't shape it (no conjuncts); loaded first, it won.
let db = db_with(&[("Arial Unicode MS", &[KA, VIRAMA, AA]), ("Malayalam Sangam MN", &[KA, VIRAMA, AA])]);
for c in [KA, VIRAMA, AA] {
for language in [None, Some("en-US"), Some("ml")] {
assert_eq!(family(&db, c, language).as_deref(), Some("Malayalam Sangam MN"), "{c:?} {language:?}");
}
}
// Past the Malayalam fonts, the language-blind search as before.
let sangam = db.face("Malayalam Sangam MN", "Regular");
assert_eq!(db.fallback_for(KA, sangam.id(), None).map(|f| f.family.clone()).as_deref(), Some("Arial Unicode MS"));
}
#[test]
fn malayalam_fonts_load_from_the_font_folders_when_first_needed() {
let dir = std::env::temp_dir().join(format!("dc-malayalam-fallback-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("noto.ttf"), font_with("Noto Sans Malayalam", &[KA]).unwrap()).unwrap();
let db = FontDb::with_font_dirs(vec![dir]);
db.add_font(font_with("Arial Unicode MS", &[KA]).unwrap());
assert!(!db.is_loaded("Noto Sans Malayalam"));
assert_eq!(family(&db, KA, None).as_deref(), Some("Noto Sans Malayalam"));
}