M3: a combining mark takes its letter's direction, a line's edge has the line's direction, and overprinting runs are placed by their clusters (view.copy-text)

- reverse_rtl applies UAX #9 W1: a nonspacing mark takes the class of the letter it rides on,
  so a Latin accent at a word end on a right-to-left line stays after its letter.
- keep_taken_back_blanks reads a line's edge as the line's direction (UAX #9 X10) instead of
  right-to-left, so a neutral at the edge of a mostly left-to-right line keeps its place.
- Rows of overprinting runs are ordered by where their clusters start, so a riding mark that
  reaches left of its letter cannot put its run first.
- The two-taken-back-spaces test checks the untrimmed text.

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 11:56:38 +08:00
1 parent 94203a2b78
commit c4cc9e0c37
2 files changed
+89 -5

No files matched your search

+31 -4
View File
@@ -344,7 +344,8 @@ const TAKEN_BACK_SCAN: usize = 8;
/// stores it. Only a right-to-left letter or a neutral (punctuation, a symbol) after the blank counts: left-to-right
/// text has no reverse-order show strings, and in scanned pages' invisible text layers its spaces overlap letters at
/// random. A neutral counts only inside right-to-left text: the nearest letter or number before it, and the nearest
/// after the blank, is right-to-left (numbers count as such beside neutrals) or the line's edge. A parenthesis that opens Latin text then stays
/// after the blank, is right-to-left (numbers count as such beside neutrals), or the line's edge on a right-to-left
/// line. A parenthesis that opens Latin text then stays
/// where x order puts it, as on a line without right-to-left text. A glyph that only touches the blank's start was
/// drawn beside it, as text set letter by letter leftwards is, and x order already reads it right. A blank that a
/// glyph placed before them also reaches over sits inside a cluster (a letter and the marks drawn on it), where its
@@ -368,12 +369,16 @@ fn keep_taken_back_blanks(glyphs: &[&TextGlyph], placed: &mut [usize]) {
if !placed.iter().any(|&i| glyphs.get(i).is_some_and(|g| g.text.chars().any(is_rtl))) {
return;
}
// 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 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 there is none before the line's edge.
// or the line is when there is none before its edge.
let rtl_side = |placed: &[usize], from: Option<usize>, step: fn(usize) -> Option<usize>| {
let mut p = from;
for _ in 0..TAKEN_BACK_SCAN {
let Some(g) = p.and_then(|p| glyph(placed, p)) else { return true };
let Some(g) = p.and_then(|p| glyph(placed, p)) else { return line_rtl };
if let Some(rtl) = strong(g) {
return rtl;
}
@@ -536,7 +541,9 @@ pub(super) fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f
let start = sc.group.len();
sc.group.extend(run.range());
sc.group[start..].sort_unstable_by(|a, b| by_cluster(glyphs, &sc.anchor, *a, *b));
let left = sc.group[start..].iter().map(|&i| x0(i)).fold(f32::INFINITY, f32::min);
// Ordered by where its clusters start: a mark riding on a glyph may reach left of it, but it has no place of
// its own, so it cannot put its run before one whose first letter stands further left.
let left = sc.group[start..].iter().map(|&i| x0(anchor_of(&sc.anchor, i))).fold(f32::INFINITY, f32::min);
let right = sc.group[start..].iter().map(|&i| glyphs[i].rect[2]).fold(f32::NEG_INFINITY, f32::max);
let lead = sc.group[start];
sc.rows.push(Row { left, right, first: run.start, lead, glyphs: start..sc.group.len() });
@@ -586,6 +593,25 @@ 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)]) {
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,
_ => continue,
};
if let Some(it) = items.get_mut(k)
&& it.0.is_some_and(nsm)
{
it.1 = base;
}
}
}
/// Reorders a line of `idx` from visual to logical order with a simplified bidi level pass (#2667), carrying the
/// 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
@@ -601,6 +627,7 @@ 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);
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 };
let n = items.len();
+58 -1
View File
@@ -1347,7 +1347,8 @@ fn two_taken_back_spaces_on_a_line_each_stay_before_their_letter() {
v.push(em_box(c, x, 0.0, x + 6.5, 11.0));
x += 6.5;
}
assert_eq!(layout(v).plain_text().trim(), "המדינה");
// Untrimmed: both spaces follow the word, in drawing order.
assert_eq!(layout(v).plain_text(), "המדינה ");
}
/// pdf.js issue 11656 (https://github.com/mozilla/pdf.js/issues/11656): its content stream rebuilt with the same
@@ -2359,3 +2360,59 @@ fn the_glyph_at_a_point_is_the_first_whose_box_holds_it() {
assert_eq!(text.glyph_at(25.0, 5.0), None);
assert_eq!(PageText::default().glyph_at(0.0, 0.0), None);
}
/// A combining mark takes the direction of the letter it rides on (UAX #9 W1, nonspacing marks): a Latin accent at the
/// end of a Latin word on a right-to-left line stays after its letter, not on the far side of it.
#[test]
fn a_latin_mark_at_a_word_end_on_a_right_to_left_line_stays_after_its_letter() {
// Drawn left to right: "abé" (e and a combining acute riding on it), a space, then "שלום" right to left.
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), em_box("e", 12.0, 0.0, 18.0, 11.0)];
v.push(em_box("\u{301}", 14.0, 0.0, 14.0, 11.0));
v.push(em_box(" ", 18.0, 0.0, 21.5, 11.0));
let mut x = 21.5;
for c in ["ם", "ו", "ל", "ש", "ם", "ו", "ל", "ש"] {
v.push(em_box(c, x, 0.0, x + 6.5, 11.0));
x += 6.5;
}
assert_eq!(layout(v).plain_text().trim_end(), "שלוםשלום abe\u{301}");
}
/// A line's edge has the line's direction (UAX #9 X10): on a line of mostly left-to-right text, a neutral taken back
/// over a space at the line's left edge is not inside right-to-left text, so it stays where its position puts it,
/// however close a Hebrew word is on its other side.
#[test]
fn a_taken_back_neutral_at_the_edge_of_a_left_to_right_line_keeps_its_place() {
let line = |words: &[&str]| {
let mut v = vec![em_box(" ", 10.0, 0.0, 13.5, 11.0), em_box("(", 9.9, 0.0, 15.0, 11.0)];
let mut x = 15.0;
for word in words {
for c in word.chars() {
v.push(em_box(&c.to_string(), x, 0.0, x + 6.5, 11.0));
x += 6.5;
}
x += 3.5;
}
layout(v).plain_text()
};
// Mostly Latin: x order, "(" first.
assert_eq!(line(&["םולש", "Hello", "world", "again"]).trim(), "( שלום Hello world again");
// Mostly Hebrew: the line's edge is right-to-left, so "(" is taken back with the space, as "?" is in
// `a_space_taken_back_under_the_next_letter_stays_before_it`.
assert_eq!(line(&["םולש", "םלוע", "AB"]).trim_end(), "AB עולם שלום(");
}
/// Runs that overprint each other are placed by where their clusters start: a combining mark riding on the second
/// run's first letter and reaching left of the first run's start does not put the second run first.
#[test]
fn a_mark_reaching_left_of_its_letter_does_not_reorder_overprinting_runs() {
// A first run "ABCDEF", then a second run drawn back over it, its "c" starting 1 pt right of "A" and the acute on
// that "c" reaching 1.5 pt left of "A".
let mut v: Vec<TextGlyph> =
"ABCDEF".chars().zip(0u8..).map(|(c, k)| em_box(&c.to_string(), 100.0 + 6.0 * f32::from(k), 0.0, 106.0 + 6.0 * f32::from(k), 11.0)).collect();
v.push(em_box("c", 101.0, 0.0, 107.0, 11.0));
v.push(em_box("\u{301}", 98.5, 0.0, 102.5, 11.0));
v.push(em_box("d", 107.0, 0.0, 113.0, 11.0));
let text = layout(v).plain_text();
assert!(text.starts_with("ABCDEF"), "{text:?}");
assert!(text.contains("c\u{301}d"), "{text:?}");
}