M4: Thai faces in the text fallback list

Thai typed into a layer whose font has no Thai glyphs (Inter, which new
text starts in) was drawn as missing-glyph boxes, on the desktop and on
the web, even with a Thai face embedded or installed: FALLBACK_FAMILIES
listed Latin, CJK, Hangul, Arabic and symbol families but no Thai one.

Noto Sans Thai, Noto Sans Thai UI, Leelawadee UI, Thonburi and Tahoma go
after Noto Sans Arabic; they are only consulted for characters the
layer's own font lacks. Tests: the list has a Thai face for each
platform, and with one installed or embedded every Thai cluster in a
Latin layer, marks included, is drawn from it (skips without one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Salah1St
2026-10-23 13:26:37 +07:00
co-authored by Claude Opus 5.5
parent 80f916326f
commit 1340e32b16
3 changed files with 47 additions and 1 deletions
+29
View File
@@ -265,6 +265,35 @@ fn hangul_comes_from_a_korean_font_when_one_is_installed() {
}
}
#[test]
fn thai_comes_from_a_thai_font_when_one_is_installed() {
// The default (Latin) face has no Thai. With a Thai face (Noto Sans Thai from craft-fonts or
// Linux, Leelawadee UI or Tahoma on Windows, Thonburi on macOS) the fallback draws every Thai
// cluster, marks included, instead of the missing-glyph box. Skips on a machine without one.
let Some(f) = face() else { return };
if f.has_char('\u{0E01}') {
return;
}
let thai = ["Noto Sans Thai", "Noto Sans Thai UI", "Leelawadee UI", "Thonburi", "Tahoma"];
if !thai.iter().any(|t| find_face(t, "Regular").is_some_and(|t| t.has_char('\u{0E01}'))) {
return;
}
// "ภาษาไทย" (the Thai language) and "น้ำ" (water: a tone mark over SARA AM).
let s = "Ab \u{0E20}\u{0E32}\u{0E29}\u{0E32}\u{0E44}\u{0E17}\u{0E22} \u{0E19}\u{0E49}\u{0E33}";
let chars: Vec<char> = s.chars().collect();
let l = layout(&text(s, TextAutoResize::WidthAndHeight), None, None);
let is_thai = |g: &&Glyph| chars.get(g.char_start).is_some_and(|c| ('\u{0E00}'..='\u{0E7F}').contains(c));
let glyphs: Vec<_> = l.glyphs.iter().filter(is_thai).collect();
assert!(!glyphs.is_empty());
for g in &glyphs {
assert!(g.gid != 0, "char {}: missing glyph in {} {}", g.char_start, g.face.family, g.face.style);
assert!(g.face.has_char('\u{0E01}'), "char {}: drawn by {}, which has no Thai", g.char_start, g.face.family);
}
let drawn: std::collections::BTreeSet<usize> = glyphs.iter().flat_map(|g| g.char_start..g.char_end).collect();
let want: std::collections::BTreeSet<usize> = (3..chars.len()).filter(|i| chars[*i] != ' ').collect();
assert_eq!(drawn, want, "every Thai char is in a drawn cluster");
}
#[test]
fn break_opportunities() {
// (before, after, may break between, derivation)
+6 -1
View File
@@ -406,7 +406,7 @@ fn names(f: &skrifa::FontRef) -> (Option<String>, Option<String>) {
}
/// Families tried, in order, for characters a text's own font lacks.
const FALLBACK_FAMILIES: [&str; 20] = [
const FALLBACK_FAMILIES: [&str; 25] = [
"Inter",
"Helvetica Neue",
"Arial",
@@ -425,6 +425,11 @@ const FALLBACK_FAMILIES: [&str; 20] = [
"Noto Sans CJK KR",
"Noto Sans KR",
"Noto Sans Arabic",
"Noto Sans Thai",
"Noto Sans Thai UI",
"Leelawadee UI",
"Thonburi",
"Tahoma",
"Apple Symbols",
"Segoe UI Symbol",
];
+12
View File
@@ -263,3 +263,15 @@ fn font_bytes_load_into_the_db() {
assert_eq!(db().read().unwrap().face_count(), faces);
}
}
#[test]
fn every_platform_has_a_thai_font_in_the_fallbacks() {
// Thai typed into a Latin layer (new text starts in Inter) is drawn by the first fallback that
// has it: Noto Sans Thai (craft-fonts, Linux), Leelawadee UI or Tahoma (Windows), Thonburi
// (macOS). They come after the Latin faces, which go on drawing Latin text.
let at = |family: &str| FALLBACK_FAMILIES.iter().position(|f| *f == family);
let latin = at("Segoe UI").unwrap();
for thai in ["Noto Sans Thai", "Leelawadee UI", "Tahoma", "Thonburi"] {
assert!(at(thai).is_some_and(|i| i > latin), "{thai}");
}
}