From e67062080ccf20a20c1ad165820b2a2960cab636 Mon Sep 17 00:00:00 2001 From: Wang Xin Date: Sun, 12 Jul 2026 09:34:29 +0800 Subject: [PATCH 1/5] Add RTL text direction support for 0.8.1 --- Cargo.toml | 6 +- examples/ocr.rs | 1 + examples/structure.rs | 1 + examples/text_recognition.rs | 24 +++ oar-ocr-core/Cargo.toml | 1 + .../adapters/text_recognition_adapter.rs | 9 +- oar-ocr-core/src/domain/tasks/mod.rs | 5 +- .../src/domain/tasks/text_recognition.rs | 187 ++++++++++++++++++ .../src/predictors/text_recognition.rs | 10 +- src/oarocr/ocr.rs | 1 + 10 files changed, 238 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 52295c5..ec0fd2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [".", "oar-ocr-derive", "oar-ocr-core", "oar-ocr-vl"] resolver = "2" [workspace.package] -version = "0.8.0" +version = "0.8.1" edition = "2024" rust-version = "1.95" license = "Apache-2.0" @@ -12,8 +12,8 @@ repository = "https://github.com/greatv/oar-ocr" homepage = "https://github.com/greatv/oar-ocr" [workspace.dependencies] -oar-ocr-core = { version = "0.8.0", path = "oar-ocr-core", default-features = false } -oar-ocr-derive = { version = "0.8.0", path = "oar-ocr-derive", default-features = false } +oar-ocr-core = { version = "0.8.1", path = "oar-ocr-core", default-features = false } +oar-ocr-derive = { version = "0.8.1", path = "oar-ocr-derive", default-features = false } # Shared third-party dependencies (kept in sync via workspace inheritance). clap = { version = "4.5.42", features = ["derive"] } diff --git a/examples/ocr.rs b/examples/ocr.rs index 0639f53..d7514b7 100644 --- a/examples/ocr.rs +++ b/examples/ocr.rs @@ -216,6 +216,7 @@ fn main() -> Result<(), Box> { let rec_config = TextRecognitionConfig { score_threshold: args.rec_score_thresh, + ..Default::default() }; // Construct OCR pipeline diff --git a/examples/structure.rs b/examples/structure.rs index b8e1a71..922965a 100644 --- a/examples/structure.rs +++ b/examples/structure.rs @@ -575,6 +575,7 @@ fn main() -> Result<(), Box> { let text_rec_config = TextRecognitionConfig { score_threshold: args.rec_score_thresh, + ..Default::default() }; // Build structure pipeline diff --git a/examples/text_recognition.rs b/examples/text_recognition.rs index a379289..7be0159 100644 --- a/examples/text_recognition.rs +++ b/examples/text_recognition.rs @@ -32,6 +32,7 @@ mod utils; use clap::Parser; +use oar_ocr::domain::tasks::TextDirection; use oar_ocr::predictors::TextRecognitionPredictor; use oar_ocr::utils::load_image; use std::path::PathBuf; @@ -73,6 +74,10 @@ struct Args { #[arg(long, default_value = "0.0")] score_thresh: f32, + /// Text direction post-processing: ltr, rtl, or auto + #[arg(long, default_value = "ltr")] + text_direction: TextDirectionArg, + /// Maximum image width for resizing (optional, e.g., 320) #[arg(long)] max_img_w: Option, @@ -90,6 +95,23 @@ struct Args { verbose: bool, } +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +enum TextDirectionArg { + Ltr, + Rtl, + Auto, +} + +impl From for TextDirection { + fn from(value: TextDirectionArg) -> Self { + match value { + TextDirectionArg::Ltr => TextDirection::Ltr, + TextDirectionArg::Rtl => TextDirection::Rtl, + TextDirectionArg::Auto => TextDirection::Auto, + } + } +} + fn main() -> Result<(), Box> { // Initialize tracing for logging utils::init_tracing(); @@ -142,6 +164,7 @@ fn main() -> Result<(), Box> { if args.verbose { info!("Recognition Configuration:"); info!(" Score threshold: {}", args.score_thresh); + info!(" Text direction: {:?}", args.text_direction); info!( " Input shape: [3, {}, {}]", args.input_height, args.input_width @@ -156,6 +179,7 @@ fn main() -> Result<(), Box> { let predictor = TextRecognitionPredictor::builder() .score_threshold(args.score_thresh) + .text_direction(args.text_direction.into()) .dict_path(&args.dict_path) .with_ort_config(ort_config) .build(&args.model_path)?; diff --git a/oar-ocr-core/Cargo.toml b/oar-ocr-core/Cargo.toml index affd958..e29c3c5 100644 --- a/oar-ocr-core/Cargo.toml +++ b/oar-ocr-core/Cargo.toml @@ -56,6 +56,7 @@ thiserror = "2.0" tokenizers.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +unicode-bidi = "0.3.18" ureq = { version = "3.0", default-features = false, features = ["rustls", "platform-verifier"], optional = true } wide = { version = "1.5", optional = true } diff --git a/oar-ocr-core/src/domain/adapters/text_recognition_adapter.rs b/oar-ocr-core/src/domain/adapters/text_recognition_adapter.rs index b78afbc..40758d7 100644 --- a/oar-ocr-core/src/domain/adapters/text_recognition_adapter.rs +++ b/oar-ocr-core/src/domain/adapters/text_recognition_adapter.rs @@ -8,7 +8,9 @@ use crate::core::traits::{ adapter::{AdapterInfo, ModelAdapter}, task::Task, }; -use crate::domain::tasks::{TextRecognitionConfig, TextRecognitionOutput, TextRecognitionTask}; +use crate::domain::tasks::{ + TextRecognitionConfig, TextRecognitionOutput, TextRecognitionTask, postprocess_text_direction, +}; use crate::impl_adapter_builder; use crate::models::recognition::crnn::{CRNNModel, CRNNModelBuilder, CRNNPreprocessConfig}; @@ -83,7 +85,10 @@ impl ModelAdapter for TextRecognitionAdapter { ) { if score >= effective_config.score_threshold { - result_texts.push(text); + result_texts.push(postprocess_text_direction( + text, + effective_config.text_direction, + )); result_scores.push(score); result_positions.push(positions); result_col_indices.push(col_indices); diff --git a/oar-ocr-core/src/domain/tasks/mod.rs b/oar-ocr-core/src/domain/tasks/mod.rs index 2c6a34d..3b01bf5 100644 --- a/oar-ocr-core/src/domain/tasks/mod.rs +++ b/oar-ocr-core/src/domain/tasks/mod.rs @@ -44,4 +44,7 @@ pub use text_detection::{Detection, TextDetectionConfig, TextDetectionOutput, Te pub use text_line_orientation::{ TextLineOrientationConfig, TextLineOrientationOutput, TextLineOrientationTask, }; -pub use text_recognition::{TextRecognitionConfig, TextRecognitionOutput, TextRecognitionTask}; +pub use text_recognition::{ + TextDirection, TextRecognitionConfig, TextRecognitionOutput, TextRecognitionTask, + postprocess_text_direction, +}; diff --git a/oar-ocr-core/src/domain/tasks/text_recognition.rs b/oar-ocr-core/src/domain/tasks/text_recognition.rs index 1308cc6..d13aaa7 100644 --- a/oar-ocr-core/src/domain/tasks/text_recognition.rs +++ b/oar-ocr-core/src/domain/tasks/text_recognition.rs @@ -9,6 +9,20 @@ use crate::core::traits::TaskDefinition; use crate::core::traits::task::{ImageTaskInput, Task, TaskSchema, TaskType}; use crate::utils::ScoreValidator; use serde::{Deserialize, Serialize}; +use unicode_bidi::{BidiClass, bidi_class}; + +/// Reading direction for recognized text post-processing. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TextDirection { + /// Keep decoded text in the model's left-to-right sequence order. + #[default] + Ltr, + /// Convert visual right-to-left OCR output into logical string order. + Rtl, + /// Convert only when decoded text contains right-to-left characters. + Auto, +} /// Configuration for text recognition task. /// @@ -18,14 +32,123 @@ pub struct TextRecognitionConfig { /// Score threshold for recognition (default: 0.0, no filtering) #[validate(range(min = 0.0, max = 1.0))] pub score_threshold: f32, + /// Reading direction used for text post-processing. + #[serde(default)] + pub text_direction: TextDirection, } impl Default for TextRecognitionConfig { fn default() -> Self { Self { score_threshold: 0.0, + text_direction: TextDirection::Ltr, + } + } +} + +fn is_rtl_char(c: char) -> bool { + matches!( + bidi_class(c), + BidiClass::AL | BidiClass::R | BidiClass::RLE | BidiClass::RLI | BidiClass::RLO + ) +} + +fn is_combining_mark(c: char) -> bool { + bidi_class(c) == BidiClass::NSM +} + +fn is_ltr_token_char(c: char) -> bool { + matches!( + bidi_class(c), + BidiClass::L + | BidiClass::EN + | BidiClass::AN + | BidiClass::ES + | BidiClass::ET + | BidiClass::CS + ) || "._:/%+-#@&".contains(c) +} + +fn preserve_ltr_phrase_order(chars: &mut [char]) { + let mut i = 0; + while i < chars.len() { + if !is_ltr_token_char(chars[i]) { + i += 1; + continue; + } + + let start = i; + i += 1; + while i < chars.len() && (is_ltr_token_char(chars[i]) || chars[i].is_whitespace()) { + i += 1; + } + let mut end = i; + while end > start && chars[end - 1].is_whitespace() { + end -= 1; + } + chars[start..end].reverse(); + } +} + +fn normalize_leading_combining_marks(chars: Vec) -> Vec { + let mut normalized = Vec::with_capacity(chars.len()); + let mut pending_marks = Vec::new(); + + for c in chars { + if is_combining_mark(c) { + pending_marks.push(c); + continue; + } + + normalized.push(c); + if is_rtl_char(c) && !pending_marks.is_empty() { + normalized.append(&mut pending_marks); + } else if !pending_marks.is_empty() { + let insertion = normalized.len().saturating_sub(1); + normalized.splice(insertion..insertion, pending_marks.drain(..)); } } + + normalized.extend(pending_marks); + normalized +} + +fn visual_rtl_line_to_logical(line: &str) -> String { + if !line.chars().any(is_rtl_char) { + return line.to_string(); + } + + // PaddleOCR/PaddleX Arabic CTC recognizers can expose visual RTL order. + // Convert to logical order while using Unicode bidi classes for RTL, + // non-spacing marks, and embedded LTR/number phrases. + let mut chars: Vec = line.chars().rev().collect(); + preserve_ltr_phrase_order(&mut chars); + normalize_leading_combining_marks(chars) + .into_iter() + .collect() +} + +fn visual_rtl_to_logical(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for segment in text.split_inclusive('\n') { + if let Some(line) = segment.strip_suffix('\n') { + out.push_str(&visual_rtl_line_to_logical(line)); + out.push('\n'); + } else { + out.push_str(&visual_rtl_line_to_logical(segment)); + } + } + out +} + +/// Applies configured reading-direction post-processing to decoded OCR text. +pub fn postprocess_text_direction(text: String, direction: TextDirection) -> String { + match direction { + TextDirection::Ltr => text, + TextDirection::Rtl => visual_rtl_to_logical(&text), + TextDirection::Auto if text.chars().any(is_rtl_char) => visual_rtl_to_logical(&text), + TextDirection::Auto => text, + } } /// Output from text recognition task. @@ -185,4 +308,68 @@ mod tests { assert!(schema.input_types.contains(&"text_boxes".to_string())); assert!(schema.output_types.contains(&"text_strings".to_string())); } + + #[test] + fn rtl_postprocess_reverses_visual_arabic_order() { + assert_eq!( + postprocess_text_direction( + "دحاو نآ يف لوؤسمو يعاديإ لمع مجرتملا لمع".to_string(), + TextDirection::Rtl, + ), + "عمل المترجم عمل إيداعي ومسؤول في آن واحد" + ); + } + + #[test] + fn rtl_postprocess_preserves_ltr_token_order() { + assert_eq!( + postprocess_text_direction("abc 123 ابحرم".to_string(), TextDirection::Rtl), + "مرحبا abc 123" + ); + } + + #[test] + fn ltr_postprocess_leaves_text_unchanged() { + let text = "دحاو نآ يف".to_string(); + assert_eq!( + postprocess_text_direction(text.clone(), TextDirection::Ltr), + text + ); + } + + #[test] + fn auto_postprocess_only_reverses_rtl_text() { + assert_eq!( + postprocess_text_direction("دحاو نآ يف".to_string(), TextDirection::Auto), + "في آن واحد" + ); + assert_eq!( + postprocess_text_direction("hello 123".to_string(), TextDirection::Auto), + "hello 123" + ); + } + + #[test] + fn rtl_postprocess_keeps_combining_marks_on_rtl_base() { + assert_eq!( + postprocess_text_direction("لكشي ّراطإ".to_string(), TextDirection::Rtl), + "إطارّ يشكل" + ); + } + + #[test] + fn rtl_postprocess_preserves_paired_punctuation() { + assert_eq!( + postprocess_text_direction(")OCR 2026( ابحرم".to_string(), TextDirection::Rtl), + "مرحبا (OCR 2026)" + ); + } + + #[test] + fn rtl_postprocess_preserves_arabic_indic_number_order() { + assert_eq!( + postprocess_text_direction("١٢٣ ابحرم".to_string(), TextDirection::Rtl), + "مرحبا ١٢٣" + ); + } } diff --git a/oar-ocr-core/src/predictors/text_recognition.rs b/oar-ocr-core/src/predictors/text_recognition.rs index 3d56af7..85111bf 100644 --- a/oar-ocr-core/src/predictors/text_recognition.rs +++ b/oar-ocr-core/src/predictors/text_recognition.rs @@ -9,7 +9,9 @@ use crate::core::errors::OCRError; use crate::core::traits::OrtConfigurable; use crate::core::traits::task::ImageTaskInput; use crate::domain::adapters::TextRecognitionAdapterBuilder; -use crate::domain::tasks::text_recognition::{TextRecognitionConfig, TextRecognitionTask}; +use crate::domain::tasks::text_recognition::{ + TextDirection, TextRecognitionConfig, TextRecognitionTask, +}; use crate::predictors::TaskPredictorCore; use image::RgbImage; use std::path::{Path, PathBuf}; @@ -58,6 +60,7 @@ impl TextRecognitionPredictorBuilder { Self { state: PredictorBuilderState::new(TextRecognitionConfig { score_threshold: 0.0, + text_direction: TextDirection::Ltr, }), dict_path: None, } @@ -68,6 +71,11 @@ impl TextRecognitionPredictorBuilder { self } + pub fn text_direction(mut self, direction: TextDirection) -> Self { + self.state.config_mut().text_direction = direction; + self + } + pub fn dict_path>(mut self, path: P) -> Self { self.dict_path = Some(path.as_ref().to_path_buf()); self diff --git a/src/oarocr/ocr.rs b/src/oarocr/ocr.rs index 29ae57d..712bbd4 100644 --- a/src/oarocr/ocr.rs +++ b/src/oarocr/ocr.rs @@ -1130,6 +1130,7 @@ mod tests { let rec_config = TextRecognitionConfig { score_threshold: 0.7, + ..Default::default() }; let builder = OAROCRBuilder::new("models/det.onnx", "models/rec.onnx", "models/dict.txt") From 20231e02647afa74fb2f5263f95a67b3683f154f Mon Sep 17 00:00:00 2001 From: Wang Xin Date: Sun, 12 Jul 2026 09:52:09 +0800 Subject: [PATCH 2/5] Address RTL text direction review feedback --- examples/ocr.rs | 1 - examples/structure.rs | 1 - .../adapters/text_recognition_adapter.rs | 32 +++- oar-ocr-core/src/domain/tasks/mod.rs | 2 +- .../src/domain/tasks/text_recognition.rs | 157 +++++++++++++++--- .../src/predictors/text_recognition.rs | 12 +- src/oarocr/ocr.rs | 12 +- src/oarocr/structure.rs | 14 +- 8 files changed, 189 insertions(+), 42 deletions(-) diff --git a/examples/ocr.rs b/examples/ocr.rs index d7514b7..0639f53 100644 --- a/examples/ocr.rs +++ b/examples/ocr.rs @@ -216,7 +216,6 @@ fn main() -> Result<(), Box> { let rec_config = TextRecognitionConfig { score_threshold: args.rec_score_thresh, - ..Default::default() }; // Construct OCR pipeline diff --git a/examples/structure.rs b/examples/structure.rs index 922965a..b8e1a71 100644 --- a/examples/structure.rs +++ b/examples/structure.rs @@ -575,7 +575,6 @@ fn main() -> Result<(), Box> { let text_rec_config = TextRecognitionConfig { score_threshold: args.rec_score_thresh, - ..Default::default() }; // Build structure pipeline diff --git a/oar-ocr-core/src/domain/adapters/text_recognition_adapter.rs b/oar-ocr-core/src/domain/adapters/text_recognition_adapter.rs index 40758d7..be99911 100644 --- a/oar-ocr-core/src/domain/adapters/text_recognition_adapter.rs +++ b/oar-ocr-core/src/domain/adapters/text_recognition_adapter.rs @@ -9,7 +9,8 @@ use crate::core::traits::{ task::Task, }; use crate::domain::tasks::{ - TextRecognitionConfig, TextRecognitionOutput, TextRecognitionTask, postprocess_text_direction, + TextDirection, TextRecognitionConfig, TextRecognitionOutput, TextRecognitionTask, + postprocess_text_direction_with_order_change, }; use crate::impl_adapter_builder; use crate::models::recognition::crnn::{CRNNModel, CRNNModelBuilder, CRNNPreprocessConfig}; @@ -23,6 +24,8 @@ pub struct TextRecognitionAdapter { info: AdapterInfo, /// Task configuration config: TextRecognitionConfig, + /// Reading direction used for text post-processing + text_direction: TextDirection, /// Whether to return character positions for word box generation return_word_box: bool, } @@ -85,14 +88,19 @@ impl ModelAdapter for TextRecognitionAdapter { ) { if score >= effective_config.score_threshold { - result_texts.push(postprocess_text_direction( - text, - effective_config.text_direction, - )); + let (text, changed_order) = + postprocess_text_direction_with_order_change(text, self.text_direction); result_scores.push(score); - result_positions.push(positions); - result_col_indices.push(col_indices); - result_seq_lengths.push(seq_len); + result_texts.push(text); + if changed_order { + result_positions.push(Vec::new()); + result_col_indices.push(Vec::new()); + result_seq_lengths.push(0); + } else { + result_positions.push(positions); + result_col_indices.push(col_indices); + result_seq_lengths.push(seq_len); + } } else { // Keep entry to preserve index correspondence, but mark as filtered result_texts.push(String::new()); @@ -141,6 +149,7 @@ impl_adapter_builder! { preprocess_config: CRNNPreprocessConfig = CRNNPreprocessConfig::default(), character_dict: Option> = None, return_word_box: bool = false, + text_direction: TextDirection = TextDirection::Ltr, model_name_override: Option = None, }, @@ -184,6 +193,12 @@ impl_adapter_builder! { self.return_word_box = enable; self } + + /// Sets reading-direction post-processing. + pub fn text_direction(mut self, direction: TextDirection) -> Self { + self.text_direction = direction; + self + } } build: |builder: TextRecognitionAdapterBuilder, model_source: crate::core::ModelSource| -> Result { @@ -212,6 +227,7 @@ impl_adapter_builder! { model, info, config: task_config, + text_direction: builder.text_direction, return_word_box: builder.return_word_box, }) }, diff --git a/oar-ocr-core/src/domain/tasks/mod.rs b/oar-ocr-core/src/domain/tasks/mod.rs index 3b01bf5..c6acf37 100644 --- a/oar-ocr-core/src/domain/tasks/mod.rs +++ b/oar-ocr-core/src/domain/tasks/mod.rs @@ -46,5 +46,5 @@ pub use text_line_orientation::{ }; pub use text_recognition::{ TextDirection, TextRecognitionConfig, TextRecognitionOutput, TextRecognitionTask, - postprocess_text_direction, + postprocess_text_direction, postprocess_text_direction_with_order_change, }; diff --git a/oar-ocr-core/src/domain/tasks/text_recognition.rs b/oar-ocr-core/src/domain/tasks/text_recognition.rs index d13aaa7..382d7ba 100644 --- a/oar-ocr-core/src/domain/tasks/text_recognition.rs +++ b/oar-ocr-core/src/domain/tasks/text_recognition.rs @@ -20,7 +20,7 @@ pub enum TextDirection { Ltr, /// Convert visual right-to-left OCR output into logical string order. Rtl, - /// Convert only when decoded text contains right-to-left characters. + /// Convert only when a decoded line's first strong directional character is right-to-left. Auto, } @@ -32,16 +32,12 @@ pub struct TextRecognitionConfig { /// Score threshold for recognition (default: 0.0, no filtering) #[validate(range(min = 0.0, max = 1.0))] pub score_threshold: f32, - /// Reading direction used for text post-processing. - #[serde(default)] - pub text_direction: TextDirection, } impl Default for TextRecognitionConfig { fn default() -> Self { Self { score_threshold: 0.0, - text_direction: TextDirection::Ltr, } } } @@ -57,6 +53,10 @@ fn is_combining_mark(c: char) -> bool { bidi_class(c) == BidiClass::NSM } +fn is_strong_ltr_char(c: char) -> bool { + bidi_class(c) == BidiClass::L +} + fn is_ltr_token_char(c: char) -> bool { matches!( bidi_class(c), @@ -66,9 +66,24 @@ fn is_ltr_token_char(c: char) -> bool { | BidiClass::ES | BidiClass::ET | BidiClass::CS + | BidiClass::NSM ) || "._:/%+-#@&".contains(c) } +fn matching_open_bracket(close: char) -> Option { + match close { + ')' => Some('('), + ']' => Some('['), + '}' => Some('{'), + '>' => Some('<'), + _ => None, + } +} + +fn is_open_bracket(c: char) -> bool { + matches!(c, '(' | '[' | '{' | '<') +} + fn preserve_ltr_phrase_order(chars: &mut [char]) { let mut i = 0; while i < chars.len() { @@ -77,35 +92,53 @@ fn preserve_ltr_phrase_order(chars: &mut [char]) { continue; } - let start = i; + let mut start = i; i += 1; - while i < chars.len() && (is_ltr_token_char(chars[i]) || chars[i].is_whitespace()) { + while i < chars.len() + && (is_ltr_token_char(chars[i]) + || chars[i].is_whitespace() + || (is_open_bracket(chars[i]) + && chars + .get(i + 1) + .is_some_and(|next| is_ltr_token_char(*next)))) + { i += 1; } let mut end = i; while end > start && chars[end - 1].is_whitespace() { end -= 1; } + + if start > 0 + && let Some(open) = matching_open_bracket(chars[start - 1]) + && chars[start..end].contains(&open) + { + start -= 1; + } + chars[start..end].reverse(); } } fn normalize_leading_combining_marks(chars: Vec) -> Vec { - let mut normalized = Vec::with_capacity(chars.len()); + let mut normalized: Vec = Vec::with_capacity(chars.len()); let mut pending_marks = Vec::new(); for c in chars { if is_combining_mark(c) { + if let Some(&last) = normalized.last() + && !last.is_whitespace() + { + normalized.push(c); + continue; + } pending_marks.push(c); continue; } normalized.push(c); - if is_rtl_char(c) && !pending_marks.is_empty() { + if !pending_marks.is_empty() && !c.is_whitespace() { normalized.append(&mut pending_marks); - } else if !pending_marks.is_empty() { - let insertion = normalized.len().saturating_sub(1); - normalized.splice(insertion..insertion, pending_marks.drain(..)); } } @@ -114,10 +147,6 @@ fn normalize_leading_combining_marks(chars: Vec) -> Vec { } fn visual_rtl_line_to_logical(line: &str) -> String { - if !line.chars().any(is_rtl_char) { - return line.to_string(); - } - // PaddleOCR/PaddleX Arabic CTC recognizers can expose visual RTL order. // Convert to logical order while using Unicode bidi classes for RTL, // non-spacing marks, and embedded LTR/number phrases. @@ -128,26 +157,67 @@ fn visual_rtl_line_to_logical(line: &str) -> String { .collect() } -fn visual_rtl_to_logical(text: &str) -> String { +fn has_rtl_char(text: &str) -> bool { + text.chars().any(is_rtl_char) +} + +fn has_rtl_base_direction(line: &str) -> bool { + for c in line.chars() { + if is_rtl_char(c) { + return true; + } + if is_strong_ltr_char(c) { + return false; + } + } + false +} + +fn should_convert_visual_rtl_line(line: &str, direction: TextDirection) -> bool { + match direction { + TextDirection::Ltr => false, + TextDirection::Rtl => has_rtl_char(line), + TextDirection::Auto => has_rtl_base_direction(line), + } +} + +fn visual_rtl_to_logical(text: &str, direction: TextDirection) -> (String, bool) { let mut out = String::with_capacity(text.len()); + let mut changed_order = false; + for segment in text.split_inclusive('\n') { if let Some(line) = segment.strip_suffix('\n') { - out.push_str(&visual_rtl_line_to_logical(line)); + if should_convert_visual_rtl_line(line, direction) { + out.push_str(&visual_rtl_line_to_logical(line)); + changed_order = true; + } else { + out.push_str(line); + } out.push('\n'); - } else { + } else if should_convert_visual_rtl_line(segment, direction) { out.push_str(&visual_rtl_line_to_logical(segment)); + changed_order = true; + } else { + out.push_str(segment); } } - out + + (out, changed_order) } /// Applies configured reading-direction post-processing to decoded OCR text. pub fn postprocess_text_direction(text: String, direction: TextDirection) -> String { + postprocess_text_direction_with_order_change(text, direction).0 +} + +/// Applies reading-direction post-processing and reports whether character order changed. +pub fn postprocess_text_direction_with_order_change( + text: String, + direction: TextDirection, +) -> (String, bool) { match direction { - TextDirection::Ltr => text, - TextDirection::Rtl => visual_rtl_to_logical(&text), - TextDirection::Auto if text.chars().any(is_rtl_char) => visual_rtl_to_logical(&text), - TextDirection::Auto => text, + TextDirection::Ltr => (text, false), + TextDirection::Rtl | TextDirection::Auto => visual_rtl_to_logical(&text, direction), } } @@ -349,6 +419,14 @@ mod tests { ); } + #[test] + fn auto_postprocess_leaves_ltr_base_mixed_text_unchanged() { + assert_eq!( + postprocess_text_direction("Issue رقم 123".to_string(), TextDirection::Auto), + "Issue رقم 123" + ); + } + #[test] fn rtl_postprocess_keeps_combining_marks_on_rtl_base() { assert_eq!( @@ -365,6 +443,37 @@ mod tests { ); } + #[test] + fn rtl_postprocess_preserves_internal_paired_punctuation_in_ltr_token() { + assert_eq!( + postprocess_text_direction("abc(def) ابحرم".to_string(), TextDirection::Rtl), + "مرحبا abc(def)" + ); + } + + #[test] + fn rtl_postprocess_preserves_ltr_combining_marks() { + assert_eq!( + postprocess_text_direction("cafe\u{301} ابحرم".to_string(), TextDirection::Rtl), + "مرحبا cafe\u{301}" + ); + } + + #[test] + fn postprocess_reports_order_changes() { + assert_eq!( + postprocess_text_direction_with_order_change( + "Issue رقم 123".to_string(), + TextDirection::Auto, + ), + ("Issue رقم 123".to_string(), false) + ); + assert_eq!( + postprocess_text_direction_with_order_change("ابحرم".to_string(), TextDirection::Rtl), + ("مرحبا".to_string(), true) + ); + } + #[test] fn rtl_postprocess_preserves_arabic_indic_number_order() { assert_eq!( diff --git a/oar-ocr-core/src/predictors/text_recognition.rs b/oar-ocr-core/src/predictors/text_recognition.rs index 85111bf..a23d97e 100644 --- a/oar-ocr-core/src/predictors/text_recognition.rs +++ b/oar-ocr-core/src/predictors/text_recognition.rs @@ -53,6 +53,7 @@ impl TextRecognitionPredictor { pub struct TextRecognitionPredictorBuilder { state: PredictorBuilderState, dict_path: Option, + text_direction: TextDirection, } impl TextRecognitionPredictorBuilder { @@ -60,9 +61,9 @@ impl TextRecognitionPredictorBuilder { Self { state: PredictorBuilderState::new(TextRecognitionConfig { score_threshold: 0.0, - text_direction: TextDirection::Ltr, }), dict_path: None, + text_direction: TextDirection::Ltr, } } @@ -72,7 +73,7 @@ impl TextRecognitionPredictorBuilder { } pub fn text_direction(mut self, direction: TextDirection) -> Self { - self.state.config_mut().text_direction = direction; + self.text_direction = direction; self } @@ -85,7 +86,11 @@ impl TextRecognitionPredictorBuilder { self, model_source: impl Into, ) -> OcrResult { - let Self { state, dict_path } = self; + let Self { + state, + dict_path, + text_direction, + } = self; let (config, ort_config) = state.into_parts(); let dict_path = dict_path @@ -100,6 +105,7 @@ impl TextRecognitionPredictorBuilder { let mut adapter_builder = TextRecognitionAdapterBuilder::new() .with_config(config.clone()) + .text_direction(text_direction) .character_dict(character_dict); if let Some(ort_cfg) = ort_config { diff --git a/src/oarocr/ocr.rs b/src/oarocr/ocr.rs index 712bbd4..390c997 100644 --- a/src/oarocr/ocr.rs +++ b/src/oarocr/ocr.rs @@ -18,7 +18,7 @@ use oar_ocr_core::domain::adapters::{ TextRecognitionAdapter, TextRecognitionAdapterBuilder, UVDocRectifierAdapter, UVDocRectifierAdapterBuilder, }; -use oar_ocr_core::domain::tasks::{TextDetectionConfig, TextRecognitionConfig}; +use oar_ocr_core::domain::tasks::{TextDetectionConfig, TextDirection, TextRecognitionConfig}; use oar_ocr_core::processors::BoundingBox; use std::path::PathBuf; use std::sync::Arc; @@ -81,6 +81,7 @@ pub struct OAROCRBuilder { // Text type and word box options text_type: Option, + text_direction: TextDirection, return_word_box: bool, } @@ -118,6 +119,7 @@ impl OAROCRBuilder { image_batch_size: None, region_batch_size: None, text_type: None, + text_direction: TextDirection::Ltr, return_word_box: false, } } @@ -222,6 +224,12 @@ impl OAROCRBuilder { self } + /// Sets reading-direction post-processing for recognized text. + pub fn text_direction(mut self, direction: TextDirection) -> Self { + self.text_direction = direction; + self + } + /// Enables word-level bounding box detection. /// /// When enabled, the pipeline will attempt to detect individual words @@ -367,6 +375,7 @@ impl OAROCRBuilder { let mut recognition_builder = TextRecognitionAdapterBuilder::new() .character_dict(char_dict_vec) + .text_direction(self.text_direction) .return_word_box(self.return_word_box); if let Some(ref ort_config) = self.ort_session_config { @@ -1130,7 +1139,6 @@ mod tests { let rec_config = TextRecognitionConfig { score_threshold: 0.7, - ..Default::default() }; let builder = OAROCRBuilder::new("models/det.onnx", "models/rec.onnx", "models/dict.txt") diff --git a/src/oarocr/structure.rs b/src/oarocr/structure.rs index a30fa5a..02c8b70 100644 --- a/src/oarocr/structure.rs +++ b/src/oarocr/structure.rs @@ -23,7 +23,7 @@ use oar_ocr_core::domain::adapters::{ use oar_ocr_core::domain::structure::{StructureResult, TableResult}; use oar_ocr_core::domain::tasks::{ FormulaRecognitionConfig, LayoutDetectionConfig, TableCellDetectionConfig, - TableClassificationConfig, TableStructureRecognitionConfig, TextDetectionConfig, + TableClassificationConfig, TableStructureRecognitionConfig, TextDetectionConfig, TextDirection, TextRecognitionConfig, }; use std::path::PathBuf; @@ -189,6 +189,7 @@ pub struct OARStructureBuilder { formula_recognition_config: Option, text_detection_config: Option, text_recognition_config: Option, + text_direction: TextDirection, // Batch sizes image_batch_size: Option, @@ -250,6 +251,7 @@ impl OARStructureBuilder { formula_recognition_config: None, text_detection_config: None, text_recognition_config: None, + text_direction: TextDirection::Ltr, image_batch_size: None, region_batch_size: None, } @@ -657,6 +659,12 @@ impl OARStructureBuilder { self } + /// Sets reading-direction post-processing for recognized text. + pub fn text_direction(mut self, direction: TextDirection) -> Self { + self.text_direction = direction; + self + } + /// Builds the structure analyzer runtime. /// /// This method instantiates all adapters and returns a ready-to-use structure analyzer. @@ -1246,7 +1254,9 @@ impl OARStructureBuilder { // Parse dict into Vec - one character per line let char_vec: Vec = dict.lines().map(|s| s.to_string()).collect(); - let mut builder = TextRecognitionAdapterBuilder::new().character_dict(char_vec); + let mut builder = TextRecognitionAdapterBuilder::new() + .character_dict(char_vec) + .text_direction(self.text_direction); if let Some(ref config) = self.text_recognition_config { builder = builder.with_config(config.clone()); From 2662bf548b586138ccfde639e308100dd6fd0ec3 Mon Sep 17 00:00:00 2001 From: Wang Xin Date: Sun, 12 Jul 2026 10:14:54 +0800 Subject: [PATCH 3/5] Fix RTL text direction edge cases --- .../src/domain/tasks/text_recognition.rs | 75 ++++++++++++++++--- 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/oar-ocr-core/src/domain/tasks/text_recognition.rs b/oar-ocr-core/src/domain/tasks/text_recognition.rs index 382d7ba..6a65b17 100644 --- a/oar-ocr-core/src/domain/tasks/text_recognition.rs +++ b/oar-ocr-core/src/domain/tasks/text_recognition.rs @@ -67,7 +67,7 @@ fn is_ltr_token_char(c: char) -> bool { | BidiClass::ET | BidiClass::CS | BidiClass::NSM - ) || "._:/%+-#@&".contains(c) + ) || "._:/%+-#@&'’`".contains(c) } fn matching_open_bracket(close: char) -> Option { @@ -124,10 +124,20 @@ fn normalize_leading_combining_marks(chars: Vec) -> Vec { let mut normalized: Vec = Vec::with_capacity(chars.len()); let mut pending_marks = Vec::new(); - for c in chars { + for (idx, c) in chars.iter().copied().enumerate() { if is_combining_mark(c) { - if let Some(&last) = normalized.last() - && !last.is_whitespace() + let last_base = normalized + .iter() + .rev() + .copied() + .find(|last| !is_combining_mark(*last)); + let next_base = chars + .iter() + .skip(idx + 1) + .copied() + .find(|next| !is_combining_mark(*next)); + if last_base.is_some_and(is_ltr_token_char) + || next_base.is_none_or(|next| next.is_whitespace()) { normalized.push(c); continue; @@ -161,12 +171,15 @@ fn has_rtl_char(text: &str) -> bool { text.chars().any(is_rtl_char) } -fn has_rtl_base_direction(line: &str) -> bool { - for c in line.chars() { +fn has_rtl_visual_line_end(line: &str) -> bool { + for c in line.chars().rev() { + if c.is_whitespace() || is_combining_mark(c) { + continue; + } if is_rtl_char(c) { return true; } - if is_strong_ltr_char(c) { + if is_strong_ltr_char(c) || is_ltr_token_char(c) { return false; } } @@ -177,7 +190,7 @@ fn should_convert_visual_rtl_line(line: &str, direction: TextDirection) -> bool match direction { TextDirection::Ltr => false, TextDirection::Rtl => has_rtl_char(line), - TextDirection::Auto => has_rtl_base_direction(line), + TextDirection::Auto => has_rtl_visual_line_end(line), } } @@ -188,15 +201,17 @@ fn visual_rtl_to_logical(text: &str, direction: TextDirection) -> (String, bool) for segment in text.split_inclusive('\n') { if let Some(line) = segment.strip_suffix('\n') { if should_convert_visual_rtl_line(line, direction) { - out.push_str(&visual_rtl_line_to_logical(line)); - changed_order = true; + let converted = visual_rtl_line_to_logical(line); + changed_order |= converted != line; + out.push_str(&converted); } else { out.push_str(line); } out.push('\n'); } else if should_convert_visual_rtl_line(segment, direction) { - out.push_str(&visual_rtl_line_to_logical(segment)); - changed_order = true; + let converted = visual_rtl_line_to_logical(segment); + changed_order |= converted != segment; + out.push_str(&converted); } else { out.push_str(segment); } @@ -427,6 +442,14 @@ mod tests { ); } + #[test] + fn auto_postprocess_detects_rtl_from_visual_line_end() { + assert_eq!( + postprocess_text_direction("abc 123 ابحرم".to_string(), TextDirection::Auto), + "مرحبا abc 123" + ); + } + #[test] fn rtl_postprocess_keeps_combining_marks_on_rtl_base() { assert_eq!( @@ -435,6 +458,14 @@ mod tests { ); } + #[test] + fn rtl_postprocess_keeps_multiple_combining_marks_on_each_rtl_base() { + assert_eq!( + postprocess_text_direction("بَتِكُ".to_string(), TextDirection::Rtl), + "كُتِبَ" + ); + } + #[test] fn rtl_postprocess_preserves_paired_punctuation() { assert_eq!( @@ -459,6 +490,22 @@ mod tests { ); } + #[test] + fn rtl_postprocess_preserves_ltr_combining_marks_before_another_ltr_word() { + assert_eq!( + postprocess_text_direction("cafe\u{301} xyz ابحرم".to_string(), TextDirection::Rtl), + "مرحبا cafe\u{301} xyz" + ); + } + + #[test] + fn rtl_postprocess_preserves_apostrophes_inside_ltr_tokens() { + assert_eq!( + postprocess_text_direction("O'Reilly ابحرم".to_string(), TextDirection::Rtl), + "مرحبا O'Reilly" + ); + } + #[test] fn postprocess_reports_order_changes() { assert_eq!( @@ -472,6 +519,10 @@ mod tests { postprocess_text_direction_with_order_change("ابحرم".to_string(), TextDirection::Rtl), ("مرحبا".to_string(), true) ); + assert_eq!( + postprocess_text_direction_with_order_change("م".to_string(), TextDirection::Rtl), + ("م".to_string(), false) + ); } #[test] From b9a7f893789e5ce1616584b9028653bf1359939b Mon Sep 17 00:00:00 2001 From: Wang Xin Date: Sun, 12 Jul 2026 13:22:50 +0800 Subject: [PATCH 4/5] Refine RTL auto direction detection --- .../src/domain/tasks/text_recognition.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/oar-ocr-core/src/domain/tasks/text_recognition.rs b/oar-ocr-core/src/domain/tasks/text_recognition.rs index 6a65b17..e182449 100644 --- a/oar-ocr-core/src/domain/tasks/text_recognition.rs +++ b/oar-ocr-core/src/domain/tasks/text_recognition.rs @@ -57,6 +57,10 @@ fn is_strong_ltr_char(c: char) -> bool { bidi_class(c) == BidiClass::L } +fn is_weak_number_char(c: char) -> bool { + matches!(bidi_class(c), BidiClass::EN | BidiClass::AN) +} + fn is_ltr_token_char(c: char) -> bool { matches!( bidi_class(c), @@ -171,12 +175,33 @@ fn has_rtl_char(text: &str) -> bool { text.chars().any(is_rtl_char) } +fn has_ltr_base_direction(line: &str) -> bool { + for c in line.chars() { + if is_strong_ltr_char(c) { + return true; + } + if is_rtl_char(c) { + return false; + } + } + false +} + fn has_rtl_visual_line_end(line: &str) -> bool { + let mut skipped_trailing_number = false; + for c in line.chars().rev() { if c.is_whitespace() || is_combining_mark(c) { continue; } + if is_weak_number_char(c) { + skipped_trailing_number = true; + continue; + } if is_rtl_char(c) { + if skipped_trailing_number && has_ltr_base_direction(line) { + return false; + } return true; } if is_strong_ltr_char(c) || is_ltr_token_char(c) { @@ -450,6 +475,14 @@ mod tests { ); } + #[test] + fn auto_postprocess_skips_trailing_weak_digits_for_rtl_base() { + assert_eq!( + postprocess_text_direction("ابحرم ١٢٣".to_string(), TextDirection::Auto), + "١٢٣ مرحبا" + ); + } + #[test] fn rtl_postprocess_keeps_combining_marks_on_rtl_base() { assert_eq!( From 22f366dba8ab81685a07fec3559b0ce8d0881cb0 Mon Sep 17 00:00:00 2001 From: Wang Xin Date: Sun, 12 Jul 2026 13:50:34 +0800 Subject: [PATCH 5/5] Use unicode bidi for text postprocessing --- examples/text_recognition.rs | 24 -- .../adapters/text_recognition_adapter.rs | 14 +- oar-ocr-core/src/domain/tasks/mod.rs | 4 +- .../src/domain/tasks/text_recognition.rs | 372 ++---------------- .../src/predictors/text_recognition.rs | 18 +- src/oarocr/ocr.rs | 11 +- src/oarocr/structure.rs | 14 +- 7 files changed, 36 insertions(+), 421 deletions(-) diff --git a/examples/text_recognition.rs b/examples/text_recognition.rs index 7be0159..a379289 100644 --- a/examples/text_recognition.rs +++ b/examples/text_recognition.rs @@ -32,7 +32,6 @@ mod utils; use clap::Parser; -use oar_ocr::domain::tasks::TextDirection; use oar_ocr::predictors::TextRecognitionPredictor; use oar_ocr::utils::load_image; use std::path::PathBuf; @@ -74,10 +73,6 @@ struct Args { #[arg(long, default_value = "0.0")] score_thresh: f32, - /// Text direction post-processing: ltr, rtl, or auto - #[arg(long, default_value = "ltr")] - text_direction: TextDirectionArg, - /// Maximum image width for resizing (optional, e.g., 320) #[arg(long)] max_img_w: Option, @@ -95,23 +90,6 @@ struct Args { verbose: bool, } -#[derive(Clone, Copy, Debug, clap::ValueEnum)] -enum TextDirectionArg { - Ltr, - Rtl, - Auto, -} - -impl From for TextDirection { - fn from(value: TextDirectionArg) -> Self { - match value { - TextDirectionArg::Ltr => TextDirection::Ltr, - TextDirectionArg::Rtl => TextDirection::Rtl, - TextDirectionArg::Auto => TextDirection::Auto, - } - } -} - fn main() -> Result<(), Box> { // Initialize tracing for logging utils::init_tracing(); @@ -164,7 +142,6 @@ fn main() -> Result<(), Box> { if args.verbose { info!("Recognition Configuration:"); info!(" Score threshold: {}", args.score_thresh); - info!(" Text direction: {:?}", args.text_direction); info!( " Input shape: [3, {}, {}]", args.input_height, args.input_width @@ -179,7 +156,6 @@ fn main() -> Result<(), Box> { let predictor = TextRecognitionPredictor::builder() .score_threshold(args.score_thresh) - .text_direction(args.text_direction.into()) .dict_path(&args.dict_path) .with_ort_config(ort_config) .build(&args.model_path)?; diff --git a/oar-ocr-core/src/domain/adapters/text_recognition_adapter.rs b/oar-ocr-core/src/domain/adapters/text_recognition_adapter.rs index be99911..43e2d8e 100644 --- a/oar-ocr-core/src/domain/adapters/text_recognition_adapter.rs +++ b/oar-ocr-core/src/domain/adapters/text_recognition_adapter.rs @@ -9,7 +9,7 @@ use crate::core::traits::{ task::Task, }; use crate::domain::tasks::{ - TextDirection, TextRecognitionConfig, TextRecognitionOutput, TextRecognitionTask, + TextRecognitionConfig, TextRecognitionOutput, TextRecognitionTask, postprocess_text_direction_with_order_change, }; use crate::impl_adapter_builder; @@ -24,8 +24,6 @@ pub struct TextRecognitionAdapter { info: AdapterInfo, /// Task configuration config: TextRecognitionConfig, - /// Reading direction used for text post-processing - text_direction: TextDirection, /// Whether to return character positions for word box generation return_word_box: bool, } @@ -88,8 +86,7 @@ impl ModelAdapter for TextRecognitionAdapter { ) { if score >= effective_config.score_threshold { - let (text, changed_order) = - postprocess_text_direction_with_order_change(text, self.text_direction); + let (text, changed_order) = postprocess_text_direction_with_order_change(text); result_scores.push(score); result_texts.push(text); if changed_order { @@ -149,7 +146,6 @@ impl_adapter_builder! { preprocess_config: CRNNPreprocessConfig = CRNNPreprocessConfig::default(), character_dict: Option> = None, return_word_box: bool = false, - text_direction: TextDirection = TextDirection::Ltr, model_name_override: Option = None, }, @@ -194,11 +190,6 @@ impl_adapter_builder! { self } - /// Sets reading-direction post-processing. - pub fn text_direction(mut self, direction: TextDirection) -> Self { - self.text_direction = direction; - self - } } build: |builder: TextRecognitionAdapterBuilder, model_source: crate::core::ModelSource| -> Result { @@ -227,7 +218,6 @@ impl_adapter_builder! { model, info, config: task_config, - text_direction: builder.text_direction, return_word_box: builder.return_word_box, }) }, diff --git a/oar-ocr-core/src/domain/tasks/mod.rs b/oar-ocr-core/src/domain/tasks/mod.rs index c6acf37..2d345c4 100644 --- a/oar-ocr-core/src/domain/tasks/mod.rs +++ b/oar-ocr-core/src/domain/tasks/mod.rs @@ -45,6 +45,6 @@ pub use text_line_orientation::{ TextLineOrientationConfig, TextLineOrientationOutput, TextLineOrientationTask, }; pub use text_recognition::{ - TextDirection, TextRecognitionConfig, TextRecognitionOutput, TextRecognitionTask, - postprocess_text_direction, postprocess_text_direction_with_order_change, + TextRecognitionConfig, TextRecognitionOutput, TextRecognitionTask, postprocess_text_direction, + postprocess_text_direction_with_order_change, }; diff --git a/oar-ocr-core/src/domain/tasks/text_recognition.rs b/oar-ocr-core/src/domain/tasks/text_recognition.rs index e182449..e788cad 100644 --- a/oar-ocr-core/src/domain/tasks/text_recognition.rs +++ b/oar-ocr-core/src/domain/tasks/text_recognition.rs @@ -9,20 +9,7 @@ use crate::core::traits::TaskDefinition; use crate::core::traits::task::{ImageTaskInput, Task, TaskSchema, TaskType}; use crate::utils::ScoreValidator; use serde::{Deserialize, Serialize}; -use unicode_bidi::{BidiClass, bidi_class}; - -/// Reading direction for recognized text post-processing. -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum TextDirection { - /// Keep decoded text in the model's left-to-right sequence order. - #[default] - Ltr, - /// Convert visual right-to-left OCR output into logical string order. - Rtl, - /// Convert only when a decoded line's first strong directional character is right-to-left. - Auto, -} +use unicode_bidi::BidiInfo; /// Configuration for text recognition task. /// @@ -42,223 +29,45 @@ impl Default for TextRecognitionConfig { } } -fn is_rtl_char(c: char) -> bool { - matches!( - bidi_class(c), - BidiClass::AL | BidiClass::R | BidiClass::RLE | BidiClass::RLI | BidiClass::RLO - ) -} - -fn is_combining_mark(c: char) -> bool { - bidi_class(c) == BidiClass::NSM -} - -fn is_strong_ltr_char(c: char) -> bool { - bidi_class(c) == BidiClass::L -} - -fn is_weak_number_char(c: char) -> bool { - matches!(bidi_class(c), BidiClass::EN | BidiClass::AN) -} - -fn is_ltr_token_char(c: char) -> bool { - matches!( - bidi_class(c), - BidiClass::L - | BidiClass::EN - | BidiClass::AN - | BidiClass::ES - | BidiClass::ET - | BidiClass::CS - | BidiClass::NSM - ) || "._:/%+-#@&'’`".contains(c) -} - -fn matching_open_bracket(close: char) -> Option { - match close { - ')' => Some('('), - ']' => Some('['), - '}' => Some('{'), - '>' => Some('<'), - _ => None, - } -} - -fn is_open_bracket(c: char) -> bool { - matches!(c, '(' | '[' | '{' | '<') -} - -fn preserve_ltr_phrase_order(chars: &mut [char]) { - let mut i = 0; - while i < chars.len() { - if !is_ltr_token_char(chars[i]) { - i += 1; - continue; - } - - let mut start = i; - i += 1; - while i < chars.len() - && (is_ltr_token_char(chars[i]) - || chars[i].is_whitespace() - || (is_open_bracket(chars[i]) - && chars - .get(i + 1) - .is_some_and(|next| is_ltr_token_char(*next)))) - { - i += 1; - } - let mut end = i; - while end > start && chars[end - 1].is_whitespace() { - end -= 1; - } - - if start > 0 - && let Some(open) = matching_open_bracket(chars[start - 1]) - && chars[start..end].contains(&open) - { - start -= 1; - } - - chars[start..end].reverse(); - } -} - -fn normalize_leading_combining_marks(chars: Vec) -> Vec { - let mut normalized: Vec = Vec::with_capacity(chars.len()); - let mut pending_marks = Vec::new(); - - for (idx, c) in chars.iter().copied().enumerate() { - if is_combining_mark(c) { - let last_base = normalized - .iter() - .rev() - .copied() - .find(|last| !is_combining_mark(*last)); - let next_base = chars - .iter() - .skip(idx + 1) - .copied() - .find(|next| !is_combining_mark(*next)); - if last_base.is_some_and(is_ltr_token_char) - || next_base.is_none_or(|next| next.is_whitespace()) - { - normalized.push(c); - continue; - } - pending_marks.push(c); - continue; - } - - normalized.push(c); - if !pending_marks.is_empty() && !c.is_whitespace() { - normalized.append(&mut pending_marks); - } - } - - normalized.extend(pending_marks); - normalized -} - -fn visual_rtl_line_to_logical(line: &str) -> String { - // PaddleOCR/PaddleX Arabic CTC recognizers can expose visual RTL order. - // Convert to logical order while using Unicode bidi classes for RTL, - // non-spacing marks, and embedded LTR/number phrases. - let mut chars: Vec = line.chars().rev().collect(); - preserve_ltr_phrase_order(&mut chars); - normalize_leading_combining_marks(chars) - .into_iter() - .collect() -} - -fn has_rtl_char(text: &str) -> bool { - text.chars().any(is_rtl_char) -} - -fn has_ltr_base_direction(line: &str) -> bool { - for c in line.chars() { - if is_strong_ltr_char(c) { - return true; - } - if is_rtl_char(c) { - return false; - } - } - false -} - -fn has_rtl_visual_line_end(line: &str) -> bool { - let mut skipped_trailing_number = false; - - for c in line.chars().rev() { - if c.is_whitespace() || is_combining_mark(c) { - continue; - } - if is_weak_number_char(c) { - skipped_trailing_number = true; - continue; - } - if is_rtl_char(c) { - if skipped_trailing_number && has_ltr_base_direction(line) { - return false; - } - return true; - } - if is_strong_ltr_char(c) || is_ltr_token_char(c) { - return false; - } - } - false -} +fn reorder_bidi_line(line: &str) -> String { + let bidi_info = BidiInfo::new(line, None); + let Some(para) = bidi_info.paragraphs.first() else { + return line.to_string(); + }; -fn should_convert_visual_rtl_line(line: &str, direction: TextDirection) -> bool { - match direction { - TextDirection::Ltr => false, - TextDirection::Rtl => has_rtl_char(line), - TextDirection::Auto => has_rtl_visual_line_end(line), - } + bidi_info + .reorder_line(para, para.range.clone()) + .into_owned() } -fn visual_rtl_to_logical(text: &str, direction: TextDirection) -> (String, bool) { +fn reorder_bidi_text(text: &str) -> (String, bool) { let mut out = String::with_capacity(text.len()); let mut changed_order = false; for segment in text.split_inclusive('\n') { if let Some(line) = segment.strip_suffix('\n') { - if should_convert_visual_rtl_line(line, direction) { - let converted = visual_rtl_line_to_logical(line); - changed_order |= converted != line; - out.push_str(&converted); - } else { - out.push_str(line); - } + let converted = reorder_bidi_line(line); + changed_order |= converted != line; + out.push_str(&converted); out.push('\n'); - } else if should_convert_visual_rtl_line(segment, direction) { - let converted = visual_rtl_line_to_logical(segment); + } else { + let converted = reorder_bidi_line(segment); changed_order |= converted != segment; out.push_str(&converted); - } else { - out.push_str(segment); } } (out, changed_order) } -/// Applies configured reading-direction post-processing to decoded OCR text. -pub fn postprocess_text_direction(text: String, direction: TextDirection) -> String { - postprocess_text_direction_with_order_change(text, direction).0 +/// Applies Unicode bidi post-processing to decoded OCR text. +pub fn postprocess_text_direction(text: String) -> String { + postprocess_text_direction_with_order_change(text).0 } -/// Applies reading-direction post-processing and reports whether character order changed. -pub fn postprocess_text_direction_with_order_change( - text: String, - direction: TextDirection, -) -> (String, bool) { - match direction { - TextDirection::Ltr => (text, false), - TextDirection::Rtl | TextDirection::Auto => visual_rtl_to_logical(&text, direction), - } +/// Applies Unicode bidi post-processing and reports whether character order changed. +pub fn postprocess_text_direction_with_order_change(text: String) -> (String, bool) { + reorder_bidi_text(&text) } /// Output from text recognition task. @@ -420,149 +229,22 @@ mod tests { } #[test] - fn rtl_postprocess_reverses_visual_arabic_order() { - assert_eq!( - postprocess_text_direction( - "دحاو نآ يف لوؤسمو يعاديإ لمع مجرتملا لمع".to_string(), - TextDirection::Rtl, - ), - "عمل المترجم عمل إيداعي ومسؤول في آن واحد" - ); - } - - #[test] - fn rtl_postprocess_preserves_ltr_token_order() { - assert_eq!( - postprocess_text_direction("abc 123 ابحرم".to_string(), TextDirection::Rtl), - "مرحبا abc 123" - ); - } - - #[test] - fn ltr_postprocess_leaves_text_unchanged() { - let text = "دحاو نآ يف".to_string(); + fn bidi_postprocess_leaves_ltr_text_unchanged() { assert_eq!( - postprocess_text_direction(text.clone(), TextDirection::Ltr), - text - ); - } - - #[test] - fn auto_postprocess_only_reverses_rtl_text() { - assert_eq!( - postprocess_text_direction("دحاو نآ يف".to_string(), TextDirection::Auto), - "في آن واحد" - ); - assert_eq!( - postprocess_text_direction("hello 123".to_string(), TextDirection::Auto), + postprocess_text_direction("hello 123".to_string()), "hello 123" ); } #[test] - fn auto_postprocess_leaves_ltr_base_mixed_text_unchanged() { - assert_eq!( - postprocess_text_direction("Issue رقم 123".to_string(), TextDirection::Auto), - "Issue رقم 123" - ); - } - - #[test] - fn auto_postprocess_detects_rtl_from_visual_line_end() { - assert_eq!( - postprocess_text_direction("abc 123 ابحرم".to_string(), TextDirection::Auto), - "مرحبا abc 123" - ); - } - - #[test] - fn auto_postprocess_skips_trailing_weak_digits_for_rtl_base() { - assert_eq!( - postprocess_text_direction("ابحرم ١٢٣".to_string(), TextDirection::Auto), - "١٢٣ مرحبا" - ); - } - - #[test] - fn rtl_postprocess_keeps_combining_marks_on_rtl_base() { - assert_eq!( - postprocess_text_direction("لكشي ّراطإ".to_string(), TextDirection::Rtl), - "إطارّ يشكل" - ); - } - - #[test] - fn rtl_postprocess_keeps_multiple_combining_marks_on_each_rtl_base() { - assert_eq!( - postprocess_text_direction("بَتِكُ".to_string(), TextDirection::Rtl), - "كُتِبَ" - ); - } - - #[test] - fn rtl_postprocess_preserves_paired_punctuation() { + fn bidi_postprocess_reports_order_changes() { assert_eq!( - postprocess_text_direction(")OCR 2026( ابحرم".to_string(), TextDirection::Rtl), - "مرحبا (OCR 2026)" - ); - } - - #[test] - fn rtl_postprocess_preserves_internal_paired_punctuation_in_ltr_token() { - assert_eq!( - postprocess_text_direction("abc(def) ابحرم".to_string(), TextDirection::Rtl), - "مرحبا abc(def)" - ); - } - - #[test] - fn rtl_postprocess_preserves_ltr_combining_marks() { - assert_eq!( - postprocess_text_direction("cafe\u{301} ابحرم".to_string(), TextDirection::Rtl), - "مرحبا cafe\u{301}" - ); - } - - #[test] - fn rtl_postprocess_preserves_ltr_combining_marks_before_another_ltr_word() { - assert_eq!( - postprocess_text_direction("cafe\u{301} xyz ابحرم".to_string(), TextDirection::Rtl), - "مرحبا cafe\u{301} xyz" - ); - } - - #[test] - fn rtl_postprocess_preserves_apostrophes_inside_ltr_tokens() { - assert_eq!( - postprocess_text_direction("O'Reilly ابحرم".to_string(), TextDirection::Rtl), - "مرحبا O'Reilly" - ); - } - - #[test] - fn postprocess_reports_order_changes() { - assert_eq!( - postprocess_text_direction_with_order_change( - "Issue رقم 123".to_string(), - TextDirection::Auto, - ), - ("Issue رقم 123".to_string(), false) + postprocess_text_direction_with_order_change("hello 123".to_string()), + ("hello 123".to_string(), false) ); assert_eq!( - postprocess_text_direction_with_order_change("ابحرم".to_string(), TextDirection::Rtl), + postprocess_text_direction_with_order_change("ابحرم".to_string()), ("مرحبا".to_string(), true) ); - assert_eq!( - postprocess_text_direction_with_order_change("م".to_string(), TextDirection::Rtl), - ("م".to_string(), false) - ); - } - - #[test] - fn rtl_postprocess_preserves_arabic_indic_number_order() { - assert_eq!( - postprocess_text_direction("١٢٣ ابحرم".to_string(), TextDirection::Rtl), - "مرحبا ١٢٣" - ); } } diff --git a/oar-ocr-core/src/predictors/text_recognition.rs b/oar-ocr-core/src/predictors/text_recognition.rs index a23d97e..3d56af7 100644 --- a/oar-ocr-core/src/predictors/text_recognition.rs +++ b/oar-ocr-core/src/predictors/text_recognition.rs @@ -9,9 +9,7 @@ use crate::core::errors::OCRError; use crate::core::traits::OrtConfigurable; use crate::core::traits::task::ImageTaskInput; use crate::domain::adapters::TextRecognitionAdapterBuilder; -use crate::domain::tasks::text_recognition::{ - TextDirection, TextRecognitionConfig, TextRecognitionTask, -}; +use crate::domain::tasks::text_recognition::{TextRecognitionConfig, TextRecognitionTask}; use crate::predictors::TaskPredictorCore; use image::RgbImage; use std::path::{Path, PathBuf}; @@ -53,7 +51,6 @@ impl TextRecognitionPredictor { pub struct TextRecognitionPredictorBuilder { state: PredictorBuilderState, dict_path: Option, - text_direction: TextDirection, } impl TextRecognitionPredictorBuilder { @@ -63,7 +60,6 @@ impl TextRecognitionPredictorBuilder { score_threshold: 0.0, }), dict_path: None, - text_direction: TextDirection::Ltr, } } @@ -72,11 +68,6 @@ impl TextRecognitionPredictorBuilder { self } - pub fn text_direction(mut self, direction: TextDirection) -> Self { - self.text_direction = direction; - self - } - pub fn dict_path>(mut self, path: P) -> Self { self.dict_path = Some(path.as_ref().to_path_buf()); self @@ -86,11 +77,7 @@ impl TextRecognitionPredictorBuilder { self, model_source: impl Into, ) -> OcrResult { - let Self { - state, - dict_path, - text_direction, - } = self; + let Self { state, dict_path } = self; let (config, ort_config) = state.into_parts(); let dict_path = dict_path @@ -105,7 +92,6 @@ impl TextRecognitionPredictorBuilder { let mut adapter_builder = TextRecognitionAdapterBuilder::new() .with_config(config.clone()) - .text_direction(text_direction) .character_dict(character_dict); if let Some(ort_cfg) = ort_config { diff --git a/src/oarocr/ocr.rs b/src/oarocr/ocr.rs index 390c997..29ae57d 100644 --- a/src/oarocr/ocr.rs +++ b/src/oarocr/ocr.rs @@ -18,7 +18,7 @@ use oar_ocr_core::domain::adapters::{ TextRecognitionAdapter, TextRecognitionAdapterBuilder, UVDocRectifierAdapter, UVDocRectifierAdapterBuilder, }; -use oar_ocr_core::domain::tasks::{TextDetectionConfig, TextDirection, TextRecognitionConfig}; +use oar_ocr_core::domain::tasks::{TextDetectionConfig, TextRecognitionConfig}; use oar_ocr_core::processors::BoundingBox; use std::path::PathBuf; use std::sync::Arc; @@ -81,7 +81,6 @@ pub struct OAROCRBuilder { // Text type and word box options text_type: Option, - text_direction: TextDirection, return_word_box: bool, } @@ -119,7 +118,6 @@ impl OAROCRBuilder { image_batch_size: None, region_batch_size: None, text_type: None, - text_direction: TextDirection::Ltr, return_word_box: false, } } @@ -224,12 +222,6 @@ impl OAROCRBuilder { self } - /// Sets reading-direction post-processing for recognized text. - pub fn text_direction(mut self, direction: TextDirection) -> Self { - self.text_direction = direction; - self - } - /// Enables word-level bounding box detection. /// /// When enabled, the pipeline will attempt to detect individual words @@ -375,7 +367,6 @@ impl OAROCRBuilder { let mut recognition_builder = TextRecognitionAdapterBuilder::new() .character_dict(char_dict_vec) - .text_direction(self.text_direction) .return_word_box(self.return_word_box); if let Some(ref ort_config) = self.ort_session_config { diff --git a/src/oarocr/structure.rs b/src/oarocr/structure.rs index 02c8b70..a30fa5a 100644 --- a/src/oarocr/structure.rs +++ b/src/oarocr/structure.rs @@ -23,7 +23,7 @@ use oar_ocr_core::domain::adapters::{ use oar_ocr_core::domain::structure::{StructureResult, TableResult}; use oar_ocr_core::domain::tasks::{ FormulaRecognitionConfig, LayoutDetectionConfig, TableCellDetectionConfig, - TableClassificationConfig, TableStructureRecognitionConfig, TextDetectionConfig, TextDirection, + TableClassificationConfig, TableStructureRecognitionConfig, TextDetectionConfig, TextRecognitionConfig, }; use std::path::PathBuf; @@ -189,7 +189,6 @@ pub struct OARStructureBuilder { formula_recognition_config: Option, text_detection_config: Option, text_recognition_config: Option, - text_direction: TextDirection, // Batch sizes image_batch_size: Option, @@ -251,7 +250,6 @@ impl OARStructureBuilder { formula_recognition_config: None, text_detection_config: None, text_recognition_config: None, - text_direction: TextDirection::Ltr, image_batch_size: None, region_batch_size: None, } @@ -659,12 +657,6 @@ impl OARStructureBuilder { self } - /// Sets reading-direction post-processing for recognized text. - pub fn text_direction(mut self, direction: TextDirection) -> Self { - self.text_direction = direction; - self - } - /// Builds the structure analyzer runtime. /// /// This method instantiates all adapters and returns a ready-to-use structure analyzer. @@ -1254,9 +1246,7 @@ impl OARStructureBuilder { // Parse dict into Vec - one character per line let char_vec: Vec = dict.lines().map(|s| s.to_string()).collect(); - let mut builder = TextRecognitionAdapterBuilder::new() - .character_dict(char_vec) - .text_direction(self.text_direction); + let mut builder = TextRecognitionAdapterBuilder::new().character_dict(char_vec); if let Some(ref config) = self.text_recognition_config { builder = builder.with_config(config.clone());