M3: a space taken back under the next letter stays a word break, so right-to-left words are not split (view.copy-text)

A right-to-left show string in reverse order starts with the space that ends
its word (ISO 32000-2 §14.8.2.5.3), and a word processor pulls the next letter
back over it with a TJ adjustment a little larger than the space's width. The
letter then starts a hair left of the space, so sorting a line by x put the
space inside the word: copied and found text read "המדינ ה" for "המדינה" and
"כולם ?" for "כולם?" (pdf.js #11656), and Find missed both.

A blank glyph has no ink, so its box cannot place it against a glyph that
covers it. On a line with right-to-left text, when the glyph drawn right after
a blank is a right-to-left letter or a neutral, starts at or left of the
blank's start and covers at least half of it, the blank keeps its drawing
place before that glyph (§14.8.2.6.2: the space is a word break in the
character stream). Left-to-right text, a glyph that only touches the blank, and
a blank inside a letter-and-mark cluster keep their x order.

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 00:42:38 +08:00
1 parent 9dbede8709
commit 152d696fb0
3 files changed
+100 -3

No files matched your search

+35 -1
View File
@@ -243,7 +243,8 @@ pub(super) fn restarts_run(glyphs: &[&TextGlyph], start: usize, next: usize, pro
/// Glyphs are placed by x. Runs that collide, meaning that glyphs of different runs overprint each other, stay whole
/// and are placed by their left edge. Runs that only interleave along the line, such as symbols between letters, keep
/// their glyph-by-glyph order, so the common case is one sort. A run of zero-width glyphs (a combining mark) has no
/// extent of its own, so it goes next to the glyph it starts at.
/// extent of its own, so it goes next to the glyph it starts at. A space whose advance the next glyph takes back stays
/// before that glyph ([`keep_taken_back_blanks`]).
pub(super) fn place_glyphs(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, s: &Seg, sc: &mut Scratch) {
let x0 = |i: usize| glyphs[i].rect[0];
let zero_width = |i: usize| glyphs[i].rect[2] - glyphs[i].rect[0] <= 0.05 * height(i);
@@ -265,6 +266,7 @@ pub(super) fn place_glyphs(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32,
} else {
collide(glyphs, height, s.h, sc);
}
keep_taken_back_blanks(glyphs, &mut sc.placed);
// Each mark goes before the first placed glyph whose x is greater than its own. Marks are visited in x order, and
// that position never moves back, so one pass over `placed` puts all of them in.
sc.marks.sort_by(|a, b| x0(*a).total_cmp(&x0(*b)));
@@ -283,6 +285,38 @@ pub(super) fn place_glyphs(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32,
}
}
/// Puts a blank glyph (a space) back before the glyph drawn right after it on a line with right-to-left text, when that
/// glyph's adjustment took the blank's advance back: it starts at or left of the blank's own start and covers at least
/// half of it. Right-to-left text drawn in reverse order starts a show string with the space that ends its word
/// (ISO 32000-2 §14.8.2.5.3, whose NOTE 2 has such a space realign the typography without visible effect), and a word
/// processor pulls the next letter back over it. A blank has no ink, so its box says nothing about which side of that
/// letter it belongs on, and sorting by x split "המדינה" as "המדינ ה". The space is a word break in the character
/// stream (§14.8.2.6.2), and drawing order is the only evidence of its place.
///
/// 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
/// 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 place is no clearer, so it stays where x order put it.
fn keep_taken_back_blanks(glyphs: &[&TextGlyph], placed: &mut [usize]) {
let blank = |g: &TextGlyph| !g.text.is_empty() && g.text.chars().all(char::is_whitespace);
let left_to_right = |g: &TextGlyph| g.text.chars().any(|c| c.is_alphanumeric() && !is_rtl(c));
if !placed.iter().any(|&i| glyphs.get(i).is_some_and(|g| g.text.chars().any(is_rtl))) {
return;
}
for p in 1..placed.len() {
let (Some(&ink), Some(&space)) = (placed.get(p - 1), placed.get(p)) else { continue };
let (Some(s), Some(g)) = (glyphs.get(space), glyphs.get(ink)) else { continue };
let width = s.rect[2] - s.rect[0];
let covered = g.rect[2].min(s.rect[2]) - s.rect[0];
let taken_back = g.rect[0] <= s.rect[0] && width > 0.0 && covered >= 0.5 * width;
let alone = p.checked_sub(2).and_then(|q| placed.get(q)).and_then(|&q| glyphs.get(q)).is_none_or(|q| q.rect[2] <= s.rect[0]);
if space.checked_add(1) == Some(ink) && blank(s) && !blank(g) && !left_to_right(g) && taken_back && alone {
placed.swap(p - 1, p);
}
}
}
/// `place_glyphs` for a segment of two or more text runs. `h` is the segment's line height. Returns whether the segment
/// was too dense to sweep ([`COLLIDE_PAIRS_PER_GLYPH`]), in which case all its text runs form one component.
///
+63
View File
@@ -1185,6 +1185,69 @@ fn word_gaps_follow_position_not_drawing_order() {
assert_eq!(layout(v).plain_text(), "שלום עולם");
}
/// A right-to-left show string in reverse order starts with the space that ends its word, and the next letter is pulled
/// back over it (ISO 32000-2 §14.8.2.5.3): the space stays a word break after the word, not inside it (pdf.js #11656).
#[test]
fn a_space_taken_back_under_the_next_letter_stays_before_it() {
let rtl_line = |first: &str| {
// Drawn left to right: " הנידמה" with the space's advance taken back, then "רקבמ" after a word gap.
let mut v = vec![em_box(" ", 10.0, 0.0, 13.5, 11.0), em_box(first, 9.9, 0.0, 17.5, 11.0)];
let mut x = 17.5;
for c in ["נ", "י", "ד", "מ", "ה"] {
v.push(em_box(c, x, 0.0, x + 6.5, 11.0));
x += 6.5;
}
x += 3.5;
for c in ["ר", "ק", "ב", "מ"] {
v.push(em_box(c, x, 0.0, x + 6.5, 11.0));
x += 6.5;
}
layout(v)
};
let text = rtl_line("ה");
assert_eq!(text.plain_text().trim_end(), "מבקר המדינה");
assert_eq!(text.find("המדינה").len(), 1);
// The same with punctuation: the question mark ends the line, with no space before it.
assert_eq!(rtl_line("?").plain_text().trim_end(), "מבקר המדינ?");
// Right-to-left text drawn letter by letter leftwards: the letter after the space ends where the space starts, a
// rounding hair over it, and stays on its left.
let mut v = Vec::new();
let mut x = 60.0;
for c in ["ש", "ל", "ו", "ם"] {
v.push(em_box(c, x - 6.0, 0.0, x, 11.0));
x -= 6.0;
}
v.push(em_box(" ", x - 3.5, 0.0, x, 11.0));
x -= 3.5;
v.push(em_box("ע", x - 6.0, 0.0, x + 0.005, 11.0));
x -= 6.0;
for c in ["ו", "ל", "ם"] {
v.push(em_box(c, x - 6.0, 0.0, x, 11.0));
x -= 6.0;
}
assert_eq!(layout(v).plain_text(), "שלום עולם");
// A space inside a cluster, under a letter and the mark drawn on it, stays where x order puts it: before the mark.
let v = vec![
em_box("ש", 9.995, 0.0, 25.0, 11.0),
em_box(" ", 10.006, 0.0, 13.5, 11.0),
em_box("\u{5B4}", 10.003, 0.0, 12.8, 11.0),
em_box("ל", 25.0, 0.0, 31.0, 11.0),
];
assert_eq!(layout(v).plain_text(), "ל \u{5B4}ש");
// Left-to-right text has no reverse-order show strings: a space taken back there is still placed by position.
let 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(" ", 12.0, 0.0, 15.5, 11.0),
em_box("c", 11.9, 0.0, 17.9, 11.0),
];
assert_eq!(layout(v).plain_text().trim_end(), "abc");
// A space whose advance stands is placed by position, as before.
let mut v = vec![em_box("a", 0.0, 0.0, 6.0, 11.0), em_box(" ", 6.0, 0.0, 9.5, 11.0), em_box("b", 9.5, 0.0, 15.5, 11.0)];
v.push(em_box("c", 15.5, 0.0, 21.5, 11.0));
assert_eq!(layout(v).plain_text(), "a bc");
}
/// #2667: a number or punctuation on a right-to-left line must not split it into separately reversed words.
#[test]
fn rtl_line_with_number_and_punctuation_reads_in_logical_order() {
+2 -2
View File
@@ -1550,8 +1550,8 @@ milestone = "M3"
status = "shipped"
commands = ["edit.copy"]
tools = ["text_extract"]
evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership"]
notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract."
evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership", "crates/render/src/text/tests.rs::a_space_taken_back_under_the_next_letter_stays_before_it"]
notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract. A space that starts a right-to-left show string and is taken back under the next letter (ISO 32000-2 §14.8.2.5.3) stays a word break after its word instead of splitting it."
[[feature]]
id = "view.select-all"