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 <noreply@anthropic.com>
This commit is contained in:
Salah1St
2026-10-23 13:30:08 +07:00
co-authored by Claude Opus 5.5
parent 80f916326f
commit 30bba303aa
2 changed files with 48 additions and 3 deletions
+8 -3
View File
@@ -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<f64>, box_height: Option<f64>)
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<f64>, box_height: Option<f64>)
.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<f64>, box_height: Option<f64>)
});
}
pen += it.adv;
if it.is_space() {
if it.stretches() {
pen += justify_extra;
}
}
+40
View File
@@ -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);
}
}
}