diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index 8151b59cd6..ed50f0064e 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -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, step: fn(usize) -> Option| { 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, 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(); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index db6016aa95..989dbbd65a 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -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 = + "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:?}"); +}