Skip to main content

edgeparse_core/pdf/ocr/
mod.rs

1//! Pluggable raster OCR engines for table recovery.
2//!
3//! Dependency inversion: lattice / cell bucketing consume [`OcrWord`] lists;
4//! only the recognizer implementation changes.
5//!
6//! - [`TesseractCliEngine`] — default on native (Tesseract / RapidOCR CLI).
7//! - [`OcrsEngine`] — pure-Rust RTen models behind feature `ocr-ocrs`.
8//! - [`HostOcrEngine`] — wasm32 host callback (PP-OCRv6 etc.).
9
10#[cfg(all(target_arch = "wasm32", feature = "image"))]
11mod host_engine;
12#[cfg(feature = "ocr-ocrs")]
13mod ocrs_engine;
14#[cfg(not(target_arch = "wasm32"))]
15mod tesseract_cli;
16
17#[cfg(all(target_arch = "wasm32", feature = "image"))]
18pub use host_engine::{host_ocr_registered, register_host_ocr_backend, HostOcrEngine};
19#[cfg(feature = "ocr-ocrs")]
20pub use ocrs_engine::OcrsEngine;
21#[cfg(not(target_arch = "wasm32"))]
22pub(crate) use tesseract_cli::parse_tesseract_tsv;
23#[cfg(not(target_arch = "wasm32"))]
24pub use tesseract_cli::TesseractCliEngine;
25
26use image::GrayImage;
27use std::sync::Arc;
28
29/// One recognized word with axis-aligned box in image pixel space.
30#[derive(Debug, Clone)]
31pub struct OcrWord {
32    /// Layout grouping key (block, paragraph, line) — Tesseract TSV semantics.
33    pub line_key: (u32, u32, u32),
34    /// Left edge in pixels.
35    pub left: u32,
36    /// Top edge in pixels.
37    pub top: u32,
38    /// Width in pixels.
39    pub width: u32,
40    /// Height in pixels.
41    pub height: u32,
42    /// Recognized text.
43    pub text: String,
44    /// Engine confidence (0–100 scale preferred).
45    pub confidence: f64,
46}
47
48/// Raster OCR provider (SOLID: depend on this abstraction, not a CLI spawn).
49pub trait OcrEngine: Send + Sync {
50    /// Recognize words in a grayscale image (engine-default page segmentation).
51    fn recognize(&self, gray: &GrayImage) -> Vec<OcrWord>;
52
53    /// Optional PSM / layout hint for CLI engines; ignored by neural engines.
54    fn recognize_psm(&self, gray: &GrayImage, _psm: &str) -> Vec<OcrWord> {
55        self.recognize(gray)
56    }
57}
58
59/// Default engine for the current build / env.
60///
61/// Preference: host callback (wasm) → ocrs → Tesseract CLI (native) → null.
62/// When a host is registered but returns no words, ocrs is tried next.
63pub fn default_engine() -> Arc<dyn OcrEngine> {
64    #[cfg(all(target_arch = "wasm32", feature = "image"))]
65    {
66        if HostOcrEngine::try_new().is_some() {
67            #[cfg(feature = "ocr-ocrs")]
68            let secondary: Option<Arc<dyn OcrEngine>> =
69                OcrsEngine::try_from_env().map(|e| Arc::new(e) as Arc<dyn OcrEngine>);
70            #[cfg(not(feature = "ocr-ocrs"))]
71            let secondary: Option<Arc<dyn OcrEngine>> = None;
72            return Arc::new(FallbackOcrEngine {
73                primary: Arc::new(HostOcrEngine),
74                secondary,
75            });
76        }
77    }
78    #[cfg(feature = "ocr-ocrs")]
79    {
80        if let Some(engine) = OcrsEngine::try_from_env() {
81            return Arc::new(engine);
82        }
83    }
84    #[cfg(not(target_arch = "wasm32"))]
85    {
86        Arc::new(TesseractCliEngine::default())
87    }
88    #[cfg(target_arch = "wasm32")]
89    {
90        Arc::new(NullOcrEngine)
91    }
92}
93
94/// Try `primary`, then `secondary` if the first yields no words.
95#[cfg(all(target_arch = "wasm32", feature = "image"))]
96struct FallbackOcrEngine {
97    primary: Arc<dyn OcrEngine>,
98    secondary: Option<Arc<dyn OcrEngine>>,
99}
100
101#[cfg(all(target_arch = "wasm32", feature = "image"))]
102impl OcrEngine for FallbackOcrEngine {
103    fn recognize(&self, gray: &GrayImage) -> Vec<OcrWord> {
104        let words = self.primary.recognize(gray);
105        if !words.is_empty() {
106            return words;
107        }
108        if let Some(sec) = &self.secondary {
109            return sec.recognize(gray);
110        }
111        Vec::new()
112    }
113}
114
115#[cfg(target_arch = "wasm32")]
116struct NullOcrEngine;
117
118#[cfg(target_arch = "wasm32")]
119impl OcrEngine for NullOcrEngine {
120    fn recognize(&self, _gray: &GrayImage) -> Vec<OcrWord> {
121        Vec::new()
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    /// Deterministic engine for wiring tests (no CLI / models).
130    struct FakeOcrEngine;
131
132    impl OcrEngine for FakeOcrEngine {
133        fn recognize(&self, _gray: &GrayImage) -> Vec<OcrWord> {
134            vec![
135                OcrWord {
136                    line_key: (1, 1, 1),
137                    left: 10,
138                    top: 10,
139                    width: 80,
140                    height: 14,
141                    text: "Tube".into(),
142                    confidence: 90.0,
143                },
144                OcrWord {
145                    line_key: (1, 1, 1),
146                    left: 145,
147                    top: 10,
148                    width: 110,
149                    height: 14,
150                    text: "Enzyme".into(),
151                    confidence: 90.0,
152                },
153                OcrWord {
154                    line_key: (1, 1, 2),
155                    left: 10,
156                    top: 42,
157                    width: 80,
158                    height: 14,
159                    text: "1".into(),
160                    confidence: 90.0,
161                },
162                OcrWord {
163                    line_key: (1, 1, 2),
164                    left: 145,
165                    top: 42,
166                    width: 110,
167                    height: 14,
168                    text: "BamHI".into(),
169                    confidence: 90.0,
170                },
171            ]
172        }
173    }
174
175    #[test]
176    fn fake_engine_returns_columnar_words() {
177        let img = GrayImage::new(400, 100);
178        let words = FakeOcrEngine.recognize(&img);
179        assert_eq!(words.len(), 4);
180        assert_eq!(words[0].text, "Tube");
181        assert_eq!(words[1].text, "Enzyme");
182        let lefts: Vec<_> = words.iter().map(|w| w.left).collect();
183        assert!(lefts.contains(&10));
184        assert!(lefts.contains(&145));
185    }
186}