collective

Browse / github.com/storytold/figcraft / patch

M4: Thai lines keep marks and leading vowels with their letter (part of #1219)

M4: Thai lines keep marks and leading vowels with their letter (part of #1219) Part of #1219. Thai, Lao, Khmer and Burmese have no spaces between words, so a paragraph has no break opportunity and wraps wherever it overflows. That emergency break could start a line with a letter's trailing mark, or end one with a leading vowel (`…ที่มีโ` / `ครงสร้าง…`), which no Thai reader accepts. It could also split the glyphs of one cluster. This PR leaves the emergency break as it is, but: - it backs up to a boundary that keeps a trailing mark with its letter and a leading vowel (Thai U+0E40–0E44, Lao U+0EC0–0EC4) with the consonant after it. If the line's first character is in su

Status
open
Score
0.00
Adopters
0 other operators
Author
SusieeTheLinuxUser (imported from pull request #1400, open)
Base
740d117313d7476e29b84f8697af45c40404a270
Size
+89 −1 in 3 files
Review
pending
Shared
2026-10-28
Id
86ce808632119086e66f8cee

How it went

No other operator has reported using it yet.

Diff

raw · crates/text/src/behaviour_tests.rs crates/text/src/breaks.rs crates/text/src/layout.rs

diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs--- a/crates/text/src/behaviour_tests.rs+++ b/crates/text/src/behaviour_tests.rs@@ -334,6 +334,56 @@ fn thai_comes_from_a_thai_font_when_one_is_installed() {     assert_eq!(drawn, want, "every Thai char is in a drawn cluster"); } +#[test]+fn thai_emergency_break_can_back_up_to_the_first_letter() {+    // #1219: in "กเม" the overflow lands between the leading vowel and its consonant; the only+    // safe break is right after the first letter, so the vowel moves down with "ม".+    let s = "กเม";+    let thai = ["Noto Sans Thai", "Noto Sans Thai UI", "Leelawadee UI", "Thonburi", "Tahoma"];+    if face().is_none() || !thai.iter().any(|t| find_face(t, "Regular").is_some_and(|t| s.chars().all(|c| t.has_char(c)))) {+        return;+    }+    let mut t = text(s, TextAutoResize::Height);+    t.style.font_size = 16.0;+    let two = layout(&t, None, None).caret(2).0;+    let lines = layout(&t, Some(two + 0.5), None).lines;+    assert_eq!(lines.get(1).map(|l| l.char_start), Some(1), "the leading vowel stays with its consonant");+}++#[test]+fn thai_emergency_breaks_keep_marks_and_leading_vowels_with_their_letter() {+    // #1219: Thai writes no spaces between words, so a paragraph is one run with no break+    // opportunity and wraps where it overflows. Even that emergency break must not start a line+    // with a mark (it belongs to the letter before it) or end one with a leading vowel (it is+    // written before the consonant it is read with). Skips without a Thai face.+    let s = "ระบบช่วยให้ทีมพัฒนาซอฟต์แวร์จัดทำเอกสารที่มีโครงสร้างได้ในที่เดียว";+    let chars: Vec<char> = s.chars().collect();+    let thai = ["Noto Sans Thai", "Noto Sans Thai UI", "Leelawadee UI", "Thonburi", "Tahoma"];+    if face().is_none() {+        return;+    }+    if !thai.iter().any(|t| find_face(t, "Regular").is_some_and(|t| chars.iter().all(|c| t.has_char(*c)))) {+        return;+    }+    let mut t = text(s, TextAutoResize::Height);+    t.style.font_size = 16.0;+    let total = layout(&t, None, None).caret(chars.len()).0;+    let is_mark = |c: char| matches!(c, '\u{0E31}' | '\u{0E34}'..='\u{0E3A}' | '\u{0E47}'..='\u{0E4E}');+    let is_leading_vowel = |c: char| ('\u{0E40}'..='\u{0E44}').contains(&c);+    let mut breaks = 0;+    for k in 1..40 {+        let width = total * f64::from(k) / 40.0;+        for line in layout(&t, Some(width), None).lines.iter().skip(1) {+            let (Some(start), Some(&first)) = (line.char_start.checked_sub(1), chars.get(line.char_start)) else { continue };+            let Some(&before) = chars.get(start) else { continue };+            assert!(!is_mark(first), "at {width:.1} px a line starts with U+{:04X}: {s}", first as u32);+            assert!(!is_leading_vowel(before), "at {width:.1} px a line ends with {before:?} before {first:?}: {s}");+            breaks += 1;+        }+    }+    assert!(breaks > 0, "the sentence wraps at least once across these widths");+}+ #[test] fn symbols_come_from_a_symbol_font_when_one_is_installed() {     // ⌖ (U+2316) and ⤒ (U+2912) are in no Latin text face. With a symbol font that has them (Applediff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs--- a/crates/text/src/breaks.rs+++ b/crates/text/src/breaks.rs@@ -76,6 +76,14 @@ pub(crate) fn is_space(c: char) -> bool {     class(c) == Class::Space } +/// Whether a line may break between `a` and the `b` after it when the text offers no break+/// opportunity and has to break inside a word (Thai, Lao, Khmer and Burmese write no spaces):+/// never before a letter's trailing mark, which belongs to the letter before it, and never after a+/// leading vowel, which is written before the consonant it is read with (#1219).+pub(crate) fn safe_emergency_break(a: char, b: char) -> bool {+    !is_trailing_mark(b) && !matches!(a, '\u{0E40}'..='\u{0E44}' | '\u{0EC0}'..='\u{0EC4}')+}+ /// For each char of `text`, whether it starts a grapheme cluster (a user-perceived character: /// an emoji sequence, a letter with its combining marks). pub(crate) fn grapheme_starts(text: &str) -> Vec<bool> {@@ -137,4 +145,15 @@ mod tests {         assert!(!break_between('日', '\u{2007}') && !break_between('\u{2007}', '日'), "nor beside ideographs");         assert!(!is_space('\u{2007}'));     }++    #[test]+    fn emergency_breaks_keep_marks_and_leading_vowels_with_their_letter() {+        // #1219: Thai and Lao write no spaces, so these are where a line has to be forced to break.+        assert!(!safe_emergency_break('\u{0E01}', '\u{0E49}'), "never before a tone mark");+        assert!(!safe_emergency_break('\u{0E01}', '\u{0E33}'), "nor before SARA AM");+        assert!(!safe_emergency_break('\u{0E40}', '\u{0E2B}'), "never after a leading vowel");+        assert!(!safe_emergency_break('\u{0EC0}', '\u{0EAB}'), "nor in Lao");+        assert!(safe_emergency_break('\u{0E01}', '\u{0E02}'), "between two letters is fine");+        assert!(safe_emergency_break('\u{0E2B}', '\u{0E40}'), "before a leading vowel is fine");+    } }diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs--- a/crates/text/src/layout.rs+++ b/crates/text/src/layout.rs@@ -506,7 +506,26 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)             && i > line_start             && !it.is_space()         {-            let brk = last_break.filter(|b| *b >= line_start).map(|b| b + 1).unwrap_or(i);+            let mut brk = last_break.filter(|b| *b >= line_start).map(|b| b + 1).unwrap_or(i);+            // Even an emergency break inside a word keeps a letter with its trailing mark and a+            // leading vowel with the consonant after it (#1219). Back up to such a boundary, or,+            // when the line's first character is in one, carry it over to the next line with it.+            let safe = |k: usize| -> bool {+                let (Some(a), Some(b)) = (items.get(k.wrapping_sub(1)), items.get(k)) else { return true };+                let at = b.char_start;+                let Some(&before) = at.checked_sub(1).and_then(|c| chars.get(c)) else { return true };+                // A break can only fall between two glyphs that are not the same cluster.+                a.char_end <= at+                    && cluster_start.get(at).copied().unwrap_or(true)+                    && crate::breaks::safe_emergency_break(before, chars.get(at).copied().unwrap_or(before))+            };+            if !safe(brk) {+                // Capped: a malformed run of thousands of marks must not make layout quadratic.+                let carry = (brk + 1..items.len().min(brk + 257))+                    .take_while(|&k| items.get(k).is_some_and(|it| it.ch != '\n' && it.ch != LINE_SEPARATOR))+                    .find(|&k| safe(k));+                brk = (line_start + 1..brk).rev().find(|&k| safe(k)).or(carry).unwrap_or(brk);+            }             lines.push((line_start, brk, false, para));             line_start = brk;             i = brk;