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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-08 13:09:56 +08:00
1 parent ee4b044293
commit 37d0f37110
2 files changed
+35 -5

No files matched your search

+10 -5
View File
@@ -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<usize>, 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();
+25
View File
@@ -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<TextGlyph> =
["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::<String>();
assert_eq!(letters(line(true)), letters(line(false)));
assert!(letters(line(true)).starts_with("abcd"), "a mostly Latin line reads Latin first");
}