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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-08 12:31:11 +08:00
1 parent c4cc9e0c37
commit ee4b044293
3 files changed
+31 -9

No files matched your search

+1 -1
View File
@@ -509,7 +509,7 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
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);
+14 -8
View File
@@ -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<usize>, 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<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))
};
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<usize>, 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<usize>, 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) {
+16
View File
@@ -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");
}