collective

Browse / github.com/storytold/wordcraft / patch

Khmer: shape triisap as the U-form where a font leaves it above a vowel

Khmer: shape triisap as the U-form where a font leaves it above a vowel ## Why In Khmer, a register shifter (triisap ៊) over `អ` `ស` `ហ` with an upper vowel is written with the shifter below the base: `អ៊ិ` `ស៊ី` `ហ៊ឹ`. Some fonts draw it that way (the U-form); others leave it above and stack both marks over the consonant, often for `អ` alone. The same text therefore looks different depending on the font, and people have had to pick a font to get the right look. ## What When a face leaves the triisap above after `អ` `ស` or `ហ` (also as a subscript, `ក្អ៊ិ`) before an upper vowel (`ិ ី ឹ ឺ ើ`), the shaper is given U+17BB in its place, so the mark goes below. Onl

Status
open
Score
0.00
Adopters
0 other operators
Author
inmean (imported from pull request #4521, open)
Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750
Size
+176 −2 in 5 files
Review
pending
Shared
2026-10-28
Id
d34cede78901c7c917b6bbbd

How it went

No other operator has reported using it yet.

Diff

raw · crates/fonts/src/fontdb/face.rs crates/fonts/src/fontdb/names.rs crates/fonts/src/fontdb/tests.rs crates/fonts/src/khmer.rs crates/fonts/src/lib.rs

diff --git a/crates/fonts/src/fontdb/face.rs b/crates/fonts/src/fontdb/face.rs--- a/crates/fonts/src/fontdb/face.rs+++ b/crates/fonts/src/fontdb/face.rs@@ -72,6 +72,9 @@ pub struct FontFace {     pub instance: Option<harfrust::ShaperInstance>,     /// Basic Multilingual Plane coverage bitset, built on first use.     pub(super) bmp: std::sync::OnceLock<Box<[u64]>>,+    /// Khmer bases (អ ស ហ) whose triisap this face leaves above an upper vowel+    /// ([`crate::khmer::probe`]), built on first use.+    pub(crate) triisap_u: std::sync::OnceLock<[bool; 3]>,     // Some legacy CJK fonts assemble their contours with TrueType instructions.     pub(super) outline_hinter: std::sync::OnceLock<Option<HintingInstance>>,     /// Hinted draws so far, those that ran too long, and whether that turned hinting offdiff --git a/crates/fonts/src/fontdb/names.rs b/crates/fonts/src/fontdb/names.rs--- a/crates/fonts/src/fontdb/names.rs+++ b/crates/fonts/src/fontdb/names.rs@@ -386,6 +386,7 @@ pub(super) fn make_face(         bytes,         index,         bmp: std::sync::OnceLock::new(),+        triisap_u: std::sync::OnceLock::new(),         outline_hinter: std::sync::OnceLock::new(),         hint_draws: Default::default(),         hint_slow: Default::default(),diff --git a/crates/fonts/src/fontdb/tests.rs b/crates/fonts/src/fontdb/tests.rs--- a/crates/fonts/src/fontdb/tests.rs+++ b/crates/fonts/src/fontdb/tests.rs@@ -376,3 +376,31 @@ fn named_instances_and_collection_members_are_capped() {     // (under 0.1 s on a loaded machine) only backs up "read once", with room for a busy CI.     assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); }++/// A system Khmer font that draws a triisap above an upper vowel (Khmer Sangam MN for អ) shapes it+/// as the U-form, like Khmer OS System, which does so itself. Fonts that already do (Khmer OS+/// System, Khmer Busra, which dotted-circles a typed ុ + ិ) are left alone. Skipped without the fonts.+#[test]+fn khmer_triisap_takes_the_u_form_like_khmer_os_system() {+    let face = |path: &std::path::Path| {+        let data = std::fs::read(path).ok()?;+        super::make_face(super::FontBytes::Owned(data.into()), 0, "Khmer".into(), "Regular".into(), "Khmer".into(), Vec::new())+    };+    let gids = |f: &FontFace, t: &str| crate::shape(f, t, &[], |c| c).into_iter().map(|g| g.gid).collect::<Vec<_>>();+    if let Some(f) = face(std::path::Path::new("/System/Library/Fonts/Supplemental/Khmer Sangam MN.ttf")) {+        for (tri, u) in [("អ៊ិ", "អុិ"), ("ស៊ី", "សុី"), ("ហ៊ឹ", "ហុឹ"), ("ក្អ៊ិ", "ក្អុិ")] {+            assert_eq!(gids(&f, tri), gids(&f, u), "{tri}");+        }+        // Other bases keep their triisap, and clusters index the original string.+        assert_ne!(gids(&f, "ប៊ិ"), gids(&f, "បុិ"));+        assert!(crate::shape(&f, "អ៊ិ", &[], |c| c).iter().all(|g| g.cluster == 0));+    }+    // The user's font folder, read at run time: no home directory (some Windows runners) skips these.+    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(std::path::PathBuf::from);+    for font in ["!KhmerOSSystem.ttf", "Busra-R.ttf"] {+        if let Some(f) = home.as_ref().and_then(|h| face(&h.join("Library/Fonts").join(font))) {+            let _ = gids(&f, "អ៊ិ");+            assert_eq!(f.triisap_u.get().copied(), Some([false; 3]), "{font} already does the U-form");+        }+    }+}diff --git a/crates/fonts/src/khmer.rs b/crates/fonts/src/khmer.rsnew file mode 100644--- /dev/null+++ b/crates/fonts/src/khmer.rs@@ -0,0 +1,119 @@+//! Khmer register shifter fallback: triisap (U+17CA) over a consonant with an upper vowel.+//!+//! Khmer OS System draws the triisap of `អ៊ិ` `ស៊ី` `ហ៊ឹ` as the U-form below the base, so it+//! doesn't collide with the upper vowel. Some fonts (Khmer Sangam MN for `អ`) leave it above and+//! stack both marks over the consonant. A face that doesn't do the U-form for one of these bases+//! gets it from here: the triisap is shaped as U+17BB (vowel sign U), which every Khmer font+//! draws below the base. Only the shaped character changes; the document text and cluster+//! indices don't.++use crate::FontFace;++const TRIISAP: char = '\u{17CA}';+const SIGN_U: char = '\u{17BB}';+/// The consonants whose triisap goes below when an upper vowel follows: អ ស ហ.+const BASES: [char; 3] = ['\u{17A2}', '\u{179F}', '\u{17A0}'];+/// A base whose triisap stays above: ប.+const NEVER_U: char = '\u{1794}';+/// The vowel the probes use: ិ.+const PROBE_VOWEL: char = '\u{17B7}';++fn is_upper_vowel(c: char) -> bool {+    matches!(c, '\u{17B7}'..='\u{17BA}' | '\u{17BE}')+}++/// Is `c` the triisap?+pub(crate) fn is_triisap(c: char) -> bool {+    c == TRIISAP+}++/// Is a triisap after `prev` and before `next` drawn as U+17BB (vowel sign U) instead? It is when+/// `prev` is a base in `needs` and an upper vowel follows. `needs` (the face's per-base flags) is+/// only asked for once the characters fit, so a face is probed on its first such triisap.+pub(crate) fn takes_u_form<'a>(prev: char, next: char, needs: impl FnOnce() -> &'a [bool; 3]) -> bool {+    if !is_upper_vowel(next) {+        return false;+    }+    let Some(k) = BASES.iter().position(|b| *b == prev) else { return false };+    needs().get(k).copied().unwrap_or(false)+}++/// U+17BB, the character a triisap is shaped as when [`takes_u_form`].+pub(crate) const U_FORM: char = SIGN_U;++/// U+25CC, the dotted circle a shaper draws under a mark that has no valid base.+const DOTTED_CIRCLE: char = '\u{25CC}';++/// Does a face need the rewrite for one base, given its shaping of the base with a triisap (`tri`)+/// and with U+17BB (`u`), the triisap glyph over a base that never takes the U-form (`above`) and+/// its dotted-circle glyph (`circle`)? It does when the triisap stays as it is over that base, and+/// the rewritten sequence neither adds width nor holds the dotted circle (the face rejects the+/// invalid U + vowel pair). Each entry is (glyph, advance).+fn needs_rewrite(tri: &[(u32, i32)], u: &[(u32, i32)], above: Option<u32>, circle: Option<u32>) -> bool {+    let width = |g: &[(u32, i32)]| g.iter().map(|x| x.1).sum::<i32>();+    above.is_some() && tri.get(1).map(|g| g.0) == above && width(tri) == width(u) && !circle.is_some_and(|c| u.iter().any(|g| g.0 == c))+}++/// For each base, whether `face` needs the rewrite (see [`needs_rewrite`]). A face that already+/// does the U-form shapes the triisap differently over these bases, so it is left alone. `shape`+/// gives (glyph, advance).+pub(crate) fn probe(face: &FontFace, shape: impl Fn(&str) -> Vec<(u32, i32)>) -> [bool; 3] {+    if face.glyph_for(SIGN_U) == 0 || face.glyph_for(TRIISAP) == 0 || face.glyph_for(NEVER_U) == 0 {+        return [false; 3];+    }+    let seq = |b: char, m: char| -> String { [b, m, PROBE_VOWEL].iter().collect() };+    let above = shape(&seq(NEVER_U, TRIISAP)).get(1).map(|g| g.0);+    let circle = Some(face.glyph_for(DOTTED_CIRCLE)).filter(|g| *g != 0);+    BASES.map(|b| face.glyph_for(b) != 0 && needs_rewrite(&shape(&seq(b, TRIISAP)), &shape(&seq(b, SIGN_U)), above, circle))+}++#[cfg(test)]+mod tests {+    use super::*;++    /// `s` as the shaper would see it: the loop in `shape_dir`, over a face that needs `needs`.+    fn run(s: &str, needs: [bool; 3]) -> String {+        let cs: Vec<char> = s.chars().collect();+        (0..cs.len())+            .map(|i| {+                let prev = if i > 0 { cs[i - 1] } else { '\0' };+                match cs.get(i + 1) {+                    Some(&next) if is_triisap(cs[i]) && takes_u_form(prev, next, || &needs) => U_FORM,+                    _ => cs[i],+                }+            })+            .collect()+    }++    #[test]+    fn rewrites_only_listed_bases_before_upper_vowels() {+        assert_eq!(run("អ៊ិ", [true, false, false]), "អុិ");+        assert_eq!(run("ស៊ី", [true, false, false]), "ស៊ី");+        assert_eq!(run("ស៊ី", [false, true, false]), "សុី");+        assert_eq!(run("ហ៊ឹ", [false, false, true]), "ហុឹ");+        assert_eq!(run("ក្អ៊ិ", [true, false, false]), "ក្អុិ");+    }++    #[test]+    fn leaves_other_contexts() {+        assert_eq!(run("អ៊ា", [true; 3]), "អ៊ា");+        assert_eq!(run("ប៊ិ", [true; 3]), "ប៊ិ");+        assert_eq!(run("អ៊", [true; 3]), "អ៊");+        assert_eq!(run("៊ិ", [true; 3]), "៊ិ");+    }++    #[test]+    fn rewrite_is_refused_when_the_replacement_has_the_dotted_circle() {+        // (glyph, advance): base, triisap left above, vowel; the same base with U + vowel.+        let tri = [(1, 1500), (7, 0), (9, 0)];+        let above = Some(7);+        assert!(needs_rewrite(&tri, &[(1, 1500), (8, 0), (9, 0)], above, Some(40)));+        // No dotted-circle glyph in the face: as before.+        assert!(needs_rewrite(&tri, &[(1, 1500), (8, 0), (9, 0)], above, None));+        // A zero-width dotted circle among the glyphs: refused although the width matches.+        assert!(!needs_rewrite(&tri, &[(1, 1500), (40, 0), (9, 0)], above, Some(40)));+        // A wider result is still refused, and a triisap that is already below is left alone.+        assert!(!needs_rewrite(&tri, &[(1, 1500), (8, 0), (9, 1393)], above, Some(40)));+        assert!(!needs_rewrite(&[(1, 1500), (8, 0), (9, 0)], &[(1, 1500), (8, 0), (9, 0)], above, Some(40)));+    }+}diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs--- a/crates/fonts/src/lib.rs+++ b/crates/fonts/src/lib.rs@@ -9,6 +9,7 @@  pub mod color; mod fontdb;+mod khmer; pub mod math; mod script; mod ui_fallback;@@ -208,14 +209,36 @@ fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(ch     let mut out = Vec::with_capacity(text.len());     let shaped = face.hb().map(|hb| {         let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();+        // Which Khmer bases this face leaves a triisap above an upper vowel for, probed on first use.+        let triisap_u = || {+            face.triisap_u.get_or_init(|| {+                khmer::probe(face, |s| {+                    let mut b = UnicodeBuffer::new();+                    b.push_str(s);+                    b.guess_segment_properties();+                    let gb = shaper.shape(b, ShapeOptions::new());+                    gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, p.x_advance)).collect()+                })+            })+        };         // One script per shaping call: the shaper picks its script engine (Myanmar, Khmer,         // Indic, USE reordering…) from the run's script, so "Hi မြန်မာ" in one face must not be         // shaped as Latin. Runs come in text order; right-to-left callers regroup by cluster.         for r in script::script_runs(text) {             let Some(sub) = text.get(r.clone()) else { continue };             let mut buf = UnicodeBuffer::new();-            for (i, c) in sub.char_indices() {-                buf.add(map(c), (r.start + i) as u32);+            let mut prev = '\0';+            let mut it = sub.char_indices().peekable();+            while let Some((i, c)) = it.next() {+                let mut m = map(c);+                if khmer::is_triisap(m)+                    && let Some(&(_, next)) = it.peek()+                    && khmer::takes_u_form(prev, map(next), triisap_u)+                {+                    m = khmer::U_FORM;+                }+                buf.add(m, (r.start + i) as u32);+                prev = m;             }             buf.guess_segment_properties();             buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });