@@ -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
0 commit comments