From 30bba303aae078d5cc04dc383fb2c6d785c3f7cb Mon Sep 17 00:00:00 2001 From: Salah1St <83543012+Salah1St@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:30:08 +0700 Subject: [PATCH] M4: justified text doesn't widen U+200B Thai, Lao, Khmer and Burmese are written without spaces between words. Tools that know the words insert U+200B ZERO WIDTH SPACE so lines can break between them, and line breaking already treats it as a space. But justification counted every space-class char as a gap, so a justified paragraph got extra room at every U+200B: visible gaps between all its words, which is not how these scripts are justified. Item::stretches() (a space other than U+200B) is used for the justify gap count, the trailing width and the pen advance. Line breaking is unchanged. Test: on a justified line of words joined by U+200B and one real space, the extra room goes to the real space only; with no real space the line is laid out as if left-aligned. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 11 ++++++++--- crates/text/src/tests.rs | 40 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 26ae3d33..7731af15 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -257,6 +257,11 @@ impl Item { fn is_space(&self) -> bool { crate::breaks::is_space(self.ch) } + /// A space justified text widens: not U+200B, which only marks where a line may break (between + /// the words of Thai, Lao, Khmer or Burmese text, written without spaces). + fn stretches(&self) -> bool { + self.is_space() && self.ch != '\u{200B}' + } } /// Bidi embedding levels per char, and whether each char's paragraph runs right to left. @@ -587,7 +592,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv); let gstart = out.glyphs.len(); // Justify: extra space per gap. - let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count(); + let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count(); let justify_extra = match (text.align_h, wrap) { (TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => { ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0) @@ -603,7 +608,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) .unwrap_or(&[]) .iter() .filter(|it| !is_line_break(it.ch)) - .map(|it| it.adv + if it.is_space() { justify_extra } else { 0.0 }) + .map(|it| it.adv + if it.stretches() { justify_extra } else { 0.0 }) .sum(); let mut pen = if rtl_line { -trailing } else { line_indent - hang_l }; // List marker (before the line's text; zero-length char range so carets skip it). @@ -698,7 +703,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) }); } pen += it.adv; - if it.is_space() { + if it.stretches() { pen += justify_extra; } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ebf5889c..459667c2 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -497,3 +497,43 @@ fn the_caret_after_a_line_break_sits_on_the_next_line() { let l = layout(&TextData::new("ab\n"), None, None); assert_eq!(l.caret(3), (0.0, 1)); } + +/// Thai is written without spaces between words; U+200B ZERO WIDTH SPACE marks where a line may +/// break. ภาษา|ไทย|เขียน|ติดกัน ("Thai is written joined up") ไม่มี|ช่องว่าง|ระหว่าง|คำ ("with no +/// spaces between words"), with one real space between the two phrases. +#[test] +fn justified_lines_dont_widen_zero_width_spaces() { + if !have_fonts() { + return; + } + let lay_out = |s: &str, align, w| { + let mut t = TextData::new(s); + t.style.font_size = 20.0; + t.align_h = align; + t.auto_resize = figcraft_doc::TextAutoResize::Height; + layout(&t, w, None) + }; + let chars_before = |s: &str, pat: &str| s.find(pat).map(|b| s[..b].chars().count()).unwrap(); + for (s, why) in [ + ("ภาษา\u{200B}ไทย\u{200B}เขียน\u{200B}ติดกัน ไม่มี\u{200B}ช่องว่าง\u{200B}ระหว่าง\u{200B}คำ", "the real space takes the extra room"), + ("ภาษา\u{200B}ไทย\u{200B}เขียน\u{200B}ติดกัน\u{200B}ไม่มี\u{200B}ช่องว่าง\u{200B}ระหว่าง\u{200B}คำ", "no space to widen: as left-aligned"), + ] { + // A box that ends inside ช่องว่าง: the first line breaks at the U+200B before it. + let word = chars_before(s, "ช่องว่าง"); + let natural = lay_out(s, TextAlignH::Left, None); + let (a, b) = (natural.caret(word).0, natural.caret(word + 4).0); + assert!(b > a, "{why}: chars need advances"); + let w = a + (b - a) / 2.0; + let left = lay_out(s, TextAlignH::Left, Some(w)); + let just = lay_out(s, TextAlignH::Justified, Some(w)); + assert!(just.lines.len() >= 2 && just.lines[0].char_end == word, "{why}: {:?}", just.lines); + let space = s.chars().position(|c| c == ' '); + let extra = w - left.lines[0].width; + assert!(extra > 0.0, "{why}"); + assert_eq!(left.glyphs.len(), just.glyphs.len()); + for (l, j) in left.glyphs.iter().zip(&just.glyphs).filter(|(g, _)| g.char_end <= word) { + let want = if space.is_some_and(|sp| l.char_start > sp) { extra } else { 0.0 }; + assert!((j.x - l.x - want).abs() < 1e-6, "{why}: char {} moved {} (want {want})", l.char_start, j.x - l.x); + } + } +}