edgeparse_core/pdf/ocr/
tesseract_cli.rs1use std::env;
4use std::fs;
5use std::path::PathBuf;
6use std::process::Command;
7use std::sync::OnceLock;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use image::GrayImage;
11use serde::Deserialize;
12
13use super::{OcrEngine, OcrWord};
14
15const TESSERACT_EFFECTIVE_DPI: u32 = 300;
16const MIN_OCR_WORD_CONFIDENCE: f64 = 6.0;
17const MAX_OCR_WORD_CONFIDENCE: f64 = 101.0;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20enum CliOcrKind {
21 Tesseract,
22 RapidOcr,
23}
24
25#[derive(Debug, Deserialize)]
26struct RapidOcrLine {
27 left: u32,
28 top: u32,
29 width: u32,
30 height: u32,
31 text: String,
32 confidence: f64,
33}
34
35static CLI_KIND: OnceLock<CliOcrKind> = OnceLock::new();
36static RAPIDOCR_PYTHON: OnceLock<Option<String>> = OnceLock::new();
37
38const RAPIDOCR_RUNNER: &str = r#"
39import json, sys
40from rapidocr import RapidOCR
41
42engine = RapidOCR()
43result = engine(sys.argv[1], use_det=True, use_cls=True, use_rec=True)
44
45if result is None:
46 print('[]')
47 raise SystemExit(0)
48
49boxes = getattr(result, 'boxes', []) or []
50txts = getattr(result, 'txts', []) or []
51scores = getattr(result, 'scores', []) or []
52out = []
53for box, text, score in zip(boxes, txts, scores):
54 if not text or not str(text).strip():
55 continue
56 xs = [pt[0] for pt in box]
57 ys = [pt[1] for pt in box]
58 out.append({
59 'left': int(min(xs)),
60 'top': int(min(ys)),
61 'width': max(1, int(max(xs) - min(xs))),
62 'height': max(1, int(max(ys) - min(ys))),
63 'text': str(text),
64 'confidence': float(score),
65 })
66print(json.dumps(out, ensure_ascii=False))
67"#;
68
69#[derive(Debug, Clone)]
71pub struct TesseractCliEngine {
72 pub psm: String,
74 pub oem: String,
76}
77
78impl Default for TesseractCliEngine {
79 fn default() -> Self {
80 Self {
81 psm: "6".into(),
82 oem: "3".into(),
83 }
84 }
85}
86
87impl TesseractCliEngine {
88 pub fn with_psm(psm: impl Into<String>) -> Self {
90 Self {
91 psm: psm.into(),
92 oem: "3".into(),
93 }
94 }
95}
96
97impl OcrEngine for TesseractCliEngine {
98 fn recognize(&self, gray: &GrayImage) -> Vec<OcrWord> {
99 self.recognize_psm(gray, &self.psm)
100 }
101
102 fn recognize_psm(&self, gray: &GrayImage, psm: &str) -> Vec<OcrWord> {
103 match selected_cli_kind() {
104 CliOcrKind::RapidOcr => run_rapidocr_words(gray).unwrap_or_default(),
105 CliOcrKind::Tesseract => run_tesseract_tsv(gray, psm, &self.oem).unwrap_or_default(),
106 }
107 }
108}
109
110fn selected_cli_kind() -> CliOcrKind {
111 *CLI_KIND.get_or_init(|| match env::var("EDGEPARSE_OCR_ENGINE") {
112 Ok(value) => match value.to_ascii_lowercase().as_str() {
113 "rapidocr" if rapidocr_python_command().is_some() => CliOcrKind::RapidOcr,
114 _ => CliOcrKind::Tesseract,
115 },
116 Err(_) => CliOcrKind::Tesseract,
117 })
118}
119
120fn rapidocr_python_command() -> Option<&'static str> {
121 RAPIDOCR_PYTHON
122 .get_or_init(|| {
123 let preferred = env::var("EDGEPARSE_OCR_PYTHON").ok();
124 let mut candidates = Vec::new();
125 if let Some(cmd) = preferred {
126 candidates.push(cmd);
127 }
128 candidates.push("python3".to_string());
129 candidates.push("python".to_string());
130
131 for candidate in candidates {
132 let ok = Command::new(&candidate)
133 .arg("-c")
134 .arg("import rapidocr")
135 .output()
136 .ok()
137 .is_some_and(|out| out.status.success());
138 if ok {
139 return Some(candidate);
140 }
141 }
142 None
143 })
144 .as_deref()
145}
146
147fn create_temp_dir() -> std::io::Result<PathBuf> {
148 let unique = SystemTime::now()
149 .duration_since(UNIX_EPOCH)
150 .unwrap_or_default()
151 .as_nanos();
152 let dir = std::env::temp_dir().join(format!(
153 "edgeparse-ocr-cli-{}-{}",
154 std::process::id(),
155 unique
156 ));
157 fs::create_dir_all(&dir)?;
158 Ok(dir)
159}
160
161fn run_tesseract_tsv(image: &GrayImage, psm: &str, oem: &str) -> Option<Vec<OcrWord>> {
162 let temp_dir = create_temp_dir().ok()?;
163 let image_path = temp_dir.join("ocr.png");
164 if image.save(&image_path).is_err() {
165 let _ = fs::remove_dir_all(&temp_dir);
166 return None;
167 }
168
169 let dpi = TESSERACT_EFFECTIVE_DPI.to_string();
170 let output = Command::new("tesseract")
171 .current_dir(&temp_dir)
172 .arg("ocr.png")
173 .arg("stdout")
174 .arg("--dpi")
175 .arg(&dpi)
176 .arg("--oem")
177 .arg(oem)
178 .arg("--psm")
179 .arg(psm)
180 .arg("-c")
181 .arg("load_system_dawg=0")
182 .arg("-c")
183 .arg("load_freq_dawg=0")
184 .arg("tsv")
185 .output()
186 .ok()?;
187 let _ = fs::remove_dir_all(&temp_dir);
188 if !output.status.success() {
189 return None;
190 }
191
192 let tsv = String::from_utf8_lossy(&output.stdout);
193 Some(parse_tesseract_tsv(&tsv))
194}
195
196pub(crate) fn parse_tesseract_tsv(tsv: &str) -> Vec<OcrWord> {
197 let mut words = Vec::new();
198 for line in tsv.lines().skip(1) {
199 let mut cols = line.splitn(12, '\t');
200 let level = cols.next().and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
201 if level != 5 {
202 continue;
203 }
204 let _page_num = cols.next();
205 let block_num = cols.next().and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
206 let par_num = cols.next().and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
207 let line_num = cols.next().and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
208 let _word_num = cols.next();
209 let left = cols.next().and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
210 let top = cols.next().and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
211 let width = cols.next().and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
212 let height = cols.next().and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
213 let confidence = cols
214 .next()
215 .and_then(|s| s.parse::<f64>().ok())
216 .unwrap_or(-1.0);
217 let text = cols.next().unwrap_or("").trim().to_string();
218 if !(MIN_OCR_WORD_CONFIDENCE..=MAX_OCR_WORD_CONFIDENCE).contains(&confidence)
219 || text.is_empty()
220 || width == 0
221 || height == 0
222 || !text.chars().any(|ch| ch.is_alphanumeric())
223 {
224 continue;
225 }
226 words.push(OcrWord {
227 line_key: (block_num, par_num, line_num),
228 left,
229 top,
230 width,
231 height,
232 text,
233 confidence,
234 });
235 }
236 words
237}
238
239fn rapidocr_lines_to_words(lines: Vec<RapidOcrLine>) -> Vec<OcrWord> {
240 let mut words = Vec::new();
241
242 for (line_idx, line) in lines.into_iter().enumerate() {
243 let tokens: Vec<&str> = line.text.split_whitespace().collect();
244 if tokens.is_empty() {
245 continue;
246 }
247
248 let total_chars: u32 = tokens
249 .iter()
250 .map(|token| token.chars().count() as u32)
251 .sum();
252 if total_chars == 0 {
253 continue;
254 }
255
256 let mut cursor = line.left;
257 let mut remaining_width = line.width.max(tokens.len() as u32);
258 let mut remaining_chars = total_chars;
259
260 for (token_idx, token) in tokens.iter().enumerate() {
261 let token_chars = token.chars().count() as u32;
262 let width = if token_idx == tokens.len() - 1 || remaining_chars <= token_chars {
263 remaining_width.max(1)
264 } else {
265 let proportional = ((remaining_width as f64) * (token_chars as f64)
266 / (remaining_chars as f64))
267 .round() as u32;
268 proportional.max(1).min(remaining_width)
269 };
270
271 words.push(OcrWord {
272 line_key: (0, line_idx as u32, 0),
273 left: cursor,
274 top: line.top,
275 width,
276 height: line.height.max(1),
277 text: (*token).to_string(),
278 confidence: line.confidence,
279 });
280
281 cursor = cursor.saturating_add(width);
282 remaining_width = remaining_width.saturating_sub(width);
283 remaining_chars = remaining_chars.saturating_sub(token_chars);
284 }
285 }
286
287 words
288}
289
290fn run_rapidocr_words(image: &GrayImage) -> Option<Vec<OcrWord>> {
291 let python = rapidocr_python_command()?;
292 let temp_dir = create_temp_dir().ok()?;
293 let image_path = temp_dir.join("ocr.png");
294 if image.save(&image_path).is_err() {
295 let _ = fs::remove_dir_all(&temp_dir);
296 return None;
297 }
298
299 let output = Command::new(python)
300 .current_dir(&temp_dir)
301 .arg("-c")
302 .arg(RAPIDOCR_RUNNER)
303 .arg("ocr.png")
304 .output()
305 .ok()?;
306 let _ = fs::remove_dir_all(&temp_dir);
307 if !output.status.success() {
308 return None;
309 }
310
311 let json = String::from_utf8_lossy(&output.stdout);
312 let lines: Vec<RapidOcrLine> = serde_json::from_str(&json).ok()?;
313 let words = rapidocr_lines_to_words(lines);
314 (!words.is_empty()).then_some(words)
315}