Skip to content

Commit 0e94fe4

Browse files
authored
Merge pull request #832 from yfedoseev/fix/826-hebrew-ocr-rtl-visual-order
Fix Hebrew/Arabic OCR-sandwich text reversed in extract_text (#826)
2 parents 0d1a82f + 8bd8d4c commit 0e94fe4

4 files changed

Lines changed: 792 additions & 70 deletions

File tree

src/extractors/text.rs

Lines changed: 74 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1851,6 +1851,14 @@ struct TjBuffer {
18511851
/// ratio so it is text/CTM-scale-independent and directly comparable to a
18521852
/// font-size fraction by the sub/superscript rejoin.
18531853
text_rise: f32,
1854+
/// Text render mode (`Tr`, ISO 32000-1 §9.3.6), captured from the
1855+
/// graphics state when the buffer started. `3`/`7` (invisible — neither
1856+
/// filled nor stroked) means this run has no rendering-correctness
1857+
/// pressure: an OCR-sandwich producer has no visual reason to mirror
1858+
/// already-logical RTL glyph positions the way a *visible*-text
1859+
/// producer would, so the geometric visual/logical detector's ascending-
1860+
/// x signal is uninformative here (#826) — see `bidi::apply_rtl_verdict`.
1861+
render_mode: u8,
18541862
}
18551863

18561864
/// Snap a run's display rotation (from the composed `CTM × T_m` rotation block,
@@ -1946,6 +1954,7 @@ impl TjBuffer {
19461954
} else {
19471955
0.0
19481956
},
1957+
render_mode: state.render_mode,
19491958
}
19501959
}
19511960

