mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 22:32:19 +00:00
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:
1 parent
9dbede8709
commit
152d696fb0
3 files changed
+100
-3
No files matched your search
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user