From ee4b04429312783fef60778853b2a29127f59120 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 12:31:11 +0800 Subject: [PATCH] M3: a mark takes the class of the letter it rides on, and the paragraph direction counts letters before marks (view.copy-text) - resolve_marks applies UAX #9 W1 only to a mark riding on the glyph before it (its anchor), so a Hebrew mark placed after a Latin letter keeps its own class and stays with its word. - reverse_rtl takes the paragraph direction before marks take their letter's class, as keep_taken_back_blanks counts the line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/render/src/text/layout.rs | 2 +- crates/render/src/text/order.rs | 22 ++++++++++++++-------- crates/render/src/text/tests.rs | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs index b5e2107e75..e147cda84d 100644 --- a/crates/render/src/text/layout.rs +++ b/crates/render/src/text/layout.rs @@ -509,7 +509,7 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { if has_rtl { sc.logical.clear(); sc.logical.extend_from_slice(&sc.placed); - reverse_rtl(&glyphs, &mut sc.logical, &mut spaces); + reverse_rtl(&glyphs, &sc.anchor, &mut sc.logical, &mut spaces); } let idx: &[usize] = if has_rtl { &sc.logical } else { &sc.placed }; order.extend_from_slice(idx); diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index ed50f0064e..8389641b4e 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -593,19 +593,23 @@ fn bidi_class(text: &str) -> Bidi { } } -/// UAX #9 W1: a nonspacing mark takes the class of the glyph before it, the letter it rides on, which placement keeps -/// right in front of it. A mark after a word gap (`None`) rides on nothing and keeps its own class. -fn resolve_marks(glyphs: &[&TextGlyph], items: &mut [(Option, Bidi)]) { +/// 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)) }; for k in 1..items.len() { - let base = match items.get(k - 1) { - Some(&(Some(_), base)) => base, + let (prev, base) = match items.get(k - 1) { + Some(&(Some(prev), base)) => (prev, base), _ => continue, }; if let Some(it) = items.get_mut(k) - && it.0.is_some_and(nsm) + && let Some(mark) = it.0 + && nsm(mark) + && anchor_of(anchor, mark) != mark + && anchor_of(anchor, mark) == anchor_of(anchor, prev) { it.1 = base; } @@ -616,7 +620,7 @@ fn resolve_marks(glyphs: &[&TextGlyph], items: &mut [(Option, Bidi)]) { /// word gaps along: `spaces[w]` is a gap left of `idx[w]` (#1062). The paragraph is right-to-left when it has /// more right-to-left than left-to-right glyphs; numbers keep their left-to-right order, and neutrals /// (punctuation, gaps) take the direction of their neighbours, or the paragraph's between opposite ones. -pub(super) fn reverse_rtl(glyphs: &[&TextGlyph], idx: &mut [usize], spaces: &mut [bool]) { +pub(super) fn reverse_rtl(glyphs: &[&TextGlyph], anchor: &[usize], idx: &mut [usize], spaces: &mut [bool]) { // Glyphs and word gaps (None) in visual order. let mut items: Vec<(Option, Bidi)> = Vec::with_capacity(idx.len() * 2); for (w, &i) in idx.iter().enumerate() { @@ -627,9 +631,11 @@ pub(super) fn reverse_rtl(glyphs: &[&TextGlyph], idx: &mut [usize], spaces: &mut } let texts: Vec<&str> = items.iter().map(|it| it.0.map_or("", |i| glyphs[i].text.as_str())).collect(); let text = |k: usize| texts.get(k).copied().unwrap_or(""); - resolve_marks(glyphs, &mut items); + // 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 para = if count(Bidi::R) > count(Bidi::L) { Bidi::R } else { Bidi::L }; + resolve_marks(glyphs, anchor, &mut items); let n = items.len(); // W4: a single separator between two digits belongs to the number. for k in 1..n.saturating_sub(1) { diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 989dbbd65a..9310632963 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -2416,3 +2416,19 @@ fn a_mark_reaching_left_of_its_letter_does_not_reorder_overprinting_runs() { assert!(text.starts_with("ABCDEF"), "{text:?}"); assert!(text.contains("c\u{301}d"), "{text:?}"); } + +/// A right-to-left mark rides on no glyph, so it keeps its own class: one placed after a Latin +/// letter, left of the Hebrew letter it belongs to, still reads with the Hebrew word, after its letter. +#[test] +fn a_hebrew_mark_after_a_latin_letter_stays_with_the_hebrew_word() { + let mut v = vec![em_box("a", 0.0, 0.0, 6.0, 11.0), em_box("b", 6.0, 0.0, 12.0, 11.0)]; + v.push(em_box("\u{5B8}", 11.9, 0.0, 11.9, 11.0)); + let mut x = 12.0; + for c in ["ם", "ו", "ל", "ש"] { + v.push(em_box(c, x, 0.0, x + 6.5, 11.0)); + x += 6.5; + } + let text = layout(v).plain_text(); + // The mark follows its letter, the last of "שלום", before the Latin text. + assert_eq!(text, "שלום\u{5B8}ab"); +}