Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 130 additions & 61 deletions src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6763,11 +6763,11 @@ impl PdfDocument {
// RTL ratio so that punctuation or stray markers adjacent to
// Arabic do not trigger a reversal.
//
// Pass 1 below handles the other common shape where each Arabic
// Pass 0.75 below handles the other common shape where each RTL
// character is emitted as its own short span and the reversal is
// a span-granularity concern. The two passes are independent:
// a span either fires Pass 0 (pre-shaped, reverse in place) or
// Pass 1 (per-glyph spans, reverse span order), never both.
// Pass 0.75 (per-glyph spans, merge + reverse), never both.
//
// This is separate from `normalize_arabic_presentation_forms`,
// which runs later on the assembled output string and unshapes
Expand Down Expand Up @@ -6809,6 +6809,134 @@ impl PdfDocument {
}
}

// Pass 0.75 (#826): merge per-glyph visual-order runs BEFORE the
// word-order pass. Some producers (scanned-Hebrew OCR text layers
// are the common case) emit one show op per glyph; the reading-order
// sort leaves those single-glyph spans in x-ascending — i.e. VISUAL —
// order. Merge each maximal run into one span whose characters are
// reversed to logical order (keeping digit runs forward, combining
// marks attached to their base, and 2-letter build-time pieces
// atomic). Running before Pass 0.5 is essential: the previous
// arrangement (span-order reversal in Pass 0.5 followed by a
// reverse-order merge in the old Pass 1) applied two reversals to
// the same run, cancelling out and leaking visual-order text —
// letter-spaced Hebrew running heads were the visible symptom.
if spans.len() >= 4 {
use crate::text::rtl_detector::is_rtl_diacritic;
// A span counts as a genuine single-glyph OCR piece only when it
// holds at most one BASE RTL letter (mirrors `extractors/text.rs`'s
// `is_rtl_glyph_piece`, which this run-membership test should have
// matched from the start) — not merely "≤2 characters total".
// A run of ordinary two-letter spans (a routine font kerning-pair
// or subsetting-run boundary in any digitally-authored Hebrew/
// Arabic PDF) also satisfies "≤2 chars", so the old gate merged
// and reversed already-correct text on non-OCR documents. A
// base+combining-mark pair (one glyph, two Unicode scalars) still
// passes here, since the mark doesn't count as a second base.
let is_single_base_rtl_or_space = |s: &TextSpan| -> bool {
let mut bases = 0usize;
for c in s.text.chars() {
if c.is_whitespace() {
continue;
}
let cp = c as u32;
if is_rtl_diacritic(cp) {
continue;
}
if c.is_alphabetic() && !is_rtl_text(cp) {
return false;
}
bases += 1;
if bases > 1 {
return false;
}
}
true
};
let mut i = 0;
while i < spans.len() {
let is_short_rtl = spans[i].text.chars().any(|c| is_rtl_text(c as u32))
&& is_single_base_rtl_or_space(&spans[i]);
if !is_short_rtl {
i += 1;
continue;
}
let run_start = i;
let y = spans[i].bbox.y;
let mut j = i + 1;
while j < spans.len() {
let y_same = (spans[j].bbox.y - y).abs() < 2.0;
if y_same && is_single_base_rtl_or_space(&spans[j]) {
j += 1;
} else {
break;
}
}
let run_end = j;
let run_len = run_end - run_start;

// Only process runs of 4+ spans (avoid false positives)
if run_len >= 4 {
use crate::text::rtl_detector::is_rtl_diacritic;
// Build atomic groups in forward (visual) order, then
// emit the groups in reverse to obtain logical order.
let mut groups: Vec<String> = Vec::new();
for span in spans[run_start..run_end].iter() {
let cs: Vec<char> = span.text.chars().collect();
let atomic_pair = cs.len() == 2
&& !cs[0].is_whitespace()
&& !cs[1].is_whitespace();
if atomic_pair {
// Ligature/base+mark piece decoded from a single
// show op — its chars are already logical.
groups.push(cs.iter().collect());
} else {
for &c in &cs {
if is_rtl_diacritic(c as u32) && !groups.is_empty() {
// Combining mark rides on the preceding base.
groups.last_mut().unwrap().push(c);
} else {
groups.push(c.to_string());
}
}
}
}
// Fold consecutive single-digit groups into one atomic
// group so numbers stay forward (UAX #9 rule L2).
let mut folded: Vec<String> = Vec::new();
for g in groups {
let is_digit_group =
g.chars().count() == 1 && g.chars().all(|c| c.is_ascii_digit());
if is_digit_group {
if let Some(last) = folded.last_mut() {
if last.chars().all(|c| c.is_ascii_digit()) {
last.push_str(&g);
continue;
}
}
}
folded.push(g);
}
let reversed_text: String =
folded.iter().rev().flat_map(|g| g.chars()).collect();

// Merge into first span, expand bbox to cover entire run
let last_span = &spans[run_end - 1];
let new_width =
(last_span.bbox.x + last_span.bbox.width) - spans[run_start].bbox.x;
spans[run_start].text = reversed_text;
spans[run_start].bbox.width = new_width;

// Remove the rest of the run
spans.drain(run_start + 1..run_end);

i = run_start + 1;
} else {
i = run_end;
}
}
}

