mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-02 16:29:34 -06:00
M4: Thai lines keep marks and leading vowels with their letter (part of #1219)
Thai, Lao, Khmer and Burmese write no spaces, so a paragraph has no break opportunity and wraps where it overflows. That emergency break could start a line with a letter's trailing mark or end one with a leading vowel, splitting a syllable (and, when the break fell between the glyphs of one cluster, drawing it twice). - The break backs up to a boundary that keeps a mark with its letter and a leading vowel with the consonant after it; when the line's first character is in one, it carries the pair over to the next line instead. Lao leading vowels count too. - It only breaks between grapheme clusters now, never between glyphs of one. - Tests: a font-free unit test for the boundary rule, and a Thai wrapping test over 39 widths (skips without a Thai face). Co-Authored-By: DeepSeek V4.1 Flash (opencode) <noreply@opencode.ai> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -334,6 +334,40 @@ 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_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 (Apple
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 + 2..brk).rev().find(|&k| safe(k)).or(carry).unwrap_or(brk);
|
||||
}
|
||||
lines.push((line_start, brk, false, para));
|
||||
line_start = brk;
|
||||
i = brk;
|
||||
|
||||
Reference in New Issue
Block a user