M3: a taken-back space carries the marks on either side of it and reads numbers as right-to-left context (view.copy-text)

The letter after a blank and the marks drawn between them are gathered from
both sides of the blank in x order, so a tanwin pulled back over the space
with its alef starting at the space's own start still moves the space before
the word (PDFBOX-5487). Numbers beside a taken-back neutral count as
right-to-left, as in the bidi pass (UAX #9 N1), so '15)' followed by a full
stop keeps the stop after the number.

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 08:46:53 +08:00
1 parent b5068a2df7
commit 94203a2b78
2 files changed
+45 -16

No files matched your search

+22 -16
View File
@@ -343,8 +343,8 @@ const TAKEN_BACK_SCAN: usize = 8;
/// and the letter keep drawing order, which puts each mark after its letter once the line is reversed, as Unicode
/// 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 glyph with a strong direction before it, and
/// the nearest after the blank, is right-to-left or the line's edge. A parenthesis that opens Latin text then stays
/// 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
/// 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
@@ -353,12 +353,13 @@ fn keep_taken_back_blanks(glyphs: &[&TextGlyph], placed: &mut [usize]) {
let glyph = |placed: &[usize], p: usize| placed.get(p).and_then(|&i| glyphs.get(i)).copied();
let blank = |g: &TextGlyph| !g.text.is_empty() && g.text.chars().all(char::is_whitespace);
let mark = |g: &TextGlyph| !g.text.is_empty() && g.text.chars().all(|c| unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM);
// Some(true) for right-to-left text, Some(false) for left-to-right text (Latin letters, European digits), None for
// a glyph without a strong direction.
// The direction a glyph gives the text around it: Some(false) for left-to-right letters, Some(true) for
// right-to-left letters and for numbers, which read as right-to-left beside neutrals (UAX #9 N1, as in
// `reverse_rtl`), None for a glyph without one.
let strong = |g: &TextGlyph| {
if g.text.chars().any(|c| c.is_alphanumeric() && !is_rtl(c)) {
if g.text.chars().any(|c| c.is_alphabetic() && !is_rtl(c)) {
Some(false)
} else if g.text.chars().any(|c| c.is_alphanumeric()) {
} else if g.text.chars().any(char::is_alphanumeric) {
Some(true)
} else {
None
@@ -395,29 +396,34 @@ fn keep_taken_back_blanks(glyphs: &[&TextGlyph], placed: &mut [usize]) {
}
}
let Some(ink) = next.filter(|&i| glyphs.get(i).is_some_and(|g| !mark(g))) else { continue };
// The marks drawn between them, wherever x order put them next to the blank, go with them.
let between = |i: usize| i > space && i < ink;
let mut q = p.checked_sub(1);
for _ in 0..TAKEN_BACK_SCAN {
match q.and_then(|q| placed.get(q)) {
Some(&i) if between(i) => q = q.and_then(|q| q.checked_sub(1)),
// The glyphs x order put next to the blank that were drawn after it, up to and including the letter: the
// letter and the marks drawn between them, on either side of the blank.
let drawn_with = |i: usize| i > space && i <= ink;
let mut q = p;
for _ in 0..=TAKEN_BACK_SCAN {
match q.checked_sub(1).and_then(|k| placed.get(k)) {
Some(&i) if drawn_with(i) => q -= 1,
_ => break,
}
}
let Some(q) = q.filter(|&q| placed.get(q) == Some(&ink)) else { continue };
let mut r = p;
for _ in 0..TAKEN_BACK_SCAN {
match r.checked_add(1).and_then(|r| placed.get(r)) {
Some(&i) if between(i) => r += 1,
for _ in 0..=TAKEN_BACK_SCAN {
match r.checked_add(1).and_then(|k| placed.get(k)) {
Some(&i) if drawn_with(i) => r += 1,
_ => break,
}
}
if q == p || !placed.get(q..=r).is_some_and(|group| group.contains(&ink)) {
continue;
}
let Some(g) = glyphs.get(ink).copied() 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 = q.checked_sub(1).and_then(|b| glyph(placed, b)).is_none_or(|before| before.rect[2] <= s.rect[0]);
// A left-to-right letter or a European digit after the blank never moves; a neutral moves inside right-to-left text.
let direction = match strong(g) {
_ if g.text.chars().any(|c| c.is_alphanumeric() && !is_rtl(c)) => false,
Some(rtl) => rtl,
None => rtl_side(placed, q.checked_sub(1), |p| p.checked_sub(1)) && rtl_side(placed, r.checked_add(1), |p| p.checked_add(1)),
};
+23
View File
@@ -1267,6 +1267,18 @@ fn a_taken_back_neutral_beside_latin_text_keeps_its_place() {
v.push(em_box("A", x - 13.0, 0.0, x - 6.5, 11.0));
v.push(em_box("B", x - 6.5, 0.0, x, 11.0));
assert_eq!(layout(v).plain_text(), "שלום (AB");
// A number reads as right-to-left beside a neutral: a question mark taken back over the space after "15", drawn in
// reverse order, still ends the line with no space before it.
let mut v = vec![em_box(" ", 10.0, 0.0, 13.5, 11.0), em_box("?", 9.9, 0.0, 15.0, 11.0)];
v.push(em_box("1", 15.0, 0.0, 20.5, 11.0));
v.push(em_box("5", 20.5, 0.0, 26.0, 11.0));
v.push(em_box(" ", 26.0, 0.0, 29.5, 11.0));
let mut x = 29.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(), "סעיף 15?");
}
/// A mark drawn between a space and the letter taken back over it does not keep them apart: the space still ends the
@@ -1289,6 +1301,17 @@ fn a_mark_between_a_space_and_its_taken_back_letter_keeps_the_space_before_both(
};
assert_eq!(line(12.0).trim_end(), "מבקר המדינה\u{5B8}");
assert_eq!(line(9.95).trim_end(), "מבקר המדינה\u{5B8}");
// PDFBOX-5487's Arabic: a tanwin pulled back over the space, and the alef it sits on starting at the space's own
// start, so x order puts the alef after the space.
let mut v = vec![em_box(" ", 10.0, 0.0, 13.5, 11.0), em_box("\u{64B}", 9.5, 0.0, 16.3, 11.0), em_box("ا", 10.0, 0.0, 12.8, 11.0)];
let mut x = 12.8;
for c in ["ر", "ي", "ر", "ق", "ت"] {
v.push(em_box(c, x, 0.0, x + 6.5, 11.0));
x += 6.5;
}
v.push(em_box("ي", 3.0, 0.0, 6.0, 11.0));
v.push(em_box("ف", 6.0, 0.0, 9.5, 11.0));
assert_eq!(layout(v).plain_text().trim_end(), "تقريراً في");
}
/// Two taken-back spaces on one line each stay before their letter, and a second space drawn before a taken-back one