From 37d0f37110ecc527a1ba32f688a612c8ca054d94 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 13:09:56 +0800 Subject: [PATCH] M3: nonspacing marks count toward neither direction of a line (view.copy-text) reverse_rtl's paragraph direction and keep_taken_back_blanks' line direction both leave glyphs made only of nonspacing marks out of their counts (is_nonspacing_mark), so a few Hebrew letters carrying many points don't turn a mostly Latin line right to left. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/render/src/text/order.rs | 15 ++++++++++----- crates/render/src/text/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index 8389641b4e..b442771820 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -371,7 +371,7 @@ fn keep_taken_back_blanks(glyphs: &[&TextGlyph], placed: &mut [usize]) { } // The line's direction, as `reverse_rtl` takes it: right-to-left when it has more right-to-left letters than // left-to-right ones. A line's edge has that direction (UAX #9 X10: sos and eos). - let count = |b: Bidi| placed.iter().filter(|&&i| glyphs.get(i).is_some_and(|g| bidi_class(&g.text) == b)).count(); + let count = |b: Bidi| placed.iter().filter(|&&i| glyphs.get(i).is_some_and(|g| !is_nonspacing_mark(&g.text) && bidi_class(&g.text) == b)).count(); let line_rtl = count(Bidi::R) > count(Bidi::L); // Whether the nearest glyph with a strong direction from position `from` on, stepping by `step`, is right-to-left, // or the line is when there is none before its edge. @@ -593,13 +593,17 @@ fn bidi_class(text: &str) -> Bidi { } } +/// Whether `text` is only nonspacing marks (bidi class NSM). Such a glyph counts toward neither direction of a line: +/// it has the direction of the letter it sits on. +fn is_nonspacing_mark(text: &str) -> bool { + !text.is_empty() && text.chars().all(|c| unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM) +} + /// UAX #9 W1: a nonspacing mark takes the class of the letter it rides on ([`Scratch::anchor`]), which placement keeps /// right in front of it. A mark that rides on nothing (one after a word gap, or a right-to-left mark, which placement /// puts by its own position) keeps its own class. fn resolve_marks(glyphs: &[&TextGlyph], anchor: &[usize], items: &mut [(Option, Bidi)]) { - let nsm = |i: usize| { - glyphs.get(i).is_some_and(|g| !g.text.is_empty() && g.text.chars().all(|c| unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM)) - }; + let nsm = |i: usize| glyphs.get(i).is_some_and(|g| is_nonspacing_mark(&g.text)); for k in 1..items.len() { let (prev, base) = match items.get(k - 1) { Some(&(Some(prev), base)) => (prev, base), @@ -633,7 +637,8 @@ pub(super) fn reverse_rtl(glyphs: &[&TextGlyph], anchor: &[usize], idx: &mut [us let text = |k: usize| texts.get(k).copied().unwrap_or(""); // The paragraph's direction counts letters as they are, before marks take their letter's class, as // `keep_taken_back_blanks` counts them. - let count = |b: Bidi| items.iter().filter(|it| it.1 == b).count(); + let count = + |b: Bidi| items.iter().filter(|it| it.1 == b && !it.0.and_then(|i| glyphs.get(i)).is_some_and(|g| is_nonspacing_mark(&g.text))).count(); let para = if count(Bidi::R) > count(Bidi::L) { Bidi::R } else { Bidi::L }; resolve_marks(glyphs, anchor, &mut items); let n = items.len(); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 9310632963..1a23aaab4b 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -2432,3 +2432,28 @@ fn a_hebrew_mark_after_a_latin_letter_stays_with_the_hebrew_word() { // The mark follows its letter, the last of "שלום", before the Latin text. assert_eq!(text, "שלום\u{5B8}ab"); } + +/// Nonspacing marks count toward neither direction of a line: a line of four Latin letters and a +/// three-letter Hebrew word whose letters each carry two points reads in the same order as without +/// the points. +#[test] +fn marks_do_not_decide_a_lines_direction() { + let line = |marks: bool| { + let mut v: Vec = + ["a", "b", "c", "d"].iter().zip(0u8..).map(|(c, k)| em_box(c, 6.0 * f32::from(k), 0.0, 6.0 * f32::from(k) + 6.0, 11.0)).collect(); + let mut x = 27.5; + for c in ["ו", "ל", "ש"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + if marks { + v.push(em_box("\u{5B8}", x + 1.0, 0.0, x + 1.0, 11.0)); + v.push(em_box("\u{5B4}", x + 2.0, 0.0, x + 2.0, 11.0)); + } + x += 6.5; + } + layout(v).plain_text() + }; + // Compared on the letters alone: the points' own word gaps are not what this is about. + let letters = |s: String| s.chars().filter(|&c| c != '\u{5B8}' && c != '\u{5B4}' && c != ' ').collect::(); + assert_eq!(letters(line(true)), letters(line(false))); + assert!(letters(line(true)).starts_with("abcd"), "a mostly Latin line reads Latin first"); +}