From 1340e32b1694854300e4e469cf3692a3ad2a6e1c Mon Sep 17 00:00:00 2001 From: Salah1St <83543012+Salah1St@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:26:37 +0700 Subject: [PATCH 1/2] 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 --- crates/text/src/behaviour_tests.rs | 29 +++++++++++++++++++++++++++++ crates/text/src/fontdb.rs | 7 ++++++- crates/text/src/fontdb_tests.rs | 12 ++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index b3cb4884..ea6ac41d 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -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 = 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 = glyphs.iter().flat_map(|g| g.char_start..g.char_end).collect(); + let want: std::collections::BTreeSet = (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) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 604f3eee..e6e7b2f2 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -406,7 +406,7 @@ fn names(f: &skrifa::FontRef) -> (Option, Option) { } /// 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", ]; diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index f2456d28..9ae9db49 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -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}"); + } +} From dd37c1763c73694d5927fad5544a16a59666a558 Mon Sep 17 00:00:00 2001 From: Salah1St <83543012+Salah1St@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:36:24 +0700 Subject: [PATCH 2/2] M4: the Thai fallback test checks the characters it draws Review: the test chose its Thai face, and checked each glyph's face, by U+0E01, which the sample doesn't contain. It now asks for a face that has every Thai character of the sample, and checks that each glyph's face has the characters of its cluster. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/behaviour_tests.rs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index ea6ac41d..201aa460 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -270,24 +270,26 @@ 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 = s.chars().collect(); + let is_thai = |c: &char| ('\u{0E00}'..='\u{0E7F}').contains(c); + let draws_all = |f: &Face| chars.iter().filter(|c| is_thai(c)).all(|c| f.has_char(*c)); + let Some(f) = face() else { return }; + if draws_all(&f) { + 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| draws_all(&t))) { + return; + } 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(); + let glyphs: Vec<_> = l.glyphs.iter().filter(|g| chars.get(g.char_start).is_some_and(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 cluster = chars.get(g.char_start..g.char_end).unwrap_or(&[]); + assert!(cluster.iter().all(|c| g.face.has_char(*c)), "chars {}..{}: drawn by {}, which lacks some", g.char_start, g.char_end, g.face.family); } let drawn: std::collections::BTreeSet = glyphs.iter().flat_map(|g| g.char_start..g.char_end).collect(); let want: std::collections::BTreeSet = (3..chars.len()).filter(|i| chars[*i] != ' ').collect();