@@ -7001,22 +7010,43 @@ impl<'doc> TextExtractor<'doc> {
70017010
.take()
70027011
.unwrap_or_else(|| "Unknown".to_string());
70037012

7004-
// RTL text correction: if text contains RTL characters and spans left-to-right
7005-
// on the page, the characters are in visual LTR order. Reverse to logical order.
7013+
// RTL text correction (#826): use the confidence-gated geometric
7014+
// detector (#537) when `char_widths` gives us per-character user-space
7015+
// x-positions, falling back to the coarse "buffer's net horizontal
7016+
// advance is positive" heuristic only for genuinely ambiguous/short
7017+
// runs. Mirrors `flush_tj_span_buffer`'s handling — this used to be
7018+
// the one flush site still on the pre-#537 `accumulated_width > 0.0`
7019+
// check, which (since `accumulated_width` only ever sums *positive*
7020+
// glyph widths — TJ kerning offsets never subtract from it) is true
7021+
// for nearly every non-empty RTL buffer and so was unconditionally
7022+
// reversing every RTL run regardless of its actual source order.
70067023
let mut text = std::mem::take(&mut buffer.unicode);
70077024
if text.len() > 1 {
70087025
let has_rtl = text
70097026
.chars()
70107027
.any(|c| crate::text::rtl_detector::is_rtl_text(c as u32));
70117028
if has_rtl {
7012-
// In the tiebreaker path, characters are appended left-to-right in content
7013-
// stream order. For RTL scripts displayed right-to-left, this means the
7014-
// leftmost visual character (last logical character) is first in the buffer.
7015-
// Reverse to get logical reading order.
7016-
// Only reverse if user_pos_x indicates LTR placement (positive width).
7017-
if buffer.accumulated_width > 0.0 {
7018-
text = crate::text::bidi::reverse_rtl_keep_numbers(&text);
7019-
}
7029+
let chars: Vec<char> = text.chars().collect();
7030+
let verdict = if chars.len() == buffer.char_widths.len()
7031+
&& !buffer.char_widths.is_empty()
7032+
{
7033+
let mut chars_with_x: Vec<(char, f32)> = Vec::with_capacity(chars.len());
7034+
let mut cursor_text_space = 0.0_f32;
7035+
for (i, c) in chars.iter().enumerate() {
7036+
let user_x = buffer.user_pos_x + cursor_text_space * buffer.user_h_scale;
7037+
chars_with_x.push((*c, user_x));
7038+
cursor_text_space += buffer.char_widths[i];
7039+
}
7040+
crate::text::bidi::detect_visual_order_run(&chars_with_x)
7041+
} else {
7042+
crate::text::bidi::RunOrder::Ambiguous
7043+
};
7044+
text = crate::text::bidi::apply_rtl_verdict(
7045+
&text,
7046+
verdict,
7047+
buffer.accumulated_width > 0.0,
7048+
matches!(buffer.render_mode, 3 | 7),
7049+
);
70207050
}
70217051
}
70227052

@@ -7497,34 +7527,25 @@ impl<'doc> TextExtractor<'doc> {
74977527
}
74987528
}
74997529
let verdict = crate::text::bidi::detect_visual_order_run(&chars_with_x);
7500-
match verdict {
7501-
crate::text::bidi::RunOrder::Visual => {
7502-
// Confidence-gated visual-order detection — reverse.
7503-
unicode_text = crate::text::bidi::reverse_rtl_keep_numbers(&unicode_text);
7504-
},
7505-
crate::text::bidi::RunOrder::Logical => {
7506-
// Confidence-gated logical-order — leave alone.
7507-
// The pdfium `hebrew_mirrored.pdf` test fixture
7508-
// and similar lands here.
7509-
},
7510-
crate::text::bidi::RunOrder::Ambiguous => {
7511-
// Short cluster or mixed signal — fall back to
7512-
// the pre-v0.3.54 simple heuristic so existing
7513-
// 2-3-char RTL runs keep working.
7514-
let first_x = {
7515-
let p = text_matrix.transform_point(cluster[0].x_position, 0.0);
7516-
ctm.transform_point(p.x, p.y).x
7517-
};
7518-
let last_x = {
7519-
let p = text_matrix.transform_point(last.x_position, 0.0);
7520-
ctm.transform_point(p.x, p.y).x
7521-
};
7522-
if last_x > first_x {
7523-
unicode_text =
7524-
crate::text::bidi::reverse_rtl_keep_numbers(&unicode_text);
7525-
}
7526-
},
7527-
}
7530+
// Pre-v0.3.54 simple heuristic — used only as the
7531+
// `Ambiguous` fallback (short cluster or mixed signal) so
7532+
// existing 2-3-char RTL runs keep working; the pdfium
7533+
// `hebrew_mirrored.pdf` fixture and similar land on
7534+
// `Logical` above and are left alone regardless.
7535+
let first_x = {
7536+
let p = text_matrix.transform_point(cluster[0].x_position, 0.0);
7537+
ctm.transform_point(p.x, p.y).x
7538+
};
7539+
let last_x = {
7540+
let p = text_matrix.transform_point(last.x_position, 0.0);
7541+
ctm.transform_point(p.x, p.y).x
7542+
};
7543+
unicode_text = crate::text::bidi::apply_rtl_verdict(
7544+
&unicode_text,
7545+
verdict,
7546+
last_x > first_x,
7547+
matches!(state.render_mode, 3 | 7),
7548+
);
75287549
}
75297550
}
75307551

