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
@@ -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 off@@ -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(),@@ -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");+ }+ }+}new file mode 100644@@ -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)));+ }+}@@ -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 });