// #557 Pass 0.5: per-word RTL span ORDER. The row-aware sort placed
// spans left-to-right (x ascending), but a right-to-left script reads
// the words in the opposite direction. For each maximal run of
Expand Down Expand Up @@ -6855,65 +6983,6 @@ impl PdfDocument {
i = end;
}

if spans.len() < 4 {
return;
}

// Iterate forward; drain consumed runs so subsequent indices stay valid
let mut i = 0;
while i < spans.len() {
// Check if this span starts an RTL single-char run
let is_short_rtl = spans[i].text.chars().count() <= 2
&& spans[i].text.chars().any(|c| is_rtl_text(c as u32));

if !is_short_rtl {
i += 1;
continue;
}

// Find the end of this RTL run (consecutive short spans on same line)
let run_start = i;
let y = spans[i].bbox.y;
let mut j = i + 1;
while j < spans.len() {
let y_same = (spans[j].bbox.y - y).abs() < 2.0;
let is_short = spans[j].text.chars().count() <= 2;
let has_rtl_or_space = spans[j]
.text
.chars()
.all(|c| is_rtl_text(c as u32) || c == ' ');
if y_same && is_short && has_rtl_or_space {
j += 1;
} else {
break;
}
}
let run_end = j;
let run_len = run_end - run_start;

// Only process runs of 4+ spans (avoid false positives)
if run_len >= 4 {
// Collect span texts in reverse order (visual LTR → logical RTL).
// Preserve space spans as word separators.
let mut reversed_text = String::new();
for span in spans[run_start..run_end].iter().rev() {
reversed_text.push_str(&span.text);
}

// Merge into first span, expand bbox to cover entire run
let last_span = &spans[run_end - 1];
let new_width = (last_span.bbox.x + last_span.bbox.width) - spans[run_start].bbox.x;
spans[run_start].text = reversed_text;
spans[run_start].bbox.width = new_width;

// Remove the rest of the run
spans.drain(run_start + 1..run_end);

i = run_start + 1;
} else {
i = run_end;
}
}
}

/// Normalize Arabic Presentation Forms to base Unicode characters.
Expand Down
133 changes: 132 additions & 1 deletion src/extractors/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4411,6 +4411,121 @@ impl<'doc> TextExtractor<'doc> {
}
}

/// Whether `s` is a per-glyph piece eligible for the visual-RTL run
/// reversal in [`Self::finalize_visual_rtl_glyph_span`]: whitespace-only,
/// or a single glyph (one non-mark char, optionally followed by RTL
/// combining marks) that is not a strong LTR letter. Producers that
/// emit one show op per glyph (common in OCR text layers) yield spans
/// of exactly this shape; multi-char show strings — whose storage
/// order was already resolved at build time by the flush-path
/// detectors — never qualify.
fn is_rtl_glyph_piece(s: &TextSpan) -> bool {
use crate::text::rtl_detector::{is_rtl_diacritic, is_rtl_text};
let mut bases = 0usize;
for c in s.text.chars() {
if c.is_whitespace() {
continue;
}
let cp = c as u32;
if is_rtl_diacritic(cp) {
// Combining mark riding on the preceding base glyph.
continue;
}
// A strong LTR letter (Latin, Greek, Cyrillic, CJK, …) makes
// the run ineligible — embedded LTR words must keep their
// build-time order.
if c.is_alphabetic() && !is_rtl_text(cp) {
return false;
}
bases += 1;
if bases > 1 {
return false;
}
}
true
}