@@ -8370,27 +8391,21 @@ impl<'doc> TextExtractor<'doc> {
83708391
.take()
83718392
.unwrap_or_else(|| "Unknown".to_string());
83728393

8373-
// #537: RTL visual-order detection for the Tj-span
8374-
// path. This was the gap on the Magic Palace Eilat Hebrew
8375-
// PDF — the Tj-span buffer flush had no RTL correction at
8376-
// all, so Hebrew came out in content-stream (visual)
8377-
// order regardless of what the geometric signals said.
8378-
// Mirrors the existing logic in `flush_tj_buffer`
8379-
// `cluster_to_span`: detect RTL content, use the geometric
8380-
// detector when `char_widths` give us per-char x; fall back
8381-
// to the `accumulated_width > 0` simple check (text drawn
8382-
// left-to-right in user space → visual order → reverse).
8394+
// #537/#826: RTL visual-order detection for the Tj-span
8395+
// path, via the shared `apply_rtl_verdict` decision point
8396+
// (also used by `flush_tj_buffer` and `cluster_to_span`) —
8397+
// geometric detector when `char_widths` give us per-char x,
8398+
// falling back to the coarse `accumulated_width > 0`
8399+
// heuristic only when ambiguous.
83838400
let mut text = std::mem::take(&mut buffer.unicode);
83848401
if text.len() > 1 {
83858402
let has_rtl = text
83868403
.chars()
83878404
.any(|c| crate::text::rtl_detector::is_rtl_text(c as u32));
83888405
if has_rtl {
8389-
// Try the geometric detector first when char_widths
8390-
// give us per-character X positions. char_widths
8391-
// contains text-space relative widths; reconstruct
8392-
// absolute user-space x by accumulating, scaling by
8393-
// user_h_scale and offsetting by user_pos_x.
8406+
// char_widths contains text-space relative widths;
8407+
// reconstruct absolute user-space x by accumulating,
8408+
// scaling by user_h_scale and offsetting by user_pos_x.
83948409
let chars: Vec<char> = text.chars().collect();
83958410
let verdict = if chars.len() == buffer.char_widths.len()
83968411
&& !buffer.char_widths.is_empty()
@@ -8408,23 +8423,12 @@ impl<'doc> TextExtractor<'doc> {
84088423
} else {
84098424
crate::text::bidi::RunOrder::Ambiguous
84108425
};
8411-
match verdict {
8412-
crate::text::bidi::RunOrder::Visual => {
8413-
text = crate::text::bidi::reverse_rtl_keep_numbers(&text);
8414-
},
8415-
crate::text::bidi::RunOrder::Logical => {
8416-
// Detected logical order — leave alone.
8417-
},
8418-
crate::text::bidi::RunOrder::Ambiguous => {
8419-
// Fall back to the simple `accumulated_width
8420-
// > 0` heuristic used elsewhere — text drawn
8421-
// left-to-right in text space implies visual
8422-
// order for RTL scripts.
8423-
if buffer.accumulated_width > 0.0 {
8424-
text = crate::text::bidi::reverse_rtl_keep_numbers(&text);
8425-
}
8426-
},
8427-
}
8426+
text = crate::text::bidi::apply_rtl_verdict(
8427+
&text,
8428+
verdict,
8429+
buffer.accumulated_width > 0.0,
8430+
matches!(buffer.render_mode, 3 | 7),
8431+
);
84288432
}
84298433
}
84308434

src/text/bidi.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,89 @@ pub(crate) fn detect_visual_order_run(chars_with_x: &[(char, f32)]) -> RunOrder
362362
RunOrder::Ambiguous
363363
}
364364

