From 152d696fb0fee8510b65b6976a85e04096916750 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 00:07:25 +0800 Subject: [PATCH] M3: a space taken back under the next letter stays a word break, so right-to-left words are not split (view.copy-text) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/render/src/text/order.rs | 36 ++++++++++++++++++- crates/render/src/text/tests.rs | 63 +++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index efcabb5f1a..8d75d9e81b 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -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. /// diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index a1af42a8d0..093fe8ceb6 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -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() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7582b3f6bc..07e626a708 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"