/// Reverse a visual-order RTL chunk to logical order, keeping each
/// digit run forward (UAX #9 rule L2) and each combining mark attached
/// to its base character.
fn reverse_visual_rtl_text(text: &str) -> String {
use crate::text::rtl_detector::is_rtl_diacritic;
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let is_digit = |c: char| c.is_ascii_digit();
let is_sep = |c: char| matches!(c, '.' | ',' | ':');
let mut groups: Vec<&[char]> = Vec::new();
let mut i = 0;
while i < n {
let start = i;
if is_digit(chars[i]) {
// Maximal number run: digit (sep digit)* — kept forward.
let mut j = i + 1;
loop {
if j < n && is_digit(chars[j]) {
j += 1;
} else if j + 1 < n && is_sep(chars[j]) && is_digit(chars[j + 1]) {
j += 2;
} else {
break;
}
}
i = j;
} else {
// Base char plus any trailing combining marks.
i += 1;
while i < n && is_rtl_diacritic(chars[i] as u32) {
i += 1;
}
}
groups.push(&chars[start..i]);
}
groups.iter().rev().flat_map(|g| g.iter()).collect()
}

/// Visual→logical reversal for a span merged from per-glyph pieces.
///
/// `merge_adjacent_spans` walks spans in x-ascending order, so a span
/// assembled from single-glyph pieces holds the *visual* left-to-right
/// character sequence regardless of how the producer stored the text —
/// per-glyph show ops carry no storage-order information of their own.
/// For a pure-RTL chunk the logical reading order is the reverse of
/// that visual sequence. This closes the per-glyph gap that neither
/// the flush-path detectors (they only see one glyph at a time) nor
/// `reverse_rtl_visual_order_runs` Pass 1 (the merge has already
/// collapsed the short-span runs it looks for) can reach — the shape
/// of scanned Hebrew OCR text layers.
///
/// Gated to spans merged from ≥2 qualifying pieces with ≥2 RTL chars
/// and no strong LTR letters, so build-time-corrected multi-char show
/// strings and Latin/CJK output stay byte-identical.
fn finalize_visual_rtl_glyph_span(span: &mut TextSpan, all_glyph_pieces: bool, pieces: usize) {
use crate::text::rtl_detector::is_rtl_text;
if !all_glyph_pieces || pieces < 2 {
return;
}
let mut rtl = 0usize;
for c in span.text.chars() {
if c.is_alphabetic() && !is_rtl_text(c as u32) {
return;
}
if is_rtl_text(c as u32) {
rtl += 1;
}
}
if rtl < 2 {
return;
}
let reversed = Self::reverse_visual_rtl_text(&span.text);
if reversed != span.text {
// Keep char_widths positionally aligned with the reversed text.
if span.char_widths.len() == span.text.chars().count() {
span.char_widths.reverse();
}
span.text = reversed;
}
}

/// This matches the behavior of industry-standard PDF tools.
fn merge_adjacent_spans(&mut self) {
if self.spans.is_empty() {
Expand All @@ -4422,19 +4537,29 @@ impl<'doc> TextExtractor<'doc> {
let spans = std::mem::take(&mut self.spans);
let mut merged = Vec::with_capacity(old_len);
let mut current_span: Option<TextSpan> = None;
// Visual-RTL glyph-run tracking for `finalize_visual_rtl_glyph_span`:
// whether every piece merged into `current_span` so far was a
// single-glyph piece, and how many pieces it was built from.
let mut cur_all_glyph = false;
let mut cur_pieces = 0usize;

for span in spans {
if current_span.is_none() {
// First span — move, no clone needed
cur_all_glyph = Self::is_rtl_glyph_piece(&span);
cur_pieces = 1;
current_span = Some(span);
continue;
}
let span_is_glyph_piece = Self::is_rtl_glyph_piece(&span);

// Take ownership of current to avoid borrow checker issues.
// Safety: checked is_none() above which continues, so this is always Some.
let mut current = match current_span.take() {
Some(s) => s,
None => {
cur_all_glyph = span_is_glyph_piece;
cur_pieces = 1;
current_span = Some(span);
continue;
},
Expand Down Expand Up @@ -4787,6 +4912,8 @@ impl<'doc> TextExtractor<'doc> {
}

if decimal_merge || should_merge || cross_font_word_glue {
cur_all_glyph &= span_is_glyph_piece;
cur_pieces += 1;
// A merged span is logical-draw RTL if any of its glyph runs was
// drawn right-to-left (see `detect_rtl_draw_direction`).
current.rtl_draw_logical |= span.rtl_draw_logical;
Expand Down Expand Up @@ -4896,13 +5023,17 @@ impl<'doc> TextExtractor<'doc> {
);
}
}
Self::finalize_visual_rtl_glyph_span(&mut current, cur_all_glyph, cur_pieces);
merged.push(current);
cur_all_glyph = span_is_glyph_piece;
cur_pieces = 1;
current_span = Some(span);
}
}

// Don't forget the last span
if let Some(last) = current_span {
if let Some(mut last) = current_span {
Self::finalize_visual_rtl_glyph_span(&mut last, cur_all_glyph, cur_pieces);
merged.push(last);
}

Expand Down
Loading