365+
/// Single decision point for "given this [`RunOrder`] verdict (from
366+
/// [`detect_visual_order_run`]), should this flushed RTL run be reversed
367+
/// to logical order?" — shared by every `Tj`/`TJ` buffer-flush site
368+
/// (`flush_tj_buffer`, `flush_tj_span_buffer`, `cluster_to_span` in
369+
/// `extractors/text.rs`).
370+
///
371+
/// Before this existed, each flush site independently re-implemented the
372+
/// identical three-way `match` on the verdict — and one site
373+
/// (`flush_tj_buffer`, the default `WordBoundaryMode::Tiebreaker` path)
374+
/// never called the geometric detector at all, only its coarse fallback.
375+
/// That fallback (`accumulated_width > 0.0`, a sum of *only positive*
376+
/// glyph advance widths — TJ kerning offsets never subtract from it) is
377+
/// true for nearly every non-empty RTL buffer, so that site was
378+
/// unconditionally reversing every RTL run it flushed rather than
379+
/// detecting direction at all (issue #826: an already-logical-order OCR
380+
/// word got wrongly flipped, and the flipped-but-still-pure-Hebrew word
381+
/// then had its span order *also* reversed by
382+
/// `document::PdfDocument::reverse_rtl_visual_order_runs`, compounding
383+
/// into a full mirror image of the correct line).
384+
///
385+
/// Callers remain responsible for deciding whether they have usable
386+
/// per-character geometry to hand `detect_visual_order_run` (that gating
387+
/// differs slightly per site — e.g. `cluster_to_span` tolerates a
388+
/// `chars_with_x` shorter than the decoded text on ligature expansion)
389+
/// and for computing `coarse_visual_order_heuristic`, the best coarse
390+
/// direction signal available (e.g. "buffer's net horizontal advance is
391+
/// positive" / "last glyph's x > first glyph's x") for the genuinely
392+
/// [`RunOrder::Ambiguous`] case (short runs, sparse geometry).
393+
///
394+
/// `is_invisible_render_mode` — was this run drawn with text render mode
395+
/// `3`/`7` (ISO 32000-1 §9.3.6, "neither fill nor stroke" — the mode
396+
/// OCR-sandwich text layers use so the recognized text is searchable but
397+
/// the original scan stays the only visible thing)? Both `verdict` and
398+
/// `coarse_visual_order_heuristic` are built on the same premise —
399+
/// glyphs placed at *ascending* x are visual order, *descending* x are
400+
/// logical order — which only holds when the producer has
401+
/// rendering-correctness pressure to mirror already-logical RTL content
402+
/// so it *looks* right on the page. Invisible text has no such pressure:
403+
/// a competent OCR engine (the modern default — Tesseract's LSTM model,
404+
/// cloud OCR APIs) outputs correct per-word logical Unicode but has no
405+
/// reason to also mirror the invisible glyph *positions*, so it places
406+
/// them at plain ascending x — geometrically identical to genuinely
407+
/// visual-order content. Neither `verdict` nor the coarse fallback can
408+
/// tell the two apart in that case (issue #826: this was exactly why
409+
/// wiring the geometric detector into every flush site alone didn't fix
410+
/// the bug — an already-correct OCR word was still being flipped). When
411+
/// `true`, skip both signals entirely and trust the extracted content
412+
/// order as-is — the modern-OCR prior plus this crate's own stated
413+
/// design principle (`detect_visual_order_run`'s doc: "the cost of an
414+
/// unwarranted reversal is higher than the cost of a missed reversal")
415+
/// both favor not reversing when the signal is this unreliable.
416+
/// Cross-word reading order for invisible OCR lines is still corrected
417+
/// separately, by the span-position-based reading-order sort and
418+
/// `document::PdfDocument::reverse_rtl_visual_order_runs` Pass 0.5, which
419+
/// this function has no bearing on.
420+
///
421+
/// Known accepted trade-off: an invisible OCR layer from a genuinely
422+
/// non-language-aware/legacy engine that emits *visual*-order Unicode
423+
/// will no longer get auto-reversed — today it coincidentally does, for
424+
/// the same ascending-x-is-uninformative reason #826 mis-fires. This is
425+
/// a deliberate choice favoring the now-dominant case.
426+
pub(crate) fn apply_rtl_verdict(
427+
text: &str,
428+
verdict: RunOrder,
429+
coarse_visual_order_heuristic: bool,
430+
is_invisible_render_mode: bool,
431+
) -> String {
432+
if is_invisible_render_mode {
433+
return text.to_string();
434+
}
435+
match verdict {
436+
RunOrder::Visual => reverse_rtl_keep_numbers(text),
437+
RunOrder::Logical => text.to_string(),
438+
RunOrder::Ambiguous => {
439+
if coarse_visual_order_heuristic {
440+
reverse_rtl_keep_numbers(text)
441+
} else {
442+
text.to_string()
443+
}
444+
},
445+
}
446+
}
447+
365448
/// Unicode bidi-isolation markers (UAX #9 §2.4).
366449
///
367450
/// These four code points isolate a directional run from the

0 commit comments

Comments
 (0)