1use std::collections::{BTreeMap, HashMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use image::{GenericImageView, GrayImage, Luma};
10
11use crate::models::bbox::BoundingBox;
12use crate::models::chunks::{ImageChunk, TextChunk};
13use crate::models::content::ContentElement;
14use crate::models::enums::{PdfLayer, TextFormat, TextType};
15use crate::models::table::{
16 TableBorder, TableBorderCell, TableBorderRow, TableToken, TableTokenType,
17};
18use crate::pdf::ocr::{parse_tesseract_tsv, OcrEngine, OcrWord, TesseractCliEngine};
19
20const MIN_IMAGE_WIDTH_RATIO: f64 = 0.40;
22const MIN_IMAGE_AREA_RATIO: f64 = 0.035;
23const MAX_NATIVE_TEXT_CHARS_IN_IMAGE: usize = 250;
24const MAX_NATIVE_TEXT_CHUNKS_IN_IMAGE: usize = 12;
25const RASTER_DARK_THRESHOLD: u8 = 180;
29const RASTER_CHART_INK_THRESHOLD: u8 = 240;
30const MIN_BORDERED_VERTICAL_LINES: usize = 3;
31const MIN_BORDERED_HORIZONTAL_LINES: usize = 3;
32const MIN_LINE_DARK_RATIO: f64 = 0.28;
34const MIN_CELL_SIZE_PX: u32 = 10;
35const CELL_INSET_PX: u32 = 5;
36const TABLE_RASTER_OCR_BORDER_PX: u32 = 14;
37const PDFTOPPM_DPI: u32 = 150;
42const OCR_SCALE_FACTOR: u32 = 2;
43#[allow(dead_code)] const TESSERACT_EFFECTIVE_DPI: u32 = PDFTOPPM_DPI * OCR_SCALE_FACTOR;
46const MIN_DOMINANT_IMAGE_WIDTH_RATIO: f64 = 0.65;
47const MIN_DOMINANT_IMAGE_AREA_RATIO: f64 = 0.40;
48const MAX_NATIVE_TEXT_CHARS_IN_DOMINANT_IMAGE: usize = 80;
49const MIN_DOMINANT_IMAGE_OCR_WORDS: usize = 18;
50const MIN_DOMINANT_IMAGE_TEXT_LINES: usize = 6;
51const MIN_DENSE_PROSE_BLOCK_LINES: usize = 3;
52const MIN_DENSE_PROSE_BLOCK_WIDTH_RATIO: f64 = 0.32;
53const MIN_TRUE_GRID_LINE_CONTINUITY: f64 = 0.60;
55const MAX_NATIVE_TEXT_CHARS_FOR_PAGE_RASTER_OCR: usize = 180;
56const MIN_EMPTY_TABLE_COVERAGE_FOR_PAGE_RASTER_OCR: f64 = 0.08;
57const MAX_EMPTY_TABLES_FOR_PAGE_RASTER_OCR: usize = 24;
58const LOCAL_BINARIZATION_RADIUS: u32 = 14;
59const MIN_BINARIZATION_BLOCK_PIXELS: usize = 81;
60const MIN_RASTER_TABLE_TEXT_CELL_RATIO: f64 = 0.05;
62const MIN_RASTER_TABLE_ROWS_WITH_TEXT: usize = 1;
63const MIN_NUMERIC_TABLE_MEDIAN_FILL_RATIO: f64 = 0.40;
64const MIN_BORDERED_CELL_DARK_RATIO: f64 = 0.03;
65const MIN_BORDERED_INKED_CELL_RATIO: f64 = 0.18;
66const MIN_BORDERED_ROWS_WITH_INK: usize = 2;
67const MAX_BORDERED_TABLE_PER_CELL_FALLBACK_CELLS: usize = 48;
69const MIN_BRIGHT_PHOTO_MID_TONE_RATIO: f64 = 0.24;
70const MIN_BRIGHT_PHOTO_HISTOGRAM_BINS: usize = 8;
71const MIN_BRIGHT_PHOTO_ENTROPY: f64 = 1.6;
72
73#[derive(Debug, Clone)]
74struct XCluster {
75 center: f64,
76 count: usize,
77 lines: HashSet<(u32, u32, u32)>,
78}
79
80#[derive(Clone)]
81struct OcrRowBuild {
82 top_y: f64,
83 bottom_y: f64,
84 cell_texts: Vec<String>,
85}
86
87#[derive(Debug, Clone)]
88struct EmptyCellRaster {
89 row_idx: usize,
90 cell_idx: usize,
91 x1: u32,
92 y1: u32,
93 x2: u32,
94 y2: u32,
95}
96
97#[derive(Debug, Clone)]
98struct RasterTableGrid {
99 vertical_lines: Vec<u32>,
100 horizontal_lines: Vec<u32>,
101}
102
103#[derive(Debug, Clone)]
104struct OcrCandidateScore {
105 words: Vec<OcrWord>,
106 score: f64,
107}
108
109#[derive(Debug, Clone)]
110struct PdfImagesListEntry {
111 image_type: String,
112}
113
114pub fn recover_raster_table_text_chunks(
116 input_path: &Path,
117 page_bbox: &BoundingBox,
118 page_number: u32,
119 text_chunks: &[TextChunk],
120 image_chunks: &[ImageChunk],
121) -> Vec<TextChunk> {
122 if page_bbox.area() <= 0.0 || image_chunks.is_empty() {
123 return Vec::new();
124 }
125
126 let candidates: Vec<&ImageChunk> = image_chunks
127 .iter()
128 .filter(|image| is_ocr_candidate(image, page_bbox, text_chunks))
129 .collect();
130 if candidates.is_empty() {
131 return Vec::new();
132 }
133
134 let temp_dir = match create_temp_dir(page_number) {
135 Ok(dir) => dir,
136 Err(_) => return Vec::new(),
137 };
138
139 let result =
140 recover_from_page_images(input_path, &temp_dir, page_number, candidates, text_chunks);
141
142 let _ = fs::remove_dir_all(&temp_dir);
143 result
144}
145
146pub fn recover_dominant_image_text_chunks(
153 input_path: &Path,
154 page_bbox: &BoundingBox,
155 page_number: u32,
156 text_chunks: &[TextChunk],
157 image_chunks: &[ImageChunk],
158) -> Vec<TextChunk> {
159 if page_bbox.area() <= 0.0 || image_chunks.is_empty() {
160 return Vec::new();
161 }
162
163 let candidates: Vec<&ImageChunk> = image_chunks
164 .iter()
165 .filter(|image| is_dominant_image_text_candidate(image, page_bbox, text_chunks))
166 .collect();
167 if candidates.is_empty() {
168 return Vec::new();
169 }
170
171 let temp_dir = match create_temp_dir(page_number) {
172 Ok(dir) => dir,
173 Err(_) => return Vec::new(),
174 };
175
176 let image_files = match extract_visible_page_image_files(input_path, page_number, &temp_dir) {
177 Some(files) => files,
178 None => {
179 let _ = fs::remove_dir_all(&temp_dir);
180 return Vec::new();
181 }
182 };
183
184 let mut recovered = Vec::new();
185 for image in candidates {
186 let Some(image_index) = image.index else {
187 continue;
188 };
189 let Some(image_path) = image_files.get(image_index.saturating_sub(1) as usize) else {
190 continue;
191 };
192 let Ok(gray) = image::open(image_path).map(|img| img.to_luma8()) else {
193 continue;
194 };
195 if recover_bordered_raster_table_from_gray(&gray, image).is_some()
196 || is_obvious_bar_chart_raster(&gray)
197 || is_natural_photograph_raster(&gray)
198 || is_dark_ui_screenshot_raster(&gray)
199 {
200 continue;
201 }
202
203 let Some(words) = run_tesseract_tsv_words_best(&gray, &["11", "6"], |candidate| {
204 looks_like_dense_prose_image_ocr(candidate)
205 }) else {
206 continue;
207 };
208
209 recovered.extend(lines_from_ocr_words(
210 &words,
211 image,
212 gray.width(),
213 gray.height(),
214 text_chunks,
215 ));
216 }
217
218 let _ = fs::remove_dir_all(&temp_dir);
219 recovered
220}
221
222pub fn recover_raster_table_borders(
224 input_path: &Path,
225 page_bbox: &BoundingBox,
226 page_number: u32,
227 text_chunks: &[TextChunk],
228 image_chunks: &[ImageChunk],
229) -> Vec<TableBorder> {
230 if page_bbox.area() <= 0.0 || image_chunks.is_empty() {
231 return Vec::new();
232 }
233
234 let candidates: Vec<&ImageChunk> = image_chunks
235 .iter()
236 .filter(|image| is_ocr_candidate(image, page_bbox, text_chunks))
237 .collect();
238 if candidates.is_empty() {
239 return Vec::new();
240 }
241
242 let temp_dir = match create_temp_dir(page_number) {
243 Ok(dir) => dir,
244 Err(_) => return Vec::new(),
245 };
246
247 let image_files = match extract_visible_page_image_files(input_path, page_number, &temp_dir) {
248 Some(files) => files,
249 None => {
250 let _ = fs::remove_dir_all(&temp_dir);
251 return Vec::new();
252 }
253 };
254
255 let mut tables = Vec::new();
256 for image in candidates {
257 let gray = match resolve_candidate_gray(image, &image_files) {
258 Some(g) => g,
259 None => continue,
260 };
261 if let Some(table) = recover_bordered_raster_table_from_gray(&gray, image) {
264 if bordered_raster_table_is_plausible(&table) {
265 tables.push(table);
266 continue;
267 }
268 }
269 if is_obvious_bar_chart_raster(&gray)
270 || is_natural_photograph_raster(&gray)
271 || is_dark_ui_screenshot_raster(&gray)
272 {
273 continue;
274 }
275 let Some(words) = run_tesseract_tsv_words_best(&gray, &["6", "11"], |candidate| {
276 looks_like_table_ocr(candidate)
277 }) else {
278 continue;
279 };
280
281 if looks_like_numeric_table_ocr(&words) {
282 if let Some(table) = build_numeric_table_border(&words, image) {
283 if is_matrixish_ocr_artifact_table(&table) {
284 continue;
285 }
286 tables.push(table);
287 continue;
288 }
289 }
290
291 if let Some(table) = build_structured_ocr_table_border(&words, image) {
292 if is_matrixish_ocr_artifact_table(&table) {
293 continue;
294 }
295 tables.push(table);
296 }
297 }
298
299 let _ = fs::remove_dir_all(&temp_dir);
300 tables
301}
302
303pub fn recover_page_raster_table_cell_text(
309 input_path: &Path,
310 page_bbox: &BoundingBox,
311 page_number: u32,
312 elements: &mut [ContentElement],
313) {
314 if page_bbox.area() <= 0.0 {
315 return;
316 }
317
318 let native_text_chars = page_native_text_chars(elements);
319
320 let candidate_indices: Vec<usize> = elements
321 .iter()
322 .enumerate()
323 .filter_map(|(idx, elem)| {
324 let table = table_candidate_ref(elem)?;
325 let local_text_chars = native_text_chars_in_region(elements, &table.bbox);
326 if !table_needs_page_raster_ocr(table) {
327 return None;
328 }
329 if native_text_chars > MAX_NATIVE_TEXT_CHARS_FOR_PAGE_RASTER_OCR
330 && local_text_chars > MAX_NATIVE_TEXT_CHARS_FOR_PAGE_RASTER_OCR
331 {
332 return None;
333 }
334 Some(idx)
335 })
336 .take(MAX_EMPTY_TABLES_FOR_PAGE_RASTER_OCR)
337 .collect();
338 if candidate_indices.is_empty() {
339 return;
340 }
341
342 let coverage: f64 = candidate_indices
343 .iter()
344 .filter_map(|idx| table_candidate_ref(&elements[*idx]).map(|table| table.bbox.area()))
345 .sum::<f64>()
346 / page_bbox.area().max(1.0);
347 if coverage < MIN_EMPTY_TABLE_COVERAGE_FOR_PAGE_RASTER_OCR {
348 return;
349 }
350
351 let temp_dir = match create_temp_dir(page_number) {
352 Ok(dir) => dir,
353 Err(_) => return,
354 };
355 let prefix = temp_dir.join("page");
356 let status = Command::new("pdftoppm")
357 .arg("-png")
358 .arg("-f")
359 .arg(page_number.to_string())
360 .arg("-l")
361 .arg(page_number.to_string())
362 .arg("-singlefile")
363 .arg(input_path)
364 .arg(&prefix)
365 .status();
366 match status {
367 Ok(s) if s.success() => {}
368 _ => {
369 let _ = fs::remove_dir_all(&temp_dir);
370 return;
371 }
372 }
373
374 let page_image_path = prefix.with_extension("png");
375 let gray = match image::open(&page_image_path) {
376 Ok(img) => img.to_luma8(),
377 Err(_) => {
378 let _ = fs::remove_dir_all(&temp_dir);
379 return;
380 }
381 };
382
383 for idx in candidate_indices {
384 let Some(elem) = elements.get_mut(idx) else {
385 continue;
386 };
387 let Some(table) = table_candidate_mut(elem) else {
388 continue;
389 };
390 enrich_empty_table_from_page_raster(&gray, page_bbox, table);
391 }
392
393 let _ = fs::remove_dir_all(&temp_dir);
394}
395
396fn table_candidate_ref(elem: &ContentElement) -> Option<&TableBorder> {
397 match elem {
398 ContentElement::TableBorder(table) => Some(table),
399 ContentElement::Table(table) => Some(&table.table_border),
400 _ => None,
401 }
402}
403
404fn table_candidate_mut(elem: &mut ContentElement) -> Option<&mut TableBorder> {
405 match elem {
406 ContentElement::TableBorder(table) => Some(table),
407 ContentElement::Table(table) => Some(&mut table.table_border),
408 _ => None,
409 }
410}
411
412fn page_native_text_chars(elements: &[ContentElement]) -> usize {
413 native_text_chars_in_region(
414 elements,
415 &BoundingBox::new(None, f64::MIN, f64::MIN, f64::MAX, f64::MAX),
416 )
417}
418
419fn native_text_chars_in_region(elements: &[ContentElement], region: &BoundingBox) -> usize {
420 elements
421 .iter()
422 .filter(|elem| region.overlaps(elem.bbox()))
423 .map(|elem| match elem {
424 ContentElement::Paragraph(p) => p.base.value().chars().count(),
425 ContentElement::Heading(h) => h.base.base.value().chars().count(),
426 ContentElement::NumberHeading(h) => h.base.base.base.value().chars().count(),
427 ContentElement::TextBlock(tb) => tb.value().chars().count(),
428 ContentElement::TextLine(tl) => tl.value().chars().count(),
429 ContentElement::TextChunk(tc) => tc.value.chars().count(),
430 ContentElement::List(list) => list
431 .list_items
432 .iter()
433 .flat_map(|item| item.contents.iter())
434 .map(|content| match content {
435 ContentElement::Paragraph(p) => p.base.value().chars().count(),
436 ContentElement::TextBlock(tb) => tb.value().chars().count(),
437 ContentElement::TextLine(tl) => tl.value().chars().count(),
438 ContentElement::TextChunk(tc) => tc.value.chars().count(),
439 _ => 0,
440 })
441 .sum(),
442 _ => 0,
443 })
444 .sum()
445}
446
447fn recover_from_page_images(
448 input_path: &Path,
449 temp_dir: &Path,
450 page_number: u32,
451 candidates: Vec<&ImageChunk>,
452 text_chunks: &[TextChunk],
453) -> Vec<TextChunk> {
454 let image_files = match extract_visible_page_image_files(input_path, page_number, temp_dir) {
455 Some(files) => files,
456 None => return Vec::new(),
457 };
458 if image_files.is_empty() {
459 return Vec::new();
460 }
461
462 let mut recovered = Vec::new();
463 for image in candidates {
464 let Some(image_index) = image.index else {
465 continue;
466 };
467 let Some(image_path) = image_files.get(image_index.saturating_sub(1) as usize) else {
468 continue;
469 };
470 let bordered_table = recover_bordered_raster_table(image_path, image);
471 if let Some(caption) = recover_bordered_raster_caption(image_path, image) {
472 recovered.push(caption);
473 }
474 if bordered_table.is_some() {
475 continue;
476 }
477 let Some(file_name) = image_path.file_name().and_then(|name| name.to_str()) else {
478 continue;
479 };
480 let native_dpi = PDFTOPPM_DPI.to_string();
484 let Ok(tsv_output) = Command::new("tesseract")
485 .current_dir(temp_dir)
486 .arg(file_name)
487 .arg("stdout")
488 .arg("--dpi")
489 .arg(&native_dpi)
490 .arg("--psm")
491 .arg("6")
492 .arg("-c")
493 .arg("load_system_dawg=0")
494 .arg("-c")
495 .arg("load_freq_dawg=0")
496 .arg("tsv")
497 .output()
498 else {
499 continue;
500 };
501 if !tsv_output.status.success() {
502 continue;
503 }
504
505 let tsv = String::from_utf8_lossy(&tsv_output.stdout);
506 let words = parse_tesseract_tsv(&tsv);
507 if !looks_like_table_ocr(&words) {
508 continue;
509 }
510
511 recovered.extend(words_to_text_chunks(&words, image, text_chunks));
512 }
513
514 recovered
515}
516
517fn table_needs_page_raster_ocr(table: &TableBorder) -> bool {
518 if table.num_rows < 1 || table.num_columns < 2 {
519 return false;
520 }
521
522 let total_cells = table.rows.iter().map(|row| row.cells.len()).sum::<usize>();
523 if total_cells == 0 {
524 return false;
525 }
526
527 let text_cells = table_text_cell_count(table);
528 let text_cell_ratio = text_cells as f64 / total_cells as f64;
529 text_cells == 0 || text_cell_ratio < MIN_RASTER_TABLE_TEXT_CELL_RATIO
530}
531
532fn table_text_cell_count(table: &TableBorder) -> usize {
533 table
534 .rows
535 .iter()
536 .flat_map(|row| row.cells.iter())
537 .filter(|cell| cell_has_substantive_text(cell))
538 .count()
539}
540
541fn cell_has_substantive_text(cell: &TableBorderCell) -> bool {
542 let has_token_text = cell.content.iter().any(|token| {
543 matches!(token.token_type, TableTokenType::Text)
544 && token.base.value.chars().any(|ch| ch.is_alphanumeric())
545 });
546 if has_token_text {
547 return true;
548 }
549
550 cell.contents.iter().any(|elem| match elem {
551 ContentElement::Paragraph(p) => p.base.value().chars().any(|ch| ch.is_alphanumeric()),
552 ContentElement::Heading(h) => h.base.base.value().chars().any(|ch| ch.is_alphanumeric()),
553 ContentElement::NumberHeading(h) => h
554 .base
555 .base
556 .base
557 .value()
558 .chars()
559 .any(|ch| ch.is_alphanumeric()),
560 ContentElement::TextBlock(tb) => tb.value().chars().any(|ch| ch.is_alphanumeric()),
561 ContentElement::TextLine(tl) => tl.value().chars().any(|ch| ch.is_alphanumeric()),
562 ContentElement::TextChunk(tc) => tc.value.chars().any(|ch| ch.is_alphanumeric()),
563 _ => false,
564 })
565}
566
567fn enrich_empty_table_from_page_raster(
568 gray: &GrayImage,
569 page_bbox: &BoundingBox,
570 table: &mut TableBorder,
571) {
572 let mut empty_cells: Vec<EmptyCellRaster> = Vec::new();
575 for (row_idx, row) in table.rows.iter().enumerate() {
576 for (cell_idx, cell) in row.cells.iter().enumerate() {
577 if cell
578 .content
579 .iter()
580 .any(|token| matches!(token.token_type, TableTokenType::Text))
581 {
582 continue;
583 }
584 let Some((x1, y1, x2, y2)) = page_bbox_to_raster_box(gray, page_bbox, &cell.bbox)
585 else {
586 continue;
587 };
588 empty_cells.push(EmptyCellRaster {
589 row_idx,
590 cell_idx,
591 x1,
592 y1,
593 x2,
594 y2,
595 });
596 }
597 }
598 if empty_cells.is_empty() {
599 return;
600 }
601
602 let Some((tx1, ty1, tx2, ty2)) = page_bbox_to_raster_box(gray, page_bbox, &table.bbox) else {
604 fill_cells_with_per_cell_ocr(gray, table, &empty_cells);
605 return;
606 };
607
608 let pad = CELL_INSET_PX * 2;
609 let crop_left = tx1.saturating_sub(pad);
610 let crop_top = ty1.saturating_sub(pad);
611 let crop_right = (tx2 + pad).min(gray.width());
612 let crop_bottom = (ty2 + pad).min(gray.height());
613 if crop_right <= crop_left || crop_bottom <= crop_top {
614 fill_cells_with_per_cell_ocr(gray, table, &empty_cells);
615 return;
616 }
617
618 let crop_width = crop_right - crop_left;
619 let crop_height = crop_bottom - crop_top;
620 if crop_width < MIN_CELL_SIZE_PX || crop_height < MIN_CELL_SIZE_PX {
621 fill_cells_with_per_cell_ocr(gray, table, &empty_cells);
622 return;
623 }
624
625 let cropped = gray
626 .view(crop_left, crop_top, crop_width, crop_height)
627 .to_image();
628 let is_bar_chart = is_obvious_bar_chart_raster(&cropped);
629 let is_photo = is_natural_photograph_raster(&cropped);
630 let is_ui = is_dark_ui_screenshot_raster(&cropped);
631 if is_bar_chart || is_photo || is_ui {
632 return;
633 }
634 let bordered = expand_white_border(&cropped, TABLE_RASTER_OCR_BORDER_PX);
635 let scaled = image::imageops::resize(
636 &bordered,
637 bordered.width() * OCR_SCALE_FACTOR,
638 bordered.height() * OCR_SCALE_FACTOR,
639 image::imageops::FilterType::Lanczos3,
640 );
641
642 let Some(words) = run_tesseract_tsv_words(&scaled, "6") else {
643 fill_cells_with_per_cell_ocr(gray, table, &empty_cells);
644 return;
645 };
646 if words.is_empty() {
647 fill_cells_with_per_cell_ocr(gray, table, &empty_cells);
648 return;
649 }
650 let chart_like = looks_like_chart_label_ocr(&words);
651 if chart_like {
652 return;
653 }
654
655 let mut buckets: Vec<Vec<(u32, u32, String)>> = vec![Vec::new(); empty_cells.len()];
656 let scale = f64::from(OCR_SCALE_FACTOR);
657 let border = f64::from(TABLE_RASTER_OCR_BORDER_PX);
658
659 for word in &words {
660 let cx_scaled = f64::from(word.left) + f64::from(word.width) / 2.0;
661 let cy_scaled = f64::from(word.top) + f64::from(word.height) / 2.0;
662
663 let cx_crop = cx_scaled / scale - border;
664 let cy_crop = cy_scaled / scale - border;
665 if cx_crop < 0.0 || cy_crop < 0.0 {
666 continue;
667 }
668
669 let cx_page = match u32::try_from(cx_crop.round() as i64) {
670 Ok(v) => crop_left.saturating_add(v),
671 Err(_) => continue,
672 };
673 let cy_page = match u32::try_from(cy_crop.round() as i64) {
674 Ok(v) => crop_top.saturating_add(v),
675 Err(_) => continue,
676 };
677
678 for (idx, cell) in empty_cells.iter().enumerate() {
679 if cx_page >= cell.x1 && cx_page < cell.x2 && cy_page >= cell.y1 && cy_page < cell.y2 {
680 buckets[idx].push((cy_page, cx_page, word.text.clone()));
681 break;
682 }
683 }
684 }
685
686 for (idx, cell) in empty_cells.iter().enumerate() {
687 let Some(row) = table.rows.get_mut(cell.row_idx) else {
688 continue;
689 };
690 let Some(target) = row.cells.get_mut(cell.cell_idx) else {
691 continue;
692 };
693 if target
694 .content
695 .iter()
696 .any(|token| matches!(token.token_type, TableTokenType::Text))
697 {
698 continue;
699 }
700 let mut parts = std::mem::take(&mut buckets[idx]);
701 if parts.is_empty() {
702 continue;
703 }
704 parts.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
705 let raw = parts
706 .into_iter()
707 .map(|(_, _, t)| t)
708 .collect::<Vec<_>>()
709 .join(" ");
710 let text = normalize_page_raster_cell_text(&target.bbox, raw);
711 if text.is_empty() {
712 continue;
713 }
714 target.content.push(TableToken {
715 base: TextChunk {
716 value: text,
717 bbox: target.bbox.clone(),
718 font_name: "OCR".to_string(),
719 font_size: target.bbox.height().max(6.0),
720 font_weight: 400.0,
721 italic_angle: 0.0,
722 font_color: "#000000".to_string(),
723 contrast_ratio: 21.0,
724 symbol_ends: Vec::new(),
725 text_format: TextFormat::Normal,
726 text_type: TextType::Regular,
727 pdf_layer: PdfLayer::Content,
728 ocg_visible: true,
729 index: None,
730 page_number: target.bbox.page_number,
731 level: None,
732 mcid: None,
733 },
734 token_type: TableTokenType::Text,
735 });
736 }
737}
738
739fn fill_cells_with_per_cell_ocr(
740 gray: &GrayImage,
741 table: &mut TableBorder,
742 empty_cells: &[EmptyCellRaster],
743) {
744 for cell in empty_cells {
745 let Some(row) = table.rows.get_mut(cell.row_idx) else {
746 continue;
747 };
748 let Some(target) = row.cells.get_mut(cell.cell_idx) else {
749 continue;
750 };
751 if target
752 .content
753 .iter()
754 .any(|token| matches!(token.token_type, TableTokenType::Text))
755 {
756 continue;
757 }
758 let Some(text) =
759 extract_page_raster_cell_text(gray, &target.bbox, cell.x1, cell.y1, cell.x2, cell.y2)
760 else {
761 continue;
762 };
763 if text.is_empty() {
764 continue;
765 }
766 target.content.push(TableToken {
767 base: TextChunk {
768 value: text,
769 bbox: target.bbox.clone(),
770 font_name: "OCR".to_string(),
771 font_size: target.bbox.height().max(6.0),
772 font_weight: 400.0,
773 italic_angle: 0.0,
774 font_color: "#000000".to_string(),
775 contrast_ratio: 21.0,
776 symbol_ends: Vec::new(),
777 text_format: TextFormat::Normal,
778 text_type: TextType::Regular,
779 pdf_layer: PdfLayer::Content,
780 ocg_visible: true,
781 index: None,
782 page_number: target.bbox.page_number,
783 level: None,
784 mcid: None,
785 },
786 token_type: TableTokenType::Text,
787 });
788 }
789}
790
791fn page_bbox_to_raster_box(
792 gray: &GrayImage,
793 page_bbox: &BoundingBox,
794 bbox: &BoundingBox,
795) -> Option<(u32, u32, u32, u32)> {
796 if page_bbox.width() <= 0.0 || page_bbox.height() <= 0.0 {
797 return None;
798 }
799
800 let left = ((bbox.left_x - page_bbox.left_x) / page_bbox.width() * f64::from(gray.width()))
801 .clamp(0.0, f64::from(gray.width()));
802 let right = ((bbox.right_x - page_bbox.left_x) / page_bbox.width() * f64::from(gray.width()))
803 .clamp(0.0, f64::from(gray.width()));
804 let top = ((page_bbox.top_y - bbox.top_y) / page_bbox.height() * f64::from(gray.height()))
805 .clamp(0.0, f64::from(gray.height()));
806 let bottom = ((page_bbox.top_y - bbox.bottom_y) / page_bbox.height()
807 * f64::from(gray.height()))
808 .clamp(0.0, f64::from(gray.height()));
809
810 let x1 = left.floor() as u32;
811 let x2 = right.ceil() as u32;
812 let y1 = top.floor() as u32;
813 let y2 = bottom.ceil() as u32;
814 (x2 > x1 && y2 > y1).then_some((x1, y1, x2, y2))
815}
816
817fn extract_page_raster_cell_text(
818 gray: &GrayImage,
819 cell_bbox: &BoundingBox,
820 x1: u32,
821 y1: u32,
822 x2: u32,
823 y2: u32,
824) -> Option<String> {
825 let inset_x = CELL_INSET_PX.min((x2 - x1) / 4);
826 let inset_y = CELL_INSET_PX.min((y2 - y1) / 4);
827 let crop_left = x1 + inset_x;
828 let crop_top = y1 + inset_y;
829 let crop_width = x2.saturating_sub(x1 + inset_x * 2);
830 let crop_height = y2.saturating_sub(y1 + inset_y * 2);
831 if crop_width < MIN_CELL_SIZE_PX || crop_height < MIN_CELL_SIZE_PX {
832 return Some(String::new());
833 }
834
835 let cropped = gray
836 .view(crop_left, crop_top, crop_width, crop_height)
837 .to_image();
838 let bordered = expand_white_border(&cropped, 12);
839 let scaled = image::imageops::resize(
840 &bordered,
841 bordered.width() * OCR_SCALE_FACTOR,
842 bordered.height() * OCR_SCALE_FACTOR,
843 image::imageops::FilterType::Lanczos3,
844 );
845
846 let aspect_ratio = cell_bbox.width() / cell_bbox.height();
848 let is_vertical = aspect_ratio < 0.8;
849
850 let psm_modes: [&str; 5] = if is_vertical {
860 ["7", "8", "6", "11", "13"]
861 } else {
862 ["6", "7", "8", "11", "13"]
863 };
864
865 let raw_text = run_tesseract_cell_text_best(&scaled, &psm_modes)?;
866 Some(normalize_page_raster_cell_text(cell_bbox, raw_text))
867}
868
869fn normalize_page_raster_cell_text(cell_bbox: &BoundingBox, text: String) -> String {
870 let normalized = text
871 .replace('|', " ")
872 .replace('—', "-")
873 .replace(['“', '”'], "\"")
874 .replace('’', "'")
875 .split_whitespace()
876 .collect::<Vec<_>>()
877 .join(" ");
878
879 if normalized.is_empty() {
880 return normalized;
881 }
882
883 let narrow_cell = cell_bbox.width() <= cell_bbox.height() * 1.15;
884 if narrow_cell && normalized.len() <= 3 && !normalized.chars().any(|ch| ch.is_ascii_digit()) {
885 return String::new();
886 }
887
888 normalized
889}
890
891fn is_ocr_candidate(
892 image: &ImageChunk,
893 page_bbox: &BoundingBox,
894 text_chunks: &[TextChunk],
895) -> bool {
896 let width_ratio = image.bbox.width() / page_bbox.width().max(1.0);
897 let area_ratio = image.bbox.area() / page_bbox.area().max(1.0);
898 if width_ratio < MIN_IMAGE_WIDTH_RATIO || area_ratio < MIN_IMAGE_AREA_RATIO {
899 return false;
900 }
901
902 let overlapping_chunks: Vec<&TextChunk> = text_chunks
903 .iter()
904 .filter(|chunk| image.bbox.intersection_percent(&chunk.bbox) >= 0.7)
905 .collect();
906 let native_text_chars: usize = overlapping_chunks
907 .iter()
908 .map(|chunk| chunk.value.chars().filter(|ch| !ch.is_whitespace()).count())
909 .sum();
910
911 native_text_chars <= MAX_NATIVE_TEXT_CHARS_IN_IMAGE
912 || overlapping_chunks.len() <= MAX_NATIVE_TEXT_CHUNKS_IN_IMAGE
913}
914
915fn is_dominant_image_text_candidate(
916 image: &ImageChunk,
917 page_bbox: &BoundingBox,
918 text_chunks: &[TextChunk],
919) -> bool {
920 let width_ratio = image.bbox.width() / page_bbox.width().max(1.0);
921 let area_ratio = image.bbox.area() / page_bbox.area().max(1.0);
922 if width_ratio < MIN_DOMINANT_IMAGE_WIDTH_RATIO || area_ratio < MIN_DOMINANT_IMAGE_AREA_RATIO {
923 return false;
924 }
925
926 let native_text_chars: usize = text_chunks
927 .iter()
928 .filter(|chunk| image.bbox.intersection_percent(&chunk.bbox) >= 0.7)
929 .map(|chunk| chunk.value.chars().filter(|ch| !ch.is_whitespace()).count())
930 .sum();
931
932 native_text_chars <= MAX_NATIVE_TEXT_CHARS_IN_DOMINANT_IMAGE
933}
934
935fn looks_like_chart_label_ocr(words: &[OcrWord]) -> bool {
936 if words.len() < 8 {
937 return false;
938 }
939
940 let min_left = words.iter().map(|word| word.left).min().unwrap_or(0);
941 let min_top = words.iter().map(|word| word.top).min().unwrap_or(0);
942 let max_right = words
943 .iter()
944 .map(|word| word.left.saturating_add(word.width))
945 .max()
946 .unwrap_or(0);
947 let max_bottom = words
948 .iter()
949 .map(|word| word.top.saturating_add(word.height))
950 .max()
951 .unwrap_or(0);
952 let image_width = max_right.saturating_sub(min_left);
953 let image_height = max_bottom.saturating_sub(min_top);
954 if image_width < 160 || image_height < 120 {
955 return false;
956 }
957
958 let width_f = f64::from(image_width);
959 let height_f = f64::from(image_height);
960 let outer_x = width_f * 0.18;
961 let outer_y = height_f * 0.18;
962 let inner_left = width_f * 0.22;
963 let inner_right = width_f * 0.78;
964 let inner_top = height_f * 0.22;
965 let inner_bottom = height_f * 0.78;
966
967 let mut by_line: BTreeMap<(u32, u32, u32), Vec<&OcrWord>> = BTreeMap::new();
968 let mut outer_words = 0usize;
969 let mut inner_words = 0usize;
970
971 for word in words {
972 by_line.entry(word.line_key).or_default().push(word);
973
974 let center_x = f64::from(word.left.saturating_sub(min_left)) + f64::from(word.width) / 2.0;
975 let center_y = f64::from(word.top.saturating_sub(min_top)) + f64::from(word.height) / 2.0;
976
977 if center_x <= outer_x
978 || center_x >= width_f - outer_x
979 || center_y <= outer_y
980 || center_y >= height_f - outer_y
981 {
982 outer_words += 1;
983 }
984
985 if center_x >= inner_left
986 && center_x <= inner_right
987 && center_y >= inner_top
988 && center_y <= inner_bottom
989 {
990 inner_words += 1;
991 }
992 }
993
994 if by_line.len() < 5 {
995 return false;
996 }
997
998 let tolerance = (f64::from(max_right) * 0.035).max(18.0);
999 let mut clusters: Vec<XCluster> = Vec::new();
1000 for line_words in by_line.values() {
1001 for word in line_words {
1002 let center = f64::from(word.left) + f64::from(word.width) / 2.0;
1003 if let Some(cluster) = clusters
1004 .iter_mut()
1005 .find(|cluster| (cluster.center - center).abs() <= tolerance)
1006 {
1007 cluster.center =
1008 (cluster.center * cluster.count as f64 + center) / (cluster.count as f64 + 1.0);
1009 cluster.count += 1;
1010 cluster.lines.insert(word.line_key);
1011 } else {
1012 let mut lines = HashSet::new();
1013 lines.insert(word.line_key);
1014 clusters.push(XCluster {
1015 center,
1016 count: 1,
1017 lines,
1018 });
1019 }
1020 }
1021 }
1022
1023 let stable_centers: Vec<f64> = clusters
1024 .iter()
1025 .filter(|cluster| cluster.lines.len() >= 4 && cluster.count >= 4)
1026 .map(|cluster| cluster.center)
1027 .collect();
1028 let mut sorted_stable_centers = stable_centers.clone();
1029 sorted_stable_centers
1030 .sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal));
1031 let max_stable_gap = sorted_stable_centers
1032 .windows(2)
1033 .map(|pair| pair[1] - pair[0])
1034 .fold(0.0, f64::max);
1035 let spans_full_table_width = stable_centers.len() >= 3
1036 && stable_centers
1037 .iter()
1038 .any(|center| *center - f64::from(min_left) <= width_f * 0.25)
1039 && stable_centers
1040 .iter()
1041 .any(|center| *center - f64::from(min_left) >= width_f * 0.75)
1042 && stable_centers.iter().any(|center| {
1043 let rel = *center - f64::from(min_left);
1044 rel >= inner_left && rel <= inner_right
1045 })
1046 && max_stable_gap <= width_f * 0.45;
1047 if spans_full_table_width {
1048 let table_like_lines = by_line
1049 .values()
1050 .filter(|line_words| {
1051 let mut seen = HashSet::<usize>::new();
1052 for word in *line_words {
1053 let center = f64::from(word.left) + f64::from(word.width) / 2.0;
1054 for (idx, stable_center) in stable_centers.iter().enumerate() {
1055 if (center - stable_center).abs() <= tolerance {
1056 seen.insert(idx);
1057 }
1058 }
1059 }
1060 seen.len() >= 3
1061 })
1062 .count();
1063 if table_like_lines >= 4 {
1064 return false;
1065 }
1066 }
1067
1068 let mut short_lines = 0usize;
1069 let mut peripheral_label_lines = 0usize;
1070 let mut wide_sentence_lines = 0usize;
1071 let mut axisish_numeric_lines = 0usize;
1072
1073 for line_words in by_line.values() {
1074 let line_left = line_words.iter().map(|word| word.left).min().unwrap_or(0);
1075 let line_top = line_words.iter().map(|word| word.top).min().unwrap_or(0);
1076 let line_right = line_words
1077 .iter()
1078 .map(|word| word.left.saturating_add(word.width))
1079 .max()
1080 .unwrap_or(0);
1081 let line_bottom = line_words
1082 .iter()
1083 .map(|word| word.top.saturating_add(word.height))
1084 .max()
1085 .unwrap_or(0);
1086 if line_right <= line_left || line_bottom <= line_top {
1087 continue;
1088 }
1089
1090 let word_count = line_words.len();
1091 let numeric_in_line = line_words
1092 .iter()
1093 .filter(|word| is_numeric_like(&word.text))
1094 .count();
1095 let line_width_ratio =
1096 f64::from(line_right.saturating_sub(line_left)) / f64::from(image_width.max(1));
1097 let touches_outer_band = f64::from(line_left.saturating_sub(min_left)) <= outer_x
1098 || f64::from(line_right.saturating_sub(min_left)) >= width_f - outer_x
1099 || f64::from(line_top.saturating_sub(min_top)) <= outer_y
1100 || f64::from(line_bottom.saturating_sub(min_top)) >= height_f - outer_y;
1101
1102 if word_count <= 3 {
1103 short_lines += 1;
1104 }
1105 if touches_outer_band && word_count <= 4 {
1106 peripheral_label_lines += 1;
1107 }
1108 if touches_outer_band && word_count <= 3 && numeric_in_line > 0 {
1109 axisish_numeric_lines += 1;
1110 }
1111 if word_count >= 4 && line_width_ratio >= 0.45 && numeric_in_line == 0 {
1112 wide_sentence_lines += 1;
1113 }
1114 }
1115
1116 let total_lines = by_line.len();
1117 let outer_dominant = outer_words * 10 >= words.len() * 5;
1118 let inner_sparse = inner_words * 10 <= words.len() * 5;
1119 let label_dominant = peripheral_label_lines * 10 >= total_lines * 6;
1120 let short_line_dominant = short_lines * 10 >= total_lines * 6;
1121 let axis_signal = axisish_numeric_lines >= 2;
1122
1123 outer_dominant
1124 && inner_sparse
1125 && label_dominant
1126 && short_line_dominant
1127 && axis_signal
1128 && wide_sentence_lines <= 2
1129}
1130
1131fn looks_like_matrix_formula_ocr(words: &[OcrWord]) -> bool {
1132 if words.len() < 6 {
1133 return false;
1134 }
1135
1136 let mut by_line: BTreeMap<(u32, u32, u32), Vec<&OcrWord>> = BTreeMap::new();
1137 for word in words {
1138 by_line.entry(word.line_key).or_default().push(word);
1139 }
1140
1141 if by_line.len() < 2 || by_line.len() > 4 {
1142 return false;
1143 }
1144
1145 let substantive_words = words
1146 .iter()
1147 .filter(|word| is_substantive_table_word(&word.text))
1148 .count();
1149 let short_formulaish_words = words
1150 .iter()
1151 .filter(|word| is_short_formulaish_word(&word.text))
1152 .count();
1153 let slash_words = words.iter().filter(|word| word.text.contains('/')).count();
1154 let equation_label_words = words
1155 .iter()
1156 .filter(|word| looks_like_equation_label_word(&word.text))
1157 .count();
1158 let dense_lines = by_line.values().filter(|line| line.len() >= 3).count();
1159 let short_lines = by_line
1160 .values()
1161 .filter(|line| line.iter().all(|word| is_short_formulaish_word(&word.text)))
1162 .count();
1163
1164 substantive_words == 0
1165 && dense_lines >= 2
1166 && short_lines * 10 >= by_line.len() * 7
1167 && short_formulaish_words * 10 >= words.len() * 7
1168 && (slash_words > 0 || equation_label_words >= 2)
1169}
1170
1171fn is_substantive_table_word(text: &str) -> bool {
1172 let normalized: String = text
1173 .chars()
1174 .filter(|ch| ch.is_alphanumeric())
1175 .flat_map(char::to_lowercase)
1176 .collect();
1177 if normalized.is_empty() {
1178 return false;
1179 }
1180
1181 let alpha_count = normalized.chars().filter(|ch| ch.is_alphabetic()).count();
1182 let digit_count = normalized.chars().filter(|ch| ch.is_ascii_digit()).count();
1183 let has_non_binary_digit = normalized
1184 .chars()
1185 .any(|ch| ch.is_ascii_digit() && !matches!(ch, '0' | '1'));
1186
1187 alpha_count >= 4
1188 || (digit_count >= 2 && alpha_count == 0 && has_non_binary_digit)
1189 || (normalized.len() >= 5 && alpha_count >= 2)
1190}
1191
1192fn is_short_formulaish_word(text: &str) -> bool {
1193 let normalized: String = text
1194 .chars()
1195 .filter(|ch| ch.is_alphanumeric())
1196 .flat_map(char::to_lowercase)
1197 .collect();
1198 if normalized.is_empty() {
1199 return true;
1200 }
1201
1202 normalized.len() <= 3 || (text.contains('/') && normalized.len() <= 4)
1203}
1204
1205fn looks_like_equation_label_word(text: &str) -> bool {
1206 let trimmed = text.trim_matches(|ch: char| !ch.is_alphanumeric());
1207 let mut chars = trimmed.chars();
1208 let Some(first) = chars.next() else {
1209 return false;
1210 };
1211 if !first.is_ascii_alphabetic() || !first.is_ascii_uppercase() {
1212 return false;
1213 }
1214
1215 let remainder: String = chars.collect();
1216 !remainder.is_empty() && remainder.len() <= 3 && remainder.chars().all(|ch| ch.is_ascii_digit())
1217}
1218
1219fn looks_like_table_ocr(words: &[OcrWord]) -> bool {
1220 if words.len() < 8 {
1221 return false;
1222 }
1223
1224 if looks_like_chart_label_ocr(words) {
1225 return false;
1226 }
1227
1228 if looks_like_matrix_formula_ocr(words) {
1229 return false;
1230 }
1231
1232 let mut by_line: BTreeMap<(u32, u32, u32), Vec<&OcrWord>> = BTreeMap::new();
1233 for word in words {
1234 by_line.entry(word.line_key).or_default().push(word);
1235 }
1236
1237 let mut qualifying_lines = Vec::new();
1238 let mut numeric_like_count = 0usize;
1239 let mut max_right = 0u32;
1240 for line_words in by_line.values_mut() {
1241 line_words.sort_by_key(|word| word.left);
1242 let numeric_words = line_words
1243 .iter()
1244 .filter(|word| is_numeric_like(&word.text))
1245 .count();
1246 numeric_like_count += numeric_words;
1247 if line_words.len() >= 3 || numeric_words >= 2 {
1248 max_right = max_right.max(
1249 line_words
1250 .iter()
1251 .map(|word| word.left.saturating_add(word.width))
1252 .max()
1253 .unwrap_or(0),
1254 );
1255 qualifying_lines.push(line_words.clone());
1256 }
1257 }
1258
1259 if qualifying_lines.len() < 2 {
1260 return false;
1261 }
1262
1263 let tolerance = (f64::from(max_right) * 0.035).max(18.0);
1264 let mut clusters: Vec<XCluster> = Vec::new();
1265 for line in &qualifying_lines {
1266 for word in line {
1267 let center = f64::from(word.left) + f64::from(word.width) / 2.0;
1268 if let Some(cluster) = clusters
1269 .iter_mut()
1270 .find(|cluster| (cluster.center - center).abs() <= tolerance)
1271 {
1272 cluster.center =
1273 (cluster.center * cluster.count as f64 + center) / (cluster.count as f64 + 1.0);
1274 cluster.count += 1;
1275 cluster.lines.insert(word.line_key);
1276 } else {
1277 let mut lines = HashSet::new();
1278 lines.insert(word.line_key);
1279 clusters.push(XCluster {
1280 center,
1281 count: 1,
1282 lines,
1283 });
1284 }
1285 }
1286 }
1287
1288 let repeated_clusters: Vec<&XCluster> = clusters
1289 .iter()
1290 .filter(|cluster| cluster.lines.len() >= 2 && cluster.count >= 2)
1291 .collect();
1292 if repeated_clusters.len() < 3 {
1293 return false;
1294 }
1295
1296 let repeated_centers: Vec<f64> = repeated_clusters
1297 .iter()
1298 .map(|cluster| cluster.center)
1299 .collect();
1300 let structured_lines = qualifying_lines
1301 .iter()
1302 .filter(|line| {
1303 let mut seen = HashSet::<usize>::new();
1304 for word in *line {
1305 let center = f64::from(word.left) + f64::from(word.width) / 2.0;
1306 for (idx, repeated_center) in repeated_centers.iter().enumerate() {
1307 if (center - repeated_center).abs() <= tolerance {
1308 seen.insert(idx);
1309 }
1310 }
1311 }
1312 seen.len() >= 3
1313 || (seen.len() >= 2
1314 && line.iter().filter(|w| is_numeric_like(&w.text)).count() >= 2)
1315 })
1316 .count();
1317
1318 let alphabetic_words = words
1319 .iter()
1320 .filter(|word| word.text.chars().any(|ch| ch.is_alphabetic()))
1321 .count();
1322
1323 if numeric_like_count == 0
1327 && alphabetic_words * 10 >= words.len() * 9
1328 && repeated_clusters.len() <= 4
1329 {
1330 return false;
1331 }
1332
1333 structured_lines >= 3
1334 || (structured_lines >= 2 && numeric_like_count >= 6 && repeated_clusters.len() >= 4)
1335}
1336
1337fn looks_like_numeric_table_ocr(words: &[OcrWord]) -> bool {
1338 if !looks_like_table_ocr(words) {
1339 return false;
1340 }
1341
1342 let mut by_line: BTreeMap<(u32, u32, u32), Vec<&OcrWord>> = BTreeMap::new();
1343 for word in words {
1344 by_line.entry(word.line_key).or_default().push(word);
1345 }
1346
1347 let numeric_like_count = words
1348 .iter()
1349 .filter(|word| is_numeric_like(&word.text))
1350 .count();
1351 let numeric_lines = by_line
1352 .values()
1353 .filter(|line| {
1354 line.iter()
1355 .filter(|word| is_numeric_like(&word.text))
1356 .count()
1357 >= 2
1358 })
1359 .count();
1360
1361 numeric_like_count >= 12 && numeric_lines >= 3
1362}
1363
1364fn looks_like_dense_prose_image_ocr(words: &[OcrWord]) -> bool {
1365 if words.len() < MIN_DOMINANT_IMAGE_OCR_WORDS || looks_like_table_ocr(words) {
1366 return false;
1367 }
1368
1369 if looks_like_chart_label_ocr(words) {
1370 return false;
1371 }
1372
1373 let mut by_line: BTreeMap<(u32, u32, u32), Vec<&OcrWord>> = BTreeMap::new();
1374 let mut alphabetic_words = 0usize;
1375 let mut numeric_like_words = 0usize;
1376 for word in words {
1377 by_line.entry(word.line_key).or_default().push(word);
1378 if word.text.chars().any(|ch| ch.is_alphabetic()) {
1379 alphabetic_words += 1;
1380 }
1381 if is_numeric_like(&word.text) {
1382 numeric_like_words += 1;
1383 }
1384 }
1385
1386 if by_line.len() < MIN_DOMINANT_IMAGE_TEXT_LINES || alphabetic_words * 3 < words.len() * 2 {
1387 return false;
1388 }
1389 if numeric_like_words * 4 > words.len() {
1390 return false;
1391 }
1392
1393 let multiword_lines = by_line
1394 .values()
1395 .filter(|line| line.iter().filter(|word| word.text.len() >= 2).count() >= 3)
1396 .count();
1397 multiword_lines >= 4 && has_dense_prose_block_geometry(words)
1398}
1399
1400fn has_dense_prose_block_geometry(words: &[OcrWord]) -> bool {
1401 let mut by_line: BTreeMap<(u32, u32, u32), Vec<&OcrWord>> = BTreeMap::new();
1402 for word in words {
1403 by_line.entry(word.line_key).or_default().push(word);
1404 }
1405
1406 let mut spatial_lines = Vec::new();
1407 for line_words in by_line.values() {
1408 if line_words.len() < 3 {
1409 continue;
1410 }
1411
1412 let left = line_words.iter().map(|word| word.left).min().unwrap_or(0);
1413 let right = line_words
1414 .iter()
1415 .map(|word| word.left.saturating_add(word.width))
1416 .max()
1417 .unwrap_or(0);
1418 let top = line_words.iter().map(|word| word.top).min().unwrap_or(0);
1419 let bottom = line_words
1420 .iter()
1421 .map(|word| word.top.saturating_add(word.height))
1422 .max()
1423 .unwrap_or(0);
1424
1425 if right <= left || bottom <= top {
1426 continue;
1427 }
1428
1429 spatial_lines.push(SpatialOcrLine {
1430 left,
1431 top,
1432 right,
1433 bottom,
1434 text: String::new(),
1435 word_count: line_words.len(),
1436 line_count: 1,
1437 line_height_sum: bottom.saturating_sub(top).max(1),
1438 });
1439 }
1440
1441 spatial_lines.sort_by_key(|line| (line.top, line.left));
1442 if spatial_lines.len() < MIN_DENSE_PROSE_BLOCK_LINES {
1443 return false;
1444 }
1445
1446 let image_width = spatial_lines
1447 .iter()
1448 .map(|line| line.right)
1449 .max()
1450 .unwrap_or(0);
1451 if image_width == 0 {
1452 return false;
1453 }
1454
1455 let median_height = {
1456 let mut heights: Vec<u32> = spatial_lines
1457 .iter()
1458 .map(|line| line.bottom.saturating_sub(line.top).max(1))
1459 .collect();
1460 heights.sort_unstable();
1461 heights[heights.len() / 2]
1462 };
1463
1464 let mut best_line_count = 1usize;
1465 let mut best_left = spatial_lines[0].left;
1466 let mut best_right = spatial_lines[0].right;
1467 let mut current_line_count = 1usize;
1468 let mut current_left = spatial_lines[0].left;
1469 let mut current_right = spatial_lines[0].right;
1470
1471 for pair in spatial_lines.windows(2) {
1472 let prev = &pair[0];
1473 let curr = &pair[1];
1474 if spatial_lines_share_block_geometry(prev, curr, image_width, median_height) {
1475 current_line_count += 1;
1476 current_left = current_left.min(curr.left);
1477 current_right = current_right.max(curr.right);
1478 } else {
1479 if current_line_count > best_line_count {
1480 best_line_count = current_line_count;
1481 best_left = current_left;
1482 best_right = current_right;
1483 }
1484 current_line_count = 1;
1485 current_left = curr.left;
1486 current_right = curr.right;
1487 }
1488 }
1489
1490 if current_line_count > best_line_count {
1491 best_line_count = current_line_count;
1492 best_left = current_left;
1493 best_right = current_right;
1494 }
1495
1496 let block_width_ratio =
1497 f64::from(best_right.saturating_sub(best_left)) / f64::from(image_width);
1498 best_line_count >= MIN_DENSE_PROSE_BLOCK_LINES
1499 && block_width_ratio >= MIN_DENSE_PROSE_BLOCK_WIDTH_RATIO
1500}
1501
1502fn build_numeric_table_border(words: &[OcrWord], image: &ImageChunk) -> Option<TableBorder> {
1503 let image_width = words
1504 .iter()
1505 .map(|word| word.left.saturating_add(word.width))
1506 .max()?;
1507 let image_height = words
1508 .iter()
1509 .map(|word| word.top.saturating_add(word.height))
1510 .max()?;
1511 if image_width == 0 || image_height == 0 {
1512 return None;
1513 }
1514
1515 let mut by_line: BTreeMap<(u32, u32, u32), Vec<&OcrWord>> = BTreeMap::new();
1516 for word in words {
1517 by_line.entry(word.line_key).or_default().push(word);
1518 }
1519
1520 let max_right = words
1521 .iter()
1522 .map(|word| word.left.saturating_add(word.width))
1523 .max()
1524 .unwrap_or(0);
1525 let tolerance = (f64::from(max_right) * 0.035).max(18.0);
1526
1527 let mut clusters: Vec<XCluster> = Vec::new();
1528 for line_words in by_line.values() {
1529 for word in line_words {
1530 let center = f64::from(word.left) + f64::from(word.width) / 2.0;
1531 if let Some(cluster) = clusters
1532 .iter_mut()
1533 .find(|cluster| (cluster.center - center).abs() <= tolerance)
1534 {
1535 cluster.center =
1536 (cluster.center * cluster.count as f64 + center) / (cluster.count as f64 + 1.0);
1537 cluster.count += 1;
1538 cluster.lines.insert(word.line_key);
1539 } else {
1540 let mut lines = HashSet::new();
1541 lines.insert(word.line_key);
1542 clusters.push(XCluster {
1543 center,
1544 count: 1,
1545 lines,
1546 });
1547 }
1548 }
1549 }
1550 let mut centers: Vec<f64> = clusters
1551 .into_iter()
1552 .filter(|cluster| cluster.lines.len() >= 2 && cluster.count >= 2)
1553 .map(|cluster| cluster.center)
1554 .collect();
1555 centers.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1556 if centers.len() < 3 {
1557 return None;
1558 }
1559
1560 let mut built_rows = Vec::<OcrRowBuild>::new();
1561 let mut row_fill_counts = Vec::<usize>::new();
1562 for line_words in by_line.values() {
1563 let mut sorted_words = line_words.clone();
1564 sorted_words.sort_by_key(|word| word.left);
1565
1566 let mut cells = vec![Vec::<&OcrWord>::new(); centers.len()];
1567 for word in &sorted_words {
1568 let center = f64::from(word.left) + f64::from(word.width) / 2.0;
1569 if let Some((col_idx, distance)) = centers
1570 .iter()
1571 .enumerate()
1572 .map(|(idx, col_center)| (idx, (center - col_center).abs()))
1573 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
1574 {
1575 if distance <= tolerance {
1576 cells[col_idx].push(word);
1577 }
1578 }
1579 }
1580
1581 let filled_cells = cells.iter().filter(|cell| !cell.is_empty()).count();
1582 let numeric_cells = cells
1583 .iter()
1584 .filter(|cell| cell.iter().any(|word| is_numeric_like(&word.text)))
1585 .count();
1586 if filled_cells < 3 && numeric_cells < 2 {
1587 continue;
1588 }
1589 row_fill_counts.push(filled_cells);
1590
1591 let top_px = sorted_words.iter().map(|word| word.top).min().unwrap_or(0);
1592 let bottom_px = sorted_words
1593 .iter()
1594 .map(|word| word.top.saturating_add(word.height))
1595 .max()
1596 .unwrap_or(0);
1597 let top_y =
1598 image.bbox.top_y - image.bbox.height() * (f64::from(top_px) / f64::from(image_height));
1599 let bottom_y = image.bbox.top_y
1600 - image.bbox.height() * (f64::from(bottom_px) / f64::from(image_height));
1601 let cell_texts = cells
1602 .iter()
1603 .map(|cell_words| {
1604 cell_words
1605 .iter()
1606 .map(|word| word.text.as_str())
1607 .collect::<Vec<_>>()
1608 .join(" ")
1609 })
1610 .collect();
1611 built_rows.push(OcrRowBuild {
1612 top_y,
1613 bottom_y,
1614 cell_texts,
1615 });
1616 }
1617
1618 if built_rows.len() < 2 {
1619 return None;
1620 }
1621 if row_fill_counts.is_empty() {
1622 return None;
1623 }
1624
1625 let mut sorted_fill_counts = row_fill_counts.clone();
1626 sorted_fill_counts.sort_unstable();
1627 let median_fill_ratio =
1628 sorted_fill_counts[sorted_fill_counts.len() / 2] as f64 / centers.len() as f64;
1629 if median_fill_ratio < MIN_NUMERIC_TABLE_MEDIAN_FILL_RATIO {
1630 return None;
1631 }
1632
1633 built_rows.sort_by(|a, b| {
1634 b.top_y
1635 .partial_cmp(&a.top_y)
1636 .unwrap_or(std::cmp::Ordering::Equal)
1637 });
1638 let x_coordinates =
1639 build_boundaries_from_centers(¢ers, image.bbox.left_x, image.bbox.right_x, image_width);
1640 let row_bounds: Vec<(f64, f64)> = built_rows
1641 .iter()
1642 .map(|row| (row.top_y, row.bottom_y))
1643 .collect();
1644 let y_coordinates = build_row_boundaries(&row_bounds);
1645 if x_coordinates.len() != centers.len() + 1 || y_coordinates.len() != built_rows.len() + 1 {
1646 return None;
1647 }
1648
1649 let mut rows = Vec::new();
1650 for (row_idx, row_build) in built_rows.iter().enumerate() {
1651 let row_bbox = BoundingBox::new(
1652 image.bbox.page_number,
1653 image.bbox.left_x,
1654 y_coordinates[row_idx + 1],
1655 image.bbox.right_x,
1656 y_coordinates[row_idx],
1657 );
1658 let mut cells = Vec::new();
1659 for col_idx in 0..centers.len() {
1660 let cell_bbox = BoundingBox::new(
1661 image.bbox.page_number,
1662 x_coordinates[col_idx],
1663 y_coordinates[row_idx + 1],
1664 x_coordinates[col_idx + 1],
1665 y_coordinates[row_idx],
1666 );
1667 let text = row_build
1668 .cell_texts
1669 .get(col_idx)
1670 .cloned()
1671 .unwrap_or_default();
1672 let mut content = Vec::new();
1673 if !text.trim().is_empty() {
1674 content.push(TableToken {
1675 base: TextChunk {
1676 value: text.trim().to_string(),
1677 bbox: cell_bbox.clone(),
1678 font_name: "OCR".to_string(),
1679 font_size: (row_build.top_y - row_build.bottom_y).max(6.0),
1680 font_weight: 400.0,
1681 italic_angle: 0.0,
1682 font_color: "#000000".to_string(),
1683 contrast_ratio: 21.0,
1684 symbol_ends: Vec::new(),
1685 text_format: TextFormat::Normal,
1686 text_type: TextType::Regular,
1687 pdf_layer: PdfLayer::Content,
1688 ocg_visible: true,
1689 index: None,
1690 page_number: image.bbox.page_number,
1691 level: None,
1692 mcid: None,
1693 },
1694 token_type: TableTokenType::Text,
1695 });
1696 }
1697 cells.push(TableBorderCell {
1698 bbox: cell_bbox,
1699 index: None,
1700 level: None,
1701 row_number: row_idx,
1702 col_number: col_idx,
1703 row_span: 1,
1704 col_span: 1,
1705 content,
1706 contents: Vec::new(),
1707 semantic_type: None,
1708 });
1709 }
1710 rows.push(TableBorderRow {
1711 bbox: row_bbox,
1712 index: None,
1713 level: None,
1714 row_number: row_idx,
1715 cells,
1716 semantic_type: None,
1717 });
1718 }
1719
1720 Some(TableBorder {
1721 bbox: image.bbox.clone(),
1722 index: None,
1723 level: None,
1724 x_coordinates: x_coordinates.clone(),
1725 x_widths: vec![0.0; x_coordinates.len()],
1726 y_coordinates: y_coordinates.clone(),
1727 y_widths: vec![0.0; y_coordinates.len()],
1728 rows,
1729 num_rows: built_rows.len(),
1730 num_columns: centers.len(),
1731 is_bad_table: false,
1732 is_table_transformer: true,
1733 previous_table: None,
1734 next_table: None,
1735 })
1736}
1737
1738fn build_structured_ocr_table_border(words: &[OcrWord], image: &ImageChunk) -> Option<TableBorder> {
1739 let image_width = words
1740 .iter()
1741 .map(|word| word.left.saturating_add(word.width))
1742 .max()?;
1743 let image_height = words
1744 .iter()
1745 .map(|word| word.top.saturating_add(word.height))
1746 .max()?;
1747 if image_width == 0 || image_height == 0 {
1748 return None;
1749 }
1750
1751 let mut by_line: BTreeMap<(u32, u32, u32), Vec<&OcrWord>> = BTreeMap::new();
1752 for word in words {
1753 by_line.entry(word.line_key).or_default().push(word);
1754 }
1755
1756 let max_right = words
1757 .iter()
1758 .map(|word| word.left.saturating_add(word.width))
1759 .max()
1760 .unwrap_or(0);
1761 let tolerance = (f64::from(max_right) * 0.035).max(18.0);
1762
1763 let mut clusters: Vec<XCluster> = Vec::new();
1764 for line_words in by_line.values() {
1765 for word in line_words {
1766 let center = f64::from(word.left) + f64::from(word.width) / 2.0;
1767 if let Some(cluster) = clusters
1768 .iter_mut()
1769 .find(|cluster| (cluster.center - center).abs() <= tolerance)
1770 {
1771 cluster.center =
1772 (cluster.center * cluster.count as f64 + center) / (cluster.count as f64 + 1.0);
1773 cluster.count += 1;
1774 cluster.lines.insert(word.line_key);
1775 } else {
1776 let mut lines = HashSet::new();
1777 lines.insert(word.line_key);
1778 clusters.push(XCluster {
1779 center,
1780 count: 1,
1781 lines,
1782 });
1783 }
1784 }
1785 }
1786
1787 let mut centers: Vec<f64> = clusters
1788 .into_iter()
1789 .filter(|cluster| cluster.lines.len() >= 2 && cluster.count >= 2)
1790 .map(|cluster| cluster.center)
1791 .collect();
1792 centers.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1793 if centers.len() < 3 {
1794 return None;
1795 }
1796
1797 let mut built_rows = Vec::<OcrRowBuild>::new();
1798 let mut row_fill_counts = Vec::<usize>::new();
1799 let mut occupied_columns = vec![0usize; centers.len()];
1800
1801 for line_words in by_line.values() {
1802 let mut sorted_words = line_words.clone();
1803 sorted_words.sort_by_key(|word| word.left);
1804
1805 let mut cells = vec![Vec::<&OcrWord>::new(); centers.len()];
1806 for word in &sorted_words {
1807 let center = f64::from(word.left) + f64::from(word.width) / 2.0;
1808 if let Some((col_idx, distance)) = centers
1809 .iter()
1810 .enumerate()
1811 .map(|(idx, col_center)| (idx, (center - col_center).abs()))
1812 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
1813 {
1814 if distance <= tolerance {
1815 cells[col_idx].push(word);
1816 }
1817 }
1818 }
1819
1820 let filled_indices: Vec<usize> = cells
1821 .iter()
1822 .enumerate()
1823 .filter_map(|(idx, cell)| (!cell.is_empty()).then_some(idx))
1824 .collect();
1825 if filled_indices.len() < 2 {
1826 continue;
1827 }
1828
1829 let span = filled_indices.last().unwrap_or(&0) - filled_indices.first().unwrap_or(&0) + 1;
1830 if filled_indices.len() < 3 && span < 3 {
1831 continue;
1832 }
1833
1834 row_fill_counts.push(filled_indices.len());
1835 for idx in &filled_indices {
1836 if let Some(count) = occupied_columns.get_mut(*idx) {
1837 *count += 1;
1838 }
1839 }
1840
1841 let top_px = sorted_words.iter().map(|word| word.top).min().unwrap_or(0);
1842 let bottom_px = sorted_words
1843 .iter()
1844 .map(|word| word.top.saturating_add(word.height))
1845 .max()
1846 .unwrap_or(0);
1847 let top_y =
1848 image.bbox.top_y - image.bbox.height() * (f64::from(top_px) / f64::from(image_height));
1849 let bottom_y = image.bbox.top_y
1850 - image.bbox.height() * (f64::from(bottom_px) / f64::from(image_height));
1851 let cell_texts = cells
1852 .iter()
1853 .map(|cell_words| {
1854 let mut sorted_cell_words = cell_words.clone();
1855 sorted_cell_words.sort_by_key(|word| word.left);
1856 sorted_cell_words
1857 .iter()
1858 .map(|word| word.text.as_str())
1859 .collect::<Vec<_>>()
1860 .join(" ")
1861 })
1862 .collect();
1863 built_rows.push(OcrRowBuild {
1864 top_y,
1865 bottom_y,
1866 cell_texts,
1867 });
1868 }
1869
1870 if built_rows.len() < 3 || row_fill_counts.is_empty() {
1871 return None;
1872 }
1873
1874 let repeated_columns = occupied_columns.iter().filter(|count| **count >= 2).count();
1875 if repeated_columns < 3 {
1876 return None;
1877 }
1878
1879 let mut sorted_fill_counts = row_fill_counts.clone();
1880 sorted_fill_counts.sort_unstable();
1881 let median_fill_ratio =
1882 sorted_fill_counts[sorted_fill_counts.len() / 2] as f64 / centers.len() as f64;
1883 if median_fill_ratio < 0.5 {
1884 return None;
1885 }
1886
1887 built_rows.sort_by(|a, b| {
1888 b.top_y
1889 .partial_cmp(&a.top_y)
1890 .unwrap_or(std::cmp::Ordering::Equal)
1891 });
1892 let x_coordinates =
1893 build_boundaries_from_centers(¢ers, image.bbox.left_x, image.bbox.right_x, image_width);
1894 let row_bounds: Vec<(f64, f64)> = built_rows
1895 .iter()
1896 .map(|row| (row.top_y, row.bottom_y))
1897 .collect();
1898 let y_coordinates = build_row_boundaries(&row_bounds);
1899 if x_coordinates.len() != centers.len() + 1 || y_coordinates.len() != built_rows.len() + 1 {
1900 return None;
1901 }
1902
1903 let mut rows = Vec::new();
1904 for (row_idx, row_build) in built_rows.iter().enumerate() {
1905 let row_bbox = BoundingBox::new(
1906 image.bbox.page_number,
1907 image.bbox.left_x,
1908 y_coordinates[row_idx + 1],
1909 image.bbox.right_x,
1910 y_coordinates[row_idx],
1911 );
1912 let mut cells = Vec::new();
1913 for col_idx in 0..centers.len() {
1914 let cell_bbox = BoundingBox::new(
1915 image.bbox.page_number,
1916 x_coordinates[col_idx],
1917 y_coordinates[row_idx + 1],
1918 x_coordinates[col_idx + 1],
1919 y_coordinates[row_idx],
1920 );
1921 let text = row_build
1922 .cell_texts
1923 .get(col_idx)
1924 .cloned()
1925 .unwrap_or_default();
1926 let mut content = Vec::new();
1927 if !text.trim().is_empty() {
1928 content.push(TableToken {
1929 base: TextChunk {
1930 value: text.trim().to_string(),
1931 bbox: cell_bbox.clone(),
1932 font_name: "OCR".to_string(),
1933 font_size: (row_build.top_y - row_build.bottom_y).max(6.0),
1934 font_weight: if row_idx == 0 { 700.0 } else { 400.0 },
1935 italic_angle: 0.0,
1936 font_color: "#000000".to_string(),
1937 contrast_ratio: 21.0,
1938 symbol_ends: Vec::new(),
1939 text_format: TextFormat::Normal,
1940 text_type: TextType::Regular,
1941 pdf_layer: PdfLayer::Content,
1942 ocg_visible: true,
1943 index: None,
1944 page_number: image.bbox.page_number,
1945 level: None,
1946 mcid: None,
1947 },
1948 token_type: TableTokenType::Text,
1949 });
1950 }
1951 cells.push(TableBorderCell {
1952 bbox: cell_bbox,
1953 index: None,
1954 level: None,
1955 row_number: row_idx,
1956 col_number: col_idx,
1957 row_span: 1,
1958 col_span: 1,
1959 content,
1960 contents: Vec::new(),
1961 semantic_type: None,
1962 });
1963 }
1964 rows.push(TableBorderRow {
1965 bbox: row_bbox,
1966 index: None,
1967 level: None,
1968 row_number: row_idx,
1969 cells,
1970 semantic_type: None,
1971 });
1972 }
1973
1974 Some(TableBorder {
1975 bbox: image.bbox.clone(),
1976 index: None,
1977 level: None,
1978 x_coordinates: x_coordinates.clone(),
1979 x_widths: vec![0.0; x_coordinates.len()],
1980 y_coordinates: y_coordinates.clone(),
1981 y_widths: vec![0.0; y_coordinates.len()],
1982 rows,
1983 num_rows: built_rows.len(),
1984 num_columns: centers.len(),
1985 is_bad_table: false,
1986 is_table_transformer: true,
1987 previous_table: None,
1988 next_table: None,
1989 })
1990}
1991
1992fn is_matrixish_ocr_artifact_table(table: &TableBorder) -> bool {
1993 if !table.is_table_transformer
1994 || table.num_rows < 2
1995 || table.num_rows > 4
1996 || table.num_columns < 3
1997 || table.bbox.height() > table.bbox.width() * 0.55
1998 {
1999 return false;
2000 }
2001
2002 let texts: Vec<String> = table
2003 .rows
2004 .iter()
2005 .flat_map(|row| row.cells.iter())
2006 .map(table_cell_text)
2007 .filter(|text| !text.is_empty())
2008 .collect();
2009 if texts.len() < 6 {
2010 return false;
2011 }
2012
2013 let substantive_cells = texts
2014 .iter()
2015 .filter(|text| is_substantive_ocr_cell_text(text))
2016 .count();
2017 let short_cells = texts
2018 .iter()
2019 .filter(|text| is_short_ocr_cell_text(text))
2020 .count();
2021 let ambiguous_cells = texts
2022 .iter()
2023 .filter(|text| is_ambiguous_matrix_cell_text(text))
2024 .count();
2025
2026 substantive_cells == 0
2027 && short_cells * 10 >= texts.len() * 8
2028 && ambiguous_cells * 10 >= texts.len() * 5
2029}
2030
2031fn table_cell_text(cell: &TableBorderCell) -> String {
2032 cell.content
2033 .iter()
2034 .map(|token| token.base.value.trim())
2035 .filter(|value| !value.is_empty())
2036 .collect::<Vec<_>>()
2037 .join(" ")
2038}
2039
2040fn is_substantive_ocr_cell_text(text: &str) -> bool {
2041 text.split_whitespace().any(is_substantive_table_word)
2042}
2043
2044fn is_short_ocr_cell_text(text: &str) -> bool {
2045 let normalized: String = text
2046 .chars()
2047 .filter(|ch| ch.is_alphanumeric())
2048 .flat_map(char::to_lowercase)
2049 .collect();
2050 !normalized.is_empty() && normalized.len() <= 4
2051}
2052
2053fn is_ambiguous_matrix_cell_text(text: &str) -> bool {
2054 if text.contains(['/', '\\', '=', '|', '[', ']', '{', '}', '(', ')']) {
2055 return true;
2056 }
2057
2058 let normalized: String = text
2059 .chars()
2060 .filter(|ch| ch.is_alphanumeric())
2061 .flat_map(char::to_lowercase)
2062 .collect();
2063 !normalized.is_empty()
2064 && normalized.len() <= 4
2065 && normalized
2066 .chars()
2067 .all(|ch| matches!(ch, '0' | '1' | 'o' | 'd' | 'q' | 'i' | 'l'))
2068}
2069
2070fn recover_bordered_raster_caption(image_path: &Path, image: &ImageChunk) -> Option<TextChunk> {
2071 let gray = image::open(image_path).ok()?.to_luma8();
2072 recover_bordered_raster_caption_from_gray(&gray, image)
2073}
2074
2075fn recover_bordered_raster_caption_from_gray(
2076 gray: &GrayImage,
2077 image: &ImageChunk,
2078) -> Option<TextChunk> {
2079 let grid = detect_bordered_raster_grid(gray)?;
2080 let first_h = *grid.horizontal_lines.first()?;
2081 if first_h <= 2 {
2082 return None;
2083 }
2084
2085 let crop = gray.view(0, 0, gray.width(), first_h).to_image();
2086 let caption_text = normalize_caption_text(&run_tesseract_plain_text(&crop, "7")?);
2087 if caption_text.is_empty() || !caption_text.chars().any(|ch| ch.is_alphabetic()) {
2088 return None;
2089 }
2090
2091 let bbox = raster_box_to_page_bbox(
2092 image,
2093 0,
2094 0,
2095 gray.width(),
2096 first_h.max(1),
2097 gray.width().max(1),
2098 gray.height().max(1),
2099 )?;
2100 let font_size = (bbox.height() * 0.55).clamp(10.0, 16.0);
2101 Some(TextChunk {
2102 value: caption_text,
2103 bbox,
2104 font_name: "OCR".to_string(),
2105 font_size,
2106 font_weight: 700.0,
2107 italic_angle: 0.0,
2108 font_color: "#000000".to_string(),
2109 contrast_ratio: 21.0,
2110 symbol_ends: Vec::new(),
2111 text_format: TextFormat::Normal,
2112 text_type: TextType::Regular,
2113 pdf_layer: PdfLayer::Content,
2114 ocg_visible: true,
2115 index: None,
2116 page_number: image.bbox.page_number,
2117 level: None,
2118 mcid: None,
2119 })
2120}
2121
2122fn bordered_raster_table_is_plausible(table: &TableBorder) -> bool {
2123 if table.num_rows < 2 || table.num_columns < 2 {
2124 return false;
2125 }
2126 let mut token_counts: Vec<usize> = Vec::new();
2130 for row in &table.rows {
2131 for cell in &row.cells {
2132 let n = cell
2133 .content
2134 .iter()
2135 .map(|t| t.base.value.split_whitespace().count())
2136 .sum::<usize>();
2137 if n > 0 {
2138 token_counts.push(n);
2139 }
2140 }
2141 }
2142 if token_counts.is_empty() {
2143 return false;
2144 }
2145 let mut sorted = token_counts.clone();
2146 sorted.sort_unstable();
2147 let median = sorted[sorted.len() / 2].max(1);
2148 let max = *token_counts.iter().max().unwrap_or(&0);
2149 if max >= 8 && max >= median.saturating_mul(8) {
2151 return false;
2152 }
2153 true
2154}
2155
2156fn recover_bordered_raster_table(image_path: &Path, image: &ImageChunk) -> Option<TableBorder> {
2157 let gray = image::open(image_path).ok()?.to_luma8();
2158 recover_bordered_raster_table_from_gray(&gray, image)
2159}
2160
2161fn recover_bordered_raster_table_from_gray(
2162 gray: &GrayImage,
2163 image: &ImageChunk,
2164) -> Option<TableBorder> {
2165 let grid = detect_bordered_raster_grid(gray)?;
2166 let num_cols = grid.vertical_lines.len().checked_sub(1)?;
2167 let num_rows = grid.horizontal_lines.len().checked_sub(1)?;
2168 if num_cols < 2 || num_rows < 2 {
2169 return None;
2170 }
2171 let table_bbox = raster_box_to_page_bbox(
2172 image,
2173 *grid.vertical_lines.first()?,
2174 *grid.horizontal_lines.first()?,
2175 *grid.vertical_lines.last()?,
2176 *grid.horizontal_lines.last()?,
2177 gray.width(),
2178 gray.height(),
2179 )?;
2180
2181 let x_coordinates = raster_boundaries_to_page(
2182 &grid.vertical_lines,
2183 image.bbox.left_x,
2184 image.bbox.right_x,
2185 gray.width(),
2186 )?;
2187 let y_coordinates = raster_boundaries_to_page_desc(
2188 &grid.horizontal_lines,
2189 image.bbox.bottom_y,
2190 image.bbox.top_y,
2191 gray.height(),
2192 )?;
2193
2194 if !bordered_grid_has_cell_ink(gray, &grid) {
2195 return None;
2196 }
2197
2198 let mut rows = Vec::with_capacity(num_rows);
2199 let mut non_empty_cells = 0usize;
2200 let mut rows_with_text = 0usize;
2201 let mut total_cells = 0usize;
2202 let mut whole_table_buckets =
2203 collect_bordered_table_ocr_buckets(gray, &grid, num_rows, num_cols)
2204 .unwrap_or_else(|| vec![Vec::new(); num_rows * num_cols]);
2205 let allow_per_cell_fallback =
2206 num_rows.saturating_mul(num_cols) <= MAX_BORDERED_TABLE_PER_CELL_FALLBACK_CELLS;
2207 for row_idx in 0..num_rows {
2208 let row_bbox = BoundingBox::new(
2209 image.bbox.page_number,
2210 image.bbox.left_x,
2211 y_coordinates[row_idx + 1],
2212 image.bbox.right_x,
2213 y_coordinates[row_idx],
2214 );
2215 let mut cells = Vec::with_capacity(num_cols);
2216 let mut row_has_text = false;
2217
2218 for col_idx in 0..num_cols {
2219 let x1 = grid.vertical_lines[col_idx];
2220 let x2 = grid.vertical_lines[col_idx + 1];
2221 let y1 = grid.horizontal_lines[row_idx];
2222 let y2 = grid.horizontal_lines[row_idx + 1];
2223 let cell_bbox = BoundingBox::new(
2224 image.bbox.page_number,
2225 x_coordinates[col_idx],
2226 y_coordinates[row_idx + 1],
2227 x_coordinates[col_idx + 1],
2228 y_coordinates[row_idx],
2229 );
2230 let bucket_idx = row_idx * num_cols + col_idx;
2231 let text = if let Some(parts) = whole_table_buckets.get_mut(bucket_idx) {
2232 if parts.is_empty() {
2233 String::new()
2234 } else {
2235 parts.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
2236 let raw = parts
2237 .iter()
2238 .map(|(_, _, text)| text.as_str())
2239 .collect::<Vec<_>>()
2240 .join(" ");
2241 normalize_raster_cell_text(row_idx, col_idx, raw)
2242 }
2243 } else {
2244 String::new()
2245 };
2246 let text = if text.is_empty() && allow_per_cell_fallback {
2247 extract_raster_cell_text(gray, row_idx, col_idx, x1, y1, x2, y2).unwrap_or_default()
2248 } else {
2249 text
2250 };
2251 total_cells += 1;
2252
2253 let mut content = Vec::new();
2254 if !text.is_empty() {
2255 row_has_text = true;
2256 non_empty_cells += 1;
2257 content.push(TableToken {
2258 base: TextChunk {
2259 value: text,
2260 bbox: cell_bbox.clone(),
2261 font_name: "OCR".to_string(),
2262 font_size: (cell_bbox.height() * 0.55).max(6.0),
2263 font_weight: if row_idx == 0 { 700.0 } else { 400.0 },
2264 italic_angle: 0.0,
2265 font_color: "#000000".to_string(),
2266 contrast_ratio: 21.0,
2267 symbol_ends: Vec::new(),
2268 text_format: TextFormat::Normal,
2269 text_type: TextType::Regular,
2270 pdf_layer: PdfLayer::Content,
2271 ocg_visible: true,
2272 index: None,
2273 page_number: image.bbox.page_number,
2274 level: None,
2275 mcid: None,
2276 },
2277 token_type: TableTokenType::Text,
2278 });
2279 }
2280
2281 cells.push(TableBorderCell {
2282 bbox: cell_bbox,
2283 index: None,
2284 level: None,
2285 row_number: row_idx,
2286 col_number: col_idx,
2287 row_span: 1,
2288 col_span: 1,
2289 content,
2290 contents: Vec::new(),
2291 semantic_type: None,
2292 });
2293 }
2294
2295 if row_has_text {
2296 rows_with_text += 1;
2297 }
2298
2299 rows.push(TableBorderRow {
2300 bbox: row_bbox,
2301 index: None,
2302 level: None,
2303 row_number: row_idx,
2304 cells,
2305 semantic_type: None,
2306 });
2307 }
2308
2309 if total_cells == 0 {
2310 return None;
2311 }
2312 let text_cell_ratio = non_empty_cells as f64 / total_cells as f64;
2313 if text_cell_ratio < MIN_RASTER_TABLE_TEXT_CELL_RATIO
2314 || rows_with_text < MIN_RASTER_TABLE_ROWS_WITH_TEXT
2315 {
2316 return None;
2317 }
2318
2319 Some(TableBorder {
2320 bbox: table_bbox,
2321 index: None,
2322 level: None,
2323 x_coordinates: x_coordinates.clone(),
2324 x_widths: vec![0.0; x_coordinates.len()],
2325 y_coordinates: y_coordinates.clone(),
2326 y_widths: vec![0.0; y_coordinates.len()],
2327 rows,
2328 num_rows,
2329 num_columns: num_cols,
2330 is_bad_table: false,
2331 is_table_transformer: true,
2332 previous_table: None,
2333 next_table: None,
2334 })
2335}
2336
2337fn collect_bordered_table_ocr_buckets(
2338 gray: &GrayImage,
2339 grid: &RasterTableGrid,
2340 num_rows: usize,
2341 num_cols: usize,
2342) -> Option<Vec<Vec<(u32, u32, String)>>> {
2343 if num_rows == 0 || num_cols == 0 {
2344 return None;
2345 }
2346
2347 let bordered = expand_white_border(gray, TABLE_RASTER_OCR_BORDER_PX);
2348 let scaled = image::imageops::resize(
2349 &bordered,
2350 bordered.width() * OCR_SCALE_FACTOR,
2351 bordered.height() * OCR_SCALE_FACTOR,
2352 image::imageops::FilterType::Lanczos3,
2353 );
2354 let words = run_tesseract_tsv_words_best(&scaled, &["6", "11"], |_| true)?;
2355 if words.is_empty() {
2358 return None;
2359 }
2360
2361 let mut buckets = vec![Vec::new(); num_rows * num_cols];
2362 let scale = f64::from(OCR_SCALE_FACTOR);
2363 let border = f64::from(TABLE_RASTER_OCR_BORDER_PX);
2364
2365 for word in words {
2366 let cx_scaled = f64::from(word.left) + f64::from(word.width) / 2.0;
2367 let cy_scaled = f64::from(word.top) + f64::from(word.height) / 2.0;
2368
2369 let cx = cx_scaled / scale - border;
2370 let cy = cy_scaled / scale - border;
2371 if cx < 0.0 || cy < 0.0 {
2372 continue;
2373 }
2374
2375 let cx = match u32::try_from(cx.round() as i64) {
2376 Ok(value) => value,
2377 Err(_) => continue,
2378 };
2379 let cy = match u32::try_from(cy.round() as i64) {
2380 Ok(value) => value,
2381 Err(_) => continue,
2382 };
2383
2384 let col_idx = grid
2385 .vertical_lines
2386 .windows(2)
2387 .position(|span| cx >= span[0] && cx < span[1]);
2388 let row_idx = grid
2389 .horizontal_lines
2390 .windows(2)
2391 .position(|span| cy >= span[0] && cy < span[1]);
2392 let (Some(row_idx), Some(col_idx)) = (row_idx, col_idx) else {
2393 continue;
2394 };
2395
2396 buckets[row_idx * num_cols + col_idx].push((cy, cx, word.text));
2397 }
2398
2399 Some(buckets)
2400}
2401
2402fn is_obvious_bar_chart_raster(gray: &GrayImage) -> bool {
2403 let width = gray.width();
2404 let height = gray.height();
2405 if width < 160 || height < 120 {
2406 return false;
2407 }
2408
2409 if detect_bordered_raster_grid_single(gray).is_some() {
2412 return false;
2413 }
2414
2415 let min_ink_pixels = (f64::from(width) * 0.35).ceil() as u32;
2416 let min_run_height = (height / 80).max(6);
2417 let wide_ink_row_runs = merge_runs(
2418 (0..height)
2419 .filter(|&y| count_ink_in_row(gray, y, RASTER_CHART_INK_THRESHOLD) >= min_ink_pixels),
2420 );
2421 let thick_runs = wide_ink_row_runs
2422 .into_iter()
2423 .filter(|(start, end)| end.saturating_sub(*start) + 1 >= min_run_height)
2424 .count();
2425
2426 thick_runs >= 3 || is_obvious_vertical_bar_chart_raster(gray)
2427}
2428
2429fn is_obvious_vertical_bar_chart_raster(gray: &GrayImage) -> bool {
2430 let width = gray.width();
2431 let height = gray.height();
2432 if width < 160 || height < 120 {
2433 return false;
2434 }
2435
2436 let min_ink_pixels = (f64::from(height) * 0.08).ceil() as u32;
2437 let min_bar_width = (width / 28).max(10);
2438 let min_bar_height = (height / 8).max(16);
2439 let max_baseline_delta = (height / 14).max(8);
2440 let min_fill_ratio = 0.10;
2441
2442 let candidate_runs =
2443 merge_runs((0..width).filter(|&x| {
2444 count_ink_in_column(gray, x, RASTER_CHART_INK_THRESHOLD) >= min_ink_pixels
2445 }));
2446 let mut baselines = Vec::new();
2447 let mut has_dominant_bar = false;
2448 let mut qualifying_bars = 0usize;
2449
2450 for (start, end) in candidate_runs {
2451 let run_width = end.saturating_sub(start) + 1;
2452 if run_width < min_bar_width {
2453 continue;
2454 }
2455
2456 let mut top = height;
2457 let mut bottom = 0u32;
2458 let mut ink_pixels = 0usize;
2459 for x in start..=end {
2460 for y in 0..height {
2461 if gray.get_pixel(x, y).0[0] < RASTER_CHART_INK_THRESHOLD {
2462 top = top.min(y);
2463 bottom = bottom.max(y);
2464 ink_pixels += 1;
2465 }
2466 }
2467 }
2468
2469 if top >= height || bottom <= top {
2470 continue;
2471 }
2472
2473 let run_height = bottom.saturating_sub(top) + 1;
2474 if run_height < min_bar_height {
2475 continue;
2476 }
2477
2478 let bbox_area = run_width as usize * run_height as usize;
2479 if bbox_area == 0 {
2480 continue;
2481 }
2482
2483 let fill_ratio = ink_pixels as f64 / bbox_area as f64;
2484 if fill_ratio < min_fill_ratio {
2485 continue;
2486 }
2487
2488 qualifying_bars += 1;
2489 if run_width >= min_bar_width.saturating_mul(2) {
2490 has_dominant_bar = true;
2491 }
2492 baselines.push(bottom);
2493 }
2494
2495 if baselines.len() < 2 {
2496 return false;
2497 }
2498
2499 baselines.sort_unstable();
2500 let median_baseline = baselines[baselines.len() / 2];
2501 let aligned_baselines = baselines
2502 .iter()
2503 .filter(|baseline| baseline.abs_diff(median_baseline) <= max_baseline_delta)
2504 .count();
2505
2506 aligned_baselines >= 2 && (has_dominant_bar || (qualifying_bars >= 4 && aligned_baselines >= 4))
2507}
2508
2509fn is_natural_photograph_raster(gray: &GrayImage) -> bool {
2522 let total = (gray.width() * gray.height()) as usize;
2523 if total < 400 {
2524 return false;
2525 }
2526
2527 if detect_bordered_raster_grid_single(gray).is_some() {
2530 return false;
2531 }
2532
2533 let mut histogram = [0usize; 256];
2534 for pixel in gray.pixels() {
2535 histogram[pixel[0] as usize] += 1;
2536 }
2537
2538 let mid_tone_count: usize = histogram[40..=215].iter().sum();
2539 if mid_tone_count * 10 >= total * 3 {
2540 return true;
2541 }
2542
2543 let mut coarse_histogram = [0usize; 16];
2544 for (value, count) in histogram.iter().enumerate() {
2545 coarse_histogram[value / 16] += count;
2546 }
2547
2548 let occupied_bins = coarse_histogram
2549 .iter()
2550 .filter(|count| **count as f64 >= total as f64 * 0.01)
2551 .count();
2552 let entropy = coarse_histogram.iter().fold(0.0, |acc, count| {
2553 if *count == 0 {
2554 return acc;
2555 }
2556 let probability = *count as f64 / total as f64;
2557 acc - probability * probability.log2()
2558 });
2559
2560 mid_tone_count as f64 / total as f64 >= MIN_BRIGHT_PHOTO_MID_TONE_RATIO
2561 && occupied_bins >= MIN_BRIGHT_PHOTO_HISTOGRAM_BINS
2562 && entropy >= MIN_BRIGHT_PHOTO_ENTROPY
2563}
2564
2565fn is_dark_ui_screenshot_raster(gray: &GrayImage) -> bool {
2568 let total = (gray.width() * gray.height()) as usize;
2569 if total < 400 {
2570 return false;
2571 }
2572
2573 let very_dark_count = gray.pixels().filter(|p| p[0] <= 39).count();
2574 let non_extreme_count = gray.pixels().filter(|p| p[0] >= 15 && p[0] <= 240).count();
2575 let bright_detail_count = gray.pixels().filter(|p| p[0] >= 180 && p[0] <= 245).count();
2576
2577 very_dark_count * 20 >= total * 13
2578 && non_extreme_count * 2 >= total
2579 && bright_detail_count * 20 >= total
2580}
2581
2582fn bordered_grid_has_cell_ink(gray: &GrayImage, grid: &RasterTableGrid) -> bool {
2583 let num_cols = match grid.vertical_lines.len().checked_sub(1) {
2584 Some(value) => value,
2585 None => return false,
2586 };
2587 let num_rows = match grid.horizontal_lines.len().checked_sub(1) {
2588 Some(value) => value,
2589 None => return false,
2590 };
2591 if num_cols == 0 || num_rows == 0 {
2592 return false;
2593 }
2594
2595 let mut total_cells = 0usize;
2596 let mut inked_cells = 0usize;
2597 let mut rows_with_ink = 0usize;
2598
2599 for row_idx in 0..num_rows {
2600 let mut row_has_ink = false;
2601 for col_idx in 0..num_cols {
2602 total_cells += 1;
2603 let x1 = grid.vertical_lines[col_idx];
2604 let x2 = grid.vertical_lines[col_idx + 1];
2605 let y1 = grid.horizontal_lines[row_idx];
2606 let y2 = grid.horizontal_lines[row_idx + 1];
2607
2608 let inset_x = CELL_INSET_PX.min((x2 - x1) / 4);
2609 let inset_y = CELL_INSET_PX.min((y2 - y1) / 4);
2610 let crop_left = x1 + inset_x;
2611 let crop_top = y1 + inset_y;
2612 let crop_width = x2.saturating_sub(x1 + inset_x * 2);
2613 let crop_height = y2.saturating_sub(y1 + inset_y * 2);
2614 if crop_width < MIN_CELL_SIZE_PX || crop_height < MIN_CELL_SIZE_PX {
2615 continue;
2616 }
2617
2618 let dark_pixels = (crop_top..crop_top + crop_height)
2619 .flat_map(|y| (crop_left..crop_left + crop_width).map(move |x| (x, y)))
2620 .filter(|&(x, y)| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
2621 .count();
2622 let area = (crop_width as usize) * (crop_height as usize);
2623 if area == 0 {
2624 continue;
2625 }
2626
2627 let dark_ratio = dark_pixels as f64 / area as f64;
2628 if dark_ratio >= MIN_BORDERED_CELL_DARK_RATIO {
2629 inked_cells += 1;
2630 row_has_ink = true;
2631 }
2632 }
2633 if row_has_ink {
2634 rows_with_ink += 1;
2635 }
2636 }
2637
2638 if total_cells == 0 {
2639 return false;
2640 }
2641
2642 (inked_cells as f64 / total_cells as f64) >= MIN_BORDERED_INKED_CELL_RATIO
2643 && rows_with_ink >= MIN_BORDERED_ROWS_WITH_INK
2644}
2645
2646fn detect_bordered_raster_grid(gray: &GrayImage) -> Option<RasterTableGrid> {
2647 let mut best_grid: Option<(RasterTableGrid, f64)> = None;
2648 for variant in build_ocr_variants(gray) {
2649 let Some((grid, score)) = detect_bordered_raster_grid_single(&variant) else {
2650 continue;
2651 };
2652 match &best_grid {
2653 Some((_, best_score)) if *best_score >= score => {}
2654 _ => best_grid = Some((grid, score)),
2655 }
2656 }
2657 let h = gray.height();
2660 for crop_frac in [0.12_f64, 0.18, 0.25] {
2661 let top = ((h as f64) * crop_frac).round() as u32;
2662 if top + 80 >= h {
2663 continue;
2664 }
2665 let cropped = image::imageops::crop_imm(gray, 0, top, gray.width(), h - top).to_image();
2666 for variant in build_ocr_variants(&cropped) {
2667 let Some((mut grid, score)) = detect_bordered_raster_grid_single(&variant) else {
2668 continue;
2669 };
2670 for y in &mut grid.horizontal_lines {
2671 *y = y.saturating_add(top);
2672 }
2673 match &best_grid {
2674 Some((_, best_score)) if *best_score >= score => {}
2675 _ => best_grid = Some((grid, score)),
2676 }
2677 }
2678 }
2679 best_grid.map(|(grid, _)| grid)
2680}
2681
2682fn detect_bordered_raster_grid_single(gray: &GrayImage) -> Option<(RasterTableGrid, f64)> {
2683 let width = gray.width();
2684 let height = gray.height();
2685 if width < 100 || height < 80 {
2686 return None;
2687 }
2688
2689 let min_vertical_dark = (f64::from(height) * MIN_LINE_DARK_RATIO).ceil() as u32;
2690 let min_horizontal_dark = (f64::from(width) * MIN_LINE_DARK_RATIO).ceil() as u32;
2691
2692 let vertical_runs =
2693 merge_runs((0..width).filter(|&x| count_dark_in_column(gray, x) >= min_vertical_dark));
2694 let horizontal_runs =
2695 merge_runs((0..height).filter(|&y| count_dark_in_row(gray, y) >= min_horizontal_dark));
2696 if vertical_runs.len() < MIN_BORDERED_VERTICAL_LINES
2697 || horizontal_runs.len() < MIN_BORDERED_HORIZONTAL_LINES
2698 {
2699 return None;
2700 }
2701
2702 let mut vertical_lines: Vec<u32> = vertical_runs
2703 .into_iter()
2704 .map(|(start, end)| (start + end) / 2)
2705 .collect();
2706 let mut horizontal_lines: Vec<u32> = horizontal_runs
2707 .into_iter()
2708 .map(|(start, end)| (start + end) / 2)
2709 .collect();
2710
2711 let (&rough_min_x, &rough_max_x) = vertical_lines.first().zip(vertical_lines.last())?;
2712 let (&rough_min_y, &rough_max_y) = horizontal_lines.first().zip(horizontal_lines.last())?;
2713 if rough_max_x <= rough_min_x || rough_max_y <= rough_min_y {
2714 return None;
2715 }
2716
2717 vertical_lines.retain(|&x| {
2718 dark_ratio_in_column(gray, x, rough_min_y, rough_max_y) >= MIN_TRUE_GRID_LINE_CONTINUITY
2719 });
2720 horizontal_lines.retain(|&y| {
2721 dark_ratio_in_row(gray, y, rough_min_x, rough_max_x) >= MIN_TRUE_GRID_LINE_CONTINUITY
2722 });
2723 if vertical_lines.len() < MIN_BORDERED_VERTICAL_LINES
2724 || horizontal_lines.len() < MIN_BORDERED_HORIZONTAL_LINES
2725 {
2726 return None;
2727 }
2728
2729 if vertical_lines
2730 .windows(2)
2731 .any(|w| w[1] <= w[0] + MIN_CELL_SIZE_PX)
2732 || horizontal_lines
2733 .windows(2)
2734 .any(|w| w[1] <= w[0] + MIN_CELL_SIZE_PX)
2735 {
2736 return None;
2737 }
2738 if !grid_lines_are_continuous(&vertical_lines, &horizontal_lines, gray) {
2739 return None;
2740 }
2741
2742 let continuity = grid_continuity_score(&vertical_lines, &horizontal_lines, gray);
2743 let line_score = vertical_lines.len() as f64 + horizontal_lines.len() as f64;
2744 let score = continuity * 100.0 + line_score;
2745
2746 Some((
2747 RasterTableGrid {
2748 vertical_lines,
2749 horizontal_lines,
2750 },
2751 score,
2752 ))
2753}
2754
2755fn grid_lines_are_continuous(
2756 vertical_lines: &[u32],
2757 horizontal_lines: &[u32],
2758 gray: &GrayImage,
2759) -> bool {
2760 let Some((&min_x, &max_x)) = vertical_lines.first().zip(vertical_lines.last()) else {
2761 return false;
2762 };
2763 let Some((&min_y, &max_y)) = horizontal_lines.first().zip(horizontal_lines.last()) else {
2764 return false;
2765 };
2766 if max_x <= min_x || max_y <= min_y {
2767 return false;
2768 }
2769
2770 vertical_lines
2771 .iter()
2772 .all(|&x| dark_ratio_in_column(gray, x, min_y, max_y) >= MIN_TRUE_GRID_LINE_CONTINUITY)
2773 && horizontal_lines
2774 .iter()
2775 .all(|&y| dark_ratio_in_row(gray, y, min_x, max_x) >= MIN_TRUE_GRID_LINE_CONTINUITY)
2776}
2777
2778fn grid_continuity_score(
2779 vertical_lines: &[u32],
2780 horizontal_lines: &[u32],
2781 gray: &GrayImage,
2782) -> f64 {
2783 let Some((&min_x, &max_x)) = vertical_lines.first().zip(vertical_lines.last()) else {
2784 return 0.0;
2785 };
2786 let Some((&min_y, &max_y)) = horizontal_lines.first().zip(horizontal_lines.last()) else {
2787 return 0.0;
2788 };
2789 if max_x <= min_x || max_y <= min_y {
2790 return 0.0;
2791 }
2792
2793 let mut samples = 0usize;
2794 let mut sum = 0.0;
2795 for &x in vertical_lines {
2796 sum += dark_ratio_in_column(gray, x, min_y, max_y);
2797 samples += 1;
2798 }
2799 for &y in horizontal_lines {
2800 sum += dark_ratio_in_row(gray, y, min_x, max_x);
2801 samples += 1;
2802 }
2803 if samples == 0 {
2804 0.0
2805 } else {
2806 sum / samples as f64
2807 }
2808}
2809
2810fn count_dark_in_column(gray: &GrayImage, x: u32) -> u32 {
2811 count_ink_in_column(gray, x, RASTER_DARK_THRESHOLD)
2812}
2813
2814fn count_ink_in_column(gray: &GrayImage, x: u32, threshold: u8) -> u32 {
2815 (0..gray.height())
2816 .filter(|&y| gray.get_pixel(x, y).0[0] < threshold)
2817 .count() as u32
2818}
2819
2820fn count_dark_in_row(gray: &GrayImage, y: u32) -> u32 {
2821 count_ink_in_row(gray, y, RASTER_DARK_THRESHOLD)
2822}
2823
2824fn count_ink_in_row(gray: &GrayImage, y: u32, threshold: u8) -> u32 {
2825 (0..gray.width())
2826 .filter(|&x| gray.get_pixel(x, y).0[0] < threshold)
2827 .count() as u32
2828}
2829
2830fn dark_ratio_in_column(gray: &GrayImage, x: u32, y1: u32, y2: u32) -> f64 {
2831 if y2 <= y1 || x >= gray.width() {
2832 return 0.0;
2833 }
2834 let dark = (y1..=y2)
2835 .filter(|&y| y < gray.height() && gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
2836 .count();
2837 dark as f64 / f64::from(y2 - y1 + 1)
2838}
2839
2840fn dark_ratio_in_row(gray: &GrayImage, y: u32, x1: u32, x2: u32) -> f64 {
2841 if x2 <= x1 || y >= gray.height() {
2842 return 0.0;
2843 }
2844 let dark = (x1..=x2)
2845 .filter(|&x| x < gray.width() && gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
2846 .count();
2847 dark as f64 / f64::from(x2 - x1 + 1)
2848}
2849
2850fn merge_runs(values: impl Iterator<Item = u32>) -> Vec<(u32, u32)> {
2851 let mut runs = Vec::new();
2852 let mut start = None;
2853 let mut prev = 0u32;
2854 for value in values {
2855 match start {
2856 None => {
2857 start = Some(value);
2858 prev = value;
2859 }
2860 Some(s) if value == prev + 1 => {
2861 prev = value;
2862 start = Some(s);
2863 }
2864 Some(s) => {
2865 runs.push((s, prev));
2866 start = Some(value);
2867 prev = value;
2868 }
2869 }
2870 }
2871 if let Some(s) = start {
2872 runs.push((s, prev));
2873 }
2874 runs
2875}
2876
2877fn build_boundaries_from_centers(
2878 centers: &[f64],
2879 left_edge: f64,
2880 right_edge: f64,
2881 image_width: u32,
2882) -> Vec<f64> {
2883 let mut boundaries = Vec::with_capacity(centers.len() + 1);
2884 boundaries.push(left_edge);
2885 if centers.len() < 2 || image_width == 0 || right_edge <= left_edge {
2886 boundaries.push(right_edge.max(left_edge));
2887 return boundaries;
2888 }
2889
2890 let page_width = right_edge - left_edge;
2891 let mut previous = left_edge;
2892 for pair in centers.windows(2) {
2893 let midpoint_px = ((pair[0] + pair[1]) / 2.0).clamp(0.0, f64::from(image_width));
2894 let boundary = left_edge + midpoint_px / f64::from(image_width) * page_width;
2895 let boundary = boundary.clamp(previous, right_edge);
2896 boundaries.push(boundary);
2897 previous = boundary;
2898 }
2899 boundaries.push(right_edge);
2900 boundaries
2901}
2902
2903fn build_row_boundaries(rows: &[(f64, f64)]) -> Vec<f64> {
2904 let mut boundaries = Vec::with_capacity(rows.len() + 1);
2905 boundaries.push(rows[0].0);
2906 for pair in rows.windows(2) {
2907 boundaries.push((pair[0].1 + pair[1].0) / 2.0);
2908 }
2909 boundaries.push(rows[rows.len() - 1].1);
2910 boundaries
2911}
2912
2913fn raster_boundaries_to_page(
2914 lines: &[u32],
2915 left_edge: f64,
2916 right_edge: f64,
2917 image_width: u32,
2918) -> Option<Vec<f64>> {
2919 if image_width == 0 {
2920 return None;
2921 }
2922 let scale = (right_edge - left_edge) / f64::from(image_width);
2923 Some(
2924 lines
2925 .iter()
2926 .map(|line| left_edge + f64::from(*line) * scale)
2927 .collect(),
2928 )
2929}
2930
2931fn raster_boundaries_to_page_desc(
2932 lines: &[u32],
2933 bottom_edge: f64,
2934 top_edge: f64,
2935 image_height: u32,
2936) -> Option<Vec<f64>> {
2937 if image_height == 0 {
2938 return None;
2939 }
2940 let page_height = top_edge - bottom_edge;
2941 Some(
2942 lines
2943 .iter()
2944 .map(|line| top_edge - f64::from(*line) / f64::from(image_height) * page_height)
2945 .collect(),
2946 )
2947}
2948
2949fn raster_box_to_page_bbox(
2950 image: &ImageChunk,
2951 x1: u32,
2952 y1: u32,
2953 x2: u32,
2954 y2: u32,
2955 image_width: u32,
2956 image_height: u32,
2957) -> Option<BoundingBox> {
2958 if x2 <= x1 || y2 <= y1 || image_width == 0 || image_height == 0 {
2959 return None;
2960 }
2961 let left_x = image.bbox.left_x + image.bbox.width() * (f64::from(x1) / f64::from(image_width));
2962 let right_x = image.bbox.left_x + image.bbox.width() * (f64::from(x2) / f64::from(image_width));
2963 let top_y = image.bbox.top_y - image.bbox.height() * (f64::from(y1) / f64::from(image_height));
2964 let bottom_y =
2965 image.bbox.top_y - image.bbox.height() * (f64::from(y2) / f64::from(image_height));
2966 Some(BoundingBox::new(
2967 image.bbox.page_number,
2968 left_x,
2969 bottom_y,
2970 right_x,
2971 top_y,
2972 ))
2973}
2974
2975fn extract_raster_cell_text(
2976 gray: &GrayImage,
2977 row_idx: usize,
2978 col_idx: usize,
2979 x1: u32,
2980 y1: u32,
2981 x2: u32,
2982 y2: u32,
2983) -> Option<String> {
2984 let inset_x = CELL_INSET_PX.min((x2 - x1) / 4);
2985 let inset_y = CELL_INSET_PX.min((y2 - y1) / 4);
2986 let crop_left = x1 + inset_x;
2987 let crop_top = y1 + inset_y;
2988 let crop_width = x2.saturating_sub(x1 + inset_x * 2);
2989 let crop_height = y2.saturating_sub(y1 + inset_y * 2);
2990 if crop_width < MIN_CELL_SIZE_PX || crop_height < MIN_CELL_SIZE_PX {
2991 return Some(String::new());
2992 }
2993
2994 let cropped = gray
2995 .view(crop_left, crop_top, crop_width, crop_height)
2996 .to_image();
2997 let bordered = expand_white_border(&cropped, 12);
2998 let scaled = image::imageops::resize(
2999 &bordered,
3000 bordered.width() * OCR_SCALE_FACTOR,
3001 bordered.height() * OCR_SCALE_FACTOR,
3002 image::imageops::FilterType::Lanczos3,
3003 );
3004 let psm_modes: [&str; 3] = if row_idx == 0 {
3005 ["6", "11", "7"]
3006 } else {
3007 ["7", "6", "11"]
3008 };
3009 let raw_text = run_tesseract_cell_text_best(&scaled, &psm_modes)?;
3010 Some(normalize_raster_cell_text(row_idx, col_idx, raw_text))
3011}
3012
3013fn expand_white_border(image: &GrayImage, border: u32) -> GrayImage {
3014 let mut expanded = GrayImage::from_pixel(
3015 image.width() + border * 2,
3016 image.height() + border * 2,
3017 Luma([255]),
3018 );
3019 for y in 0..image.height() {
3020 for x in 0..image.width() {
3021 expanded.put_pixel(x + border, y + border, *image.get_pixel(x, y));
3022 }
3023 }
3024 expanded
3025}
3026
3027fn run_tesseract_tsv_words(image: &GrayImage, psm: &str) -> Option<Vec<OcrWord>> {
3028 let words = TesseractCliEngine::with_psm(psm).recognize_psm(image, psm);
3029 (!words.is_empty()).then_some(words)
3030}
3031
3032fn run_tesseract_tsv_words_with_oem(
3033 image: &GrayImage,
3034 psm: &str,
3035 oem: &str,
3036) -> Option<Vec<OcrWord>> {
3037 let engine = TesseractCliEngine {
3038 psm: psm.to_string(),
3039 oem: oem.to_string(),
3040 };
3041 let words = engine.recognize_psm(image, psm);
3042 (!words.is_empty()).then_some(words)
3043}
3044
3045fn run_tesseract_cell_text_best(image: &GrayImage, psm_modes: &[&str]) -> Option<String> {
3046 let mut best: Option<(String, f64)> = None;
3047
3048 let consensus_words = collect_consensus_words(image, psm_modes);
3050 if !consensus_words.is_empty() {
3051 let text = words_to_plain_line_text(&consensus_words);
3052 if !text.is_empty() {
3053 let score = score_ocr_words(&consensus_words, image.width(), image.height());
3054 best = Some((text, score));
3055 }
3056 }
3057
3058 if best.is_none() {
3060 for variant in build_ocr_variants(image) {
3061 for psm in psm_modes {
3062 let Some(words) = run_tesseract_tsv_words(&variant, psm) else {
3063 continue;
3064 };
3065 if words.is_empty() {
3066 continue;
3067 }
3068 let text = words_to_plain_line_text(&words);
3069 if text.is_empty() {
3070 continue;
3071 }
3072 let score = score_ocr_words(&words, variant.width(), variant.height());
3073 match &best {
3074 Some((_, best_score)) if *best_score >= score => {}
3075 _ => best = Some((text, score)),
3076 }
3077
3078 if let Some(text) = run_tesseract_plain_text_with_variant(&variant, psm) {
3079 let norm_len = normalize_text(&text).len() as f64;
3080 if norm_len > 0.0 {
3081 match &best {
3082 Some((_, best_score)) if *best_score >= norm_len => {}
3083 _ => best = Some((text, norm_len)),
3084 }
3085 }
3086 }
3087 }
3088 }
3089 }
3090
3091 best.map(|(text, _)| text)
3092}
3093
3094fn collect_consensus_words(image: &GrayImage, psm_modes: &[&str]) -> Vec<OcrWord> {
3095 let variants = build_ocr_variants(image);
3096
3097 let oems = ["1", "3"]; let mut perspective_best: HashMap<(String, String, String), OcrWord> = HashMap::new();
3112
3113 for variant in &variants {
3114 for psm in psm_modes {
3115 for oem in oems {
3116 let Some(words) = run_tesseract_tsv_words_with_oem(variant, psm, oem) else {
3117 continue;
3118 };
3119 for word in words {
3120 let key = (psm.to_string(), oem.to_string(), word.text.to_lowercase());
3121 perspective_best
3122 .entry(key)
3123 .and_modify(|best| {
3124 if word.confidence > best.confidence {
3125 *best = word.clone();
3126 }
3127 })
3128 .or_insert(word);
3129 }
3130 }
3131 }
3132 }
3133
3134 const MIN_PERSPECTIVES: usize = 2;
3137
3138 let mut text_to_perspectives: HashMap<String, HashSet<(String, String)>> = HashMap::new();
3139 for (psm, oem, norm_text) in perspective_best.keys() {
3140 text_to_perspectives
3141 .entry(norm_text.clone())
3142 .or_default()
3143 .insert((psm.clone(), oem.clone()));
3144 }
3145
3146 let mut consensus: Vec<OcrWord> = text_to_perspectives
3148 .iter()
3149 .filter(|(_, perspectives)| perspectives.len() >= MIN_PERSPECTIVES)
3150 .filter_map(|(norm_text, _)| {
3151 perspective_best
3152 .iter()
3153 .filter(|((_, _, t), _)| t == norm_text)
3154 .max_by(|(_, a), (_, b)| {
3155 a.confidence
3156 .partial_cmp(&b.confidence)
3157 .unwrap_or(std::cmp::Ordering::Equal)
3158 })
3159 .map(|(_, w)| w.clone())
3160 })
3161 .collect();
3162
3163 consensus.sort_by_key(|w| (w.top, w.left));
3164 consensus
3165}
3166
3167fn filter_words_by_spatial_coherence(words: &[OcrWord]) -> Vec<OcrWord> {
3168 if words.len() <= 1 {
3169 return words.to_vec();
3170 }
3171
3172 let median_h: u32 = {
3179 let mut heights: Vec<u32> = words.iter().map(|w| w.height.max(1)).collect();
3180 heights.sort_unstable();
3181 heights[heights.len() / 2]
3182 };
3183 let gap_threshold = (median_h * 3).max(8);
3185 let narrow_threshold = (median_h / 2).max(4);
3187 let min_iso_width = (median_h * 2 / 5).max(4);
3189 let min_iso_height = (median_h * 2 / 5).max(3);
3190
3191 let mut by_line: BTreeMap<(u32, u32, u32), Vec<&OcrWord>> = BTreeMap::new();
3193 for word in words {
3194 by_line.entry(word.line_key).or_default().push(word);
3195 }
3196
3197 let mut filtered = Vec::new();
3198
3199 for line_words in by_line.values_mut() {
3201 if line_words.len() <= 1 {
3202 if let Some(word) = line_words.first() {
3204 if word.width >= min_iso_width && word.height >= min_iso_height {
3205 filtered.push((*word).clone());
3206 }
3207 }
3208 continue;
3209 }
3210
3211 line_words.sort_by_key(|word| word.left);
3212
3213 for (i, word) in line_words.iter().enumerate() {
3215 let is_isolated = if i > 0 {
3216 let prev = line_words[i - 1];
3217 let gap = word
3218 .left
3219 .saturating_sub(prev.left.saturating_add(prev.width));
3220 gap > gap_threshold && word.width < narrow_threshold
3221 } else if i < line_words.len() - 1 {
3222 let next = line_words[i + 1];
3223 let gap = next
3224 .left
3225 .saturating_sub(word.left.saturating_add(word.width));
3226 gap > gap_threshold && word.width < narrow_threshold
3227 } else {
3228 false
3229 };
3230
3231 if !is_isolated {
3232 filtered.push((*word).clone());
3233 }
3234 }
3235 }
3236
3237 filtered
3238}
3239
3240fn cluster_words_by_proximity(words: &[OcrWord], gap_tolerance: u32) -> Vec<Vec<OcrWord>> {
3241 if words.is_empty() {
3242 return Vec::new();
3243 }
3244
3245 let mut sorted_words = words.to_vec();
3246 sorted_words.sort_by_key(|w| (w.top, w.left));
3247
3248 let median_h: i32 = {
3252 let mut heights: Vec<u32> = sorted_words.iter().map(|w| w.height.max(1)).collect();
3253 heights.sort_unstable();
3254 heights[heights.len() / 2] as i32
3255 };
3256 let vertical_tolerance = (median_h / 2).max(2);
3257
3258 let mut clusters: Vec<Vec<OcrWord>> = Vec::new();
3259 let mut current_cluster = vec![sorted_words[0].clone()];
3260
3261 for word in &sorted_words[1..] {
3262 if let Some(last) = current_cluster.last() {
3263 let vertical_gap = (word.top as i32 - last.top as i32).abs();
3264 let horizontal_gap = word
3265 .left
3266 .saturating_sub(last.left.saturating_add(last.width));
3267
3268 if vertical_gap <= vertical_tolerance && horizontal_gap <= gap_tolerance {
3269 current_cluster.push(word.clone());
3270 } else {
3271 clusters.push(current_cluster);
3272 current_cluster = vec![word.clone()];
3273 }
3274 }
3275 }
3276
3277 if !current_cluster.is_empty() {
3278 clusters.push(current_cluster);
3279 }
3280
3281 clusters
3282}
3283
3284fn words_to_plain_line_text(words: &[OcrWord]) -> String {
3285 let filtered_words = filter_words_by_spatial_coherence(words);
3287
3288 if filtered_words.is_empty() {
3289 return String::new();
3290 }
3291
3292 let avg_word_width =
3294 filtered_words.iter().map(|w| w.width).sum::<u32>() as f64 / filtered_words.len() as f64;
3295 let gap_tolerance = (avg_word_width * 0.8).ceil() as u32;
3296 let clusters = cluster_words_by_proximity(&filtered_words, gap_tolerance);
3297
3298 let mut lines: Vec<String> = Vec::new();
3299 for cluster in clusters {
3300 let mut sorted_cluster = cluster;
3301 sorted_cluster.sort_by_key(|w| w.left);
3302
3303 let line = sorted_cluster
3304 .iter()
3305 .map(|word| word.text.as_str())
3306 .collect::<Vec<_>>()
3307 .join(" ")
3308 .trim()
3309 .to_string();
3310
3311 if !line.is_empty() {
3312 lines.push(line);
3313 }
3314 }
3315
3316 lines.join(" ")
3317}
3318
3319fn run_tesseract_tsv_words_best<F>(
3324 image: &GrayImage,
3325 psm_modes: &[&str],
3326 accept: F,
3327) -> Option<Vec<OcrWord>>
3328where
3329 F: Fn(&[OcrWord]) -> bool,
3330{
3331 let variants = build_ocr_variants(image);
3332 let mut best: Option<OcrCandidateScore> = None;
3333
3334 for variant in variants {
3335 for psm in psm_modes {
3336 let Some(words) = run_tesseract_tsv_words(&variant, psm) else {
3337 continue;
3338 };
3339 if !accept(&words) {
3340 continue;
3341 }
3342 let score = score_ocr_words(&words, variant.width(), variant.height());
3343 match &best {
3344 Some(current) if current.score >= score => {}
3345 _ => {
3346 best = Some(OcrCandidateScore { words, score });
3347 }
3348 }
3349 }
3350 }
3351
3352 best.map(|candidate| candidate.words)
3353}
3354
3355fn score_ocr_words(words: &[OcrWord], width: u32, height: u32) -> f64 {
3356 if words.is_empty() || width == 0 || height == 0 {
3357 return 0.0;
3358 }
3359
3360 let mut by_line: BTreeMap<(u32, u32, u32), Vec<&OcrWord>> = BTreeMap::new();
3361 let mut alpha_words = 0usize;
3362 let mut area_coverage = 0f64;
3363 let mut vertical_spread_top = height;
3364 let mut vertical_spread_bottom = 0u32;
3365 let mut total_confidence = 0f64;
3366
3367 for word in words {
3368 by_line.entry(word.line_key).or_default().push(word);
3369 if word.text.chars().any(|ch| ch.is_alphabetic()) {
3370 alpha_words += 1;
3371 }
3372 area_coverage += f64::from(word.width.saturating_mul(word.height));
3373 vertical_spread_top = vertical_spread_top.min(word.top);
3374 vertical_spread_bottom = vertical_spread_bottom.max(word.top.saturating_add(word.height));
3375 total_confidence += word.confidence;
3376 }
3377
3378 let line_count = by_line.len() as f64;
3379 let alpha_ratio = alpha_words as f64 / words.len() as f64;
3380 let density = (area_coverage / f64::from(width.saturating_mul(height))).clamp(0.0, 1.0);
3381 let spread = if vertical_spread_bottom > vertical_spread_top {
3382 f64::from(vertical_spread_bottom - vertical_spread_top) / f64::from(height)
3383 } else {
3384 0.0
3385 };
3386 let avg_confidence = total_confidence / words.len() as f64;
3387 let confidence_bonus = (avg_confidence / 100.0).clamp(0.0, 1.0);
3389
3390 let horizontal_spread = if words.is_empty() {
3392 0.0
3393 } else {
3394 let min_left = words.iter().map(|w| w.left).min().unwrap_or(0);
3395 let max_right = words
3396 .iter()
3397 .map(|w| w.left + w.width)
3398 .max()
3399 .unwrap_or(width);
3400 f64::from(max_right.saturating_sub(min_left)) / f64::from(width)
3401 };
3402
3403 words.len() as f64
3404 + line_count * 1.5
3405 + alpha_ratio * 6.0
3406 + density * 25.0
3407 + spread * 3.0
3408 + horizontal_spread * 2.0
3409 + confidence_bonus * 5.0 }
3411
3412fn build_ocr_variants(gray: &GrayImage) -> Vec<GrayImage> {
3413 vec![
3414 gray.clone(),
3415 contrast_stretch(gray),
3416 global_otsu_binarize(gray),
3417 local_mean_binarize(gray, LOCAL_BINARIZATION_RADIUS),
3418 morphological_clean(gray),
3420 unsharp_mask(gray, 1.5),
3423 gamma_correct(gray, 0.6),
3425 ]
3426}
3427
3428fn unsharp_mask(gray: &GrayImage, amount: f32) -> GrayImage {
3432 let width = gray.width() as i32;
3433 let height = gray.height() as i32;
3434 let mut out = GrayImage::new(gray.width(), gray.height());
3435 for y in 0..height {
3436 for x in 0..width {
3437 let mut sum = 0i32;
3438 let mut count = 0i32;
3439 for dy in -1i32..=1 {
3440 for dx in -1i32..=1 {
3441 let nx = x + dx;
3442 let ny = y + dy;
3443 if nx >= 0 && ny >= 0 && nx < width && ny < height {
3444 sum += gray.get_pixel(nx as u32, ny as u32).0[0] as i32;
3445 count += 1;
3446 }
3447 }
3448 }
3449 let blurred = if count > 0 {
3450 sum / count
3451 } else {
3452 gray.get_pixel(x as u32, y as u32).0[0] as i32
3453 };
3454 let original = gray.get_pixel(x as u32, y as u32).0[0] as i32;
3455 let sharpened = original + ((original - blurred) as f32 * amount) as i32;
3456 out.put_pixel(x as u32, y as u32, Luma([sharpened.clamp(0, 255) as u8]));
3457 }
3458 }
3459 out
3460}
3461
3462fn gamma_correct(gray: &GrayImage, gamma: f32) -> GrayImage {
3465 let mut out = GrayImage::new(gray.width(), gray.height());
3466 for (x, y, pixel) in gray.enumerate_pixels() {
3467 let v = pixel.0[0] as f32 / 255.0;
3468 let corrected = (v.powf(gamma) * 255.0).round() as u8;
3469 out.put_pixel(x, y, Luma([corrected]));
3470 }
3471 out
3472}
3473
3474fn contrast_stretch(gray: &GrayImage) -> GrayImage {
3475 let mut min_val = u8::MAX;
3476 let mut max_val = u8::MIN;
3477 for pixel in gray.pixels() {
3478 let value = pixel.0[0];
3479 min_val = min_val.min(value);
3480 max_val = max_val.max(value);
3481 }
3482
3483 if max_val <= min_val {
3484 return gray.clone();
3485 }
3486
3487 let in_range = (max_val - min_val) as f64;
3488 let mut out = GrayImage::new(gray.width(), gray.height());
3489 for (x, y, pixel) in gray.enumerate_pixels() {
3490 let value = pixel.0[0];
3491 let normalized = ((value.saturating_sub(min_val)) as f64 / in_range * 255.0).round() as u8;
3492 out.put_pixel(x, y, Luma([normalized]));
3493 }
3494 out
3495}
3496
3497fn global_otsu_binarize(gray: &GrayImage) -> GrayImage {
3498 let threshold = otsu_threshold(gray);
3499 let mut out = GrayImage::new(gray.width(), gray.height());
3500 for (x, y, pixel) in gray.enumerate_pixels() {
3501 let value = if pixel.0[0] <= threshold { 0 } else { 255 };
3502 out.put_pixel(x, y, Luma([value]));
3503 }
3504 out
3505}
3506
3507fn otsu_threshold(gray: &GrayImage) -> u8 {
3508 let mut histogram = [0u64; 256];
3509 for pixel in gray.pixels() {
3510 histogram[pixel.0[0] as usize] += 1;
3511 }
3512
3513 let total = (gray.width() as u64) * (gray.height() as u64);
3514 if total == 0 {
3515 return 127;
3516 }
3517
3518 let sum_total: f64 = histogram
3519 .iter()
3520 .enumerate()
3521 .map(|(idx, count)| idx as f64 * *count as f64)
3522 .sum();
3523
3524 let mut sum_background = 0f64;
3525 let mut weight_background = 0f64;
3526 let mut max_variance = -1f64;
3527 let mut best_threshold = 127u8;
3528
3529 for (idx, count) in histogram.iter().enumerate() {
3530 weight_background += *count as f64;
3531 if weight_background <= 0.0 {
3532 continue;
3533 }
3534
3535 let weight_foreground = total as f64 - weight_background;
3536 if weight_foreground <= 0.0 {
3537 break;
3538 }
3539
3540 sum_background += idx as f64 * *count as f64;
3541 let mean_background = sum_background / weight_background;
3542 let mean_foreground = (sum_total - sum_background) / weight_foreground;
3543 let between_class_variance =
3544 weight_background * weight_foreground * (mean_background - mean_foreground).powi(2);
3545
3546 if between_class_variance > max_variance {
3547 max_variance = between_class_variance;
3548 best_threshold = idx as u8;
3549 }
3550 }
3551
3552 best_threshold
3553}
3554
3555fn local_mean_binarize(gray: &GrayImage, radius: u32) -> GrayImage {
3556 if gray.width() == 0 || gray.height() == 0 {
3557 return gray.clone();
3558 }
3559
3560 let width = gray.width() as usize;
3561 let height = gray.height() as usize;
3562 let (integral, stride) = integral_image(gray);
3563 let mut out = GrayImage::new(gray.width(), gray.height());
3564
3565 for y in 0..height {
3566 for x in 0..width {
3567 let x1 = x.saturating_sub(radius as usize);
3568 let y1 = y.saturating_sub(radius as usize);
3569 let x2 = (x + radius as usize).min(width - 1);
3570 let y2 = (y + radius as usize).min(height - 1);
3571
3572 let area = (x2 - x1 + 1) * (y2 - y1 + 1);
3573 let sum = region_sum(&integral, stride, x1, y1, x2, y2);
3574 let local_mean = (sum as f64) / (area as f64);
3575 let offset = if area >= MIN_BINARIZATION_BLOCK_PIXELS {
3576 8.0
3577 } else {
3578 4.0
3579 };
3580 let threshold = (local_mean - offset).clamp(0.0, 255.0);
3581
3582 let pixel_value = gray.get_pixel(x as u32, y as u32).0[0] as f64;
3583 let value = if pixel_value <= threshold { 0 } else { 255 };
3584 out.put_pixel(x as u32, y as u32, Luma([value]));
3585 }
3586 }
3587
3588 out
3589}
3590
3591fn morphological_clean(gray: &GrayImage) -> GrayImage {
3594 if gray.width() == 0 || gray.height() == 0 {
3595 return gray.clone();
3596 }
3597
3598 let binary = global_otsu_binarize(gray);
3600
3601 let dilated = morphological_dilate(&binary, 2);
3603 morphological_erode(&dilated, 2)
3604}
3605
3606fn morphological_dilate(gray: &GrayImage, iterations: u32) -> GrayImage {
3607 let mut result = gray.clone();
3608 for _ in 0..iterations {
3609 let mut next = GrayImage::from_pixel(gray.width(), gray.height(), Luma([255]));
3610
3611 for y in 1..gray.height().saturating_sub(1) {
3612 for x in 1..gray.width().saturating_sub(1) {
3613 let mut has_black = false;
3615 for dy in 0..3 {
3616 for dx in 0..3 {
3617 let px = result.get_pixel(x + dx - 1, y + dy - 1).0[0];
3618 if px < 128 {
3619 has_black = true;
3620 break;
3621 }
3622 }
3623 if has_black {
3624 break;
3625 }
3626 }
3627 next.put_pixel(x, y, if has_black { Luma([0]) } else { Luma([255]) });
3628 }
3629 }
3630 result = next;
3631 }
3632 result
3633}
3634
3635fn morphological_erode(gray: &GrayImage, iterations: u32) -> GrayImage {
3636 let mut result = gray.clone();
3637 for _ in 0..iterations {
3638 let mut next = GrayImage::from_pixel(gray.width(), gray.height(), Luma([255]));
3639
3640 for y in 1..gray.height().saturating_sub(1) {
3641 for x in 1..gray.width().saturating_sub(1) {
3642 let mut all_black = true;
3645 for dy in 0..3 {
3646 for dx in 0..3 {
3647 let px = result.get_pixel(x + dx - 1, y + dy - 1).0[0];
3648 if px >= 128 {
3649 all_black = false;
3650 break;
3651 }
3652 }
3653 if !all_black {
3654 break;
3655 }
3656 }
3657 next.put_pixel(x, y, if all_black { Luma([0]) } else { Luma([255]) });
3658 }
3659 }
3660 result = next;
3661 }
3662 result
3663}
3664
3665fn integral_image(gray: &GrayImage) -> (Vec<u64>, usize) {
3666 let width = gray.width() as usize;
3667 let height = gray.height() as usize;
3668 let stride = width + 1;
3669 let mut integral = vec![0u64; (width + 1) * (height + 1)];
3670
3671 for y in 0..height {
3672 let mut row_sum = 0u64;
3673 for x in 0..width {
3674 row_sum += gray.get_pixel(x as u32, y as u32).0[0] as u64;
3675 let idx = (y + 1) * stride + (x + 1);
3676 integral[idx] = integral[y * stride + (x + 1)] + row_sum;
3677 }
3678 }
3679
3680 (integral, stride)
3681}
3682
3683fn region_sum(integral: &[u64], stride: usize, x1: usize, y1: usize, x2: usize, y2: usize) -> u64 {
3684 let a = integral[y1 * stride + x1];
3685 let b = integral[y1 * stride + (x2 + 1)];
3686 let c = integral[(y2 + 1) * stride + x1];
3687 let d = integral[(y2 + 1) * stride + (x2 + 1)];
3688 d + a - b - c
3689}
3690
3691fn run_tesseract_plain_text(image: &GrayImage, psm: &str) -> Option<String> {
3692 run_tesseract_plain_text_with_variant(image, psm)
3693}
3694
3695fn run_tesseract_plain_text_with_variant(image: &GrayImage, psm: &str) -> Option<String> {
3696 let words = TesseractCliEngine::with_psm(psm).recognize_psm(image, psm);
3697 let text = words_to_plain_line_text(&words);
3698 (!text.is_empty()).then_some(text)
3699}
3700
3701fn words_to_text_chunks(
3702 words: &[OcrWord],
3703 image: &ImageChunk,
3704 text_chunks: &[TextChunk],
3705) -> Vec<TextChunk> {
3706 let mut image_size = (0u32, 0u32);
3707 for word in words {
3708 image_size.0 = image_size.0.max(word.left.saturating_add(word.width));
3709 image_size.1 = image_size.1.max(word.top.saturating_add(word.height));
3710 }
3711 if image_size.0 == 0 || image_size.1 == 0 {
3712 return Vec::new();
3713 }
3714
3715 let mut dedupe: HashMap<String, usize> = HashMap::new();
3716 for chunk in text_chunks {
3717 dedupe.insert(normalize_text(&chunk.value), dedupe.len());
3718 }
3719
3720 let mut recovered = Vec::new();
3721 for word in words {
3722 let normalized = normalize_text(&word.text);
3723 if normalized.len() >= 4 && dedupe.contains_key(&normalized) {
3724 continue;
3725 }
3726
3727 let left_ratio = f64::from(word.left) / f64::from(image_size.0);
3728 let right_ratio = f64::from(word.left.saturating_add(word.width)) / f64::from(image_size.0);
3729 let top_ratio = f64::from(word.top) / f64::from(image_size.1);
3730 let bottom_ratio =
3731 f64::from(word.top.saturating_add(word.height)) / f64::from(image_size.1);
3732
3733 let left_x = image.bbox.left_x + image.bbox.width() * left_ratio;
3734 let right_x = image.bbox.left_x + image.bbox.width() * right_ratio;
3735 let top_y = image.bbox.top_y - image.bbox.height() * top_ratio;
3736 let bottom_y = image.bbox.top_y - image.bbox.height() * bottom_ratio;
3737 if right_x <= left_x || top_y <= bottom_y {
3738 continue;
3739 }
3740
3741 recovered.push(TextChunk {
3742 value: word.text.clone(),
3743 bbox: BoundingBox::new(image.bbox.page_number, left_x, bottom_y, right_x, top_y),
3744 font_name: "OCR".to_string(),
3745 font_size: (top_y - bottom_y).max(6.0),
3746 font_weight: 400.0,
3747 italic_angle: 0.0,
3748 font_color: "#000000".to_string(),
3749 contrast_ratio: 21.0,
3750 symbol_ends: Vec::new(),
3751 text_format: TextFormat::Normal,
3752 text_type: TextType::Regular,
3753 pdf_layer: PdfLayer::Content,
3754 ocg_visible: true,
3755 index: None,
3756 page_number: image.bbox.page_number,
3757 level: None,
3758 mcid: None,
3759 });
3760 }
3761
3762 recovered
3763}
3764
3765fn lines_from_ocr_words(
3766 words: &[OcrWord],
3767 image: &ImageChunk,
3768 image_width: u32,
3769 image_height: u32,
3770 text_chunks: &[TextChunk],
3771) -> Vec<TextChunk> {
3772 if image_width == 0 || image_height == 0 {
3773 return Vec::new();
3774 }
3775
3776 let mut dedupe: HashMap<String, usize> = HashMap::new();
3777 for chunk in text_chunks {
3778 dedupe.insert(normalize_text(&chunk.value), dedupe.len());
3779 }
3780
3781 let spatial_lines = build_spatial_ocr_lines(words);
3782 if spatial_lines.is_empty() {
3783 return Vec::new();
3784 }
3785
3786 let blocks = merge_spatial_ocr_lines_into_blocks(&spatial_lines, image_width);
3787 if blocks.is_empty() {
3788 return Vec::new();
3789 }
3790
3791 let mut recovered = Vec::new();
3792 for block in blocks {
3793 let normalized = normalize_text(&block.text);
3794 if normalized.len() >= 8 && dedupe.contains_key(&normalized) {
3795 continue;
3796 }
3797
3798 if block.right <= block.left || block.bottom <= block.top {
3799 continue;
3800 }
3801
3802 let left_x = image.bbox.left_x
3803 + image.bbox.width() * (f64::from(block.left) / f64::from(image_width));
3804 let right_x = image.bbox.left_x
3805 + image.bbox.width() * (f64::from(block.right) / f64::from(image_width));
3806 let top_y = image.bbox.top_y
3807 - image.bbox.height() * (f64::from(block.top) / f64::from(image_height));
3808 let bottom_y = image.bbox.top_y
3809 - image.bbox.height() * (f64::from(block.bottom) / f64::from(image_height));
3810 if right_x <= left_x || top_y <= bottom_y {
3811 continue;
3812 }
3813
3814 recovered.push(TextChunk {
3815 value: block.text,
3816 bbox: BoundingBox::new(image.bbox.page_number, left_x, bottom_y, right_x, top_y),
3817 font_name: "OCR".to_string(),
3818 font_size: (f64::from(block.line_height_sum) / block.line_count.max(1) as f64).max(6.0),
3819 font_weight: 400.0,
3820 italic_angle: 0.0,
3821 font_color: "#000000".to_string(),
3822 contrast_ratio: 21.0,
3823 symbol_ends: Vec::new(),
3824 text_format: TextFormat::Normal,
3825 text_type: TextType::Regular,
3826 pdf_layer: PdfLayer::Content,
3827 ocg_visible: true,
3828 index: None,
3829 page_number: image.bbox.page_number,
3830 level: None,
3831 mcid: None,
3832 });
3833 }
3834
3835 recovered
3836}
3837
3838#[derive(Debug, Clone)]
3839struct SpatialOcrLine {
3840 left: u32,
3841 top: u32,
3842 right: u32,
3843 bottom: u32,
3844 text: String,
3845 word_count: usize,
3846 line_count: usize,
3847 line_height_sum: u32,
3848}
3849
3850fn build_spatial_ocr_lines(words: &[OcrWord]) -> Vec<SpatialOcrLine> {
3851 let filtered_words = filter_words_by_spatial_coherence(words);
3852 if filtered_words.is_empty() {
3853 return Vec::new();
3854 }
3855
3856 let avg_word_width =
3857 filtered_words.iter().map(|w| w.width).sum::<u32>() as f64 / filtered_words.len() as f64;
3858 let gap_tolerance = (avg_word_width * 0.8).ceil() as u32;
3859 let clusters = cluster_words_by_proximity(&filtered_words, gap_tolerance);
3860
3861 let mut lines = Vec::new();
3862 for mut cluster in clusters {
3863 cluster.sort_by_key(|word| word.left);
3864 let text = cluster
3865 .iter()
3866 .map(|word| word.text.as_str())
3867 .collect::<Vec<_>>()
3868 .join(" ")
3869 .trim()
3870 .to_string();
3871 if text.is_empty() {
3872 continue;
3873 }
3874
3875 let left = cluster.iter().map(|word| word.left).min().unwrap_or(0);
3876 let right = cluster
3877 .iter()
3878 .map(|word| word.left.saturating_add(word.width))
3879 .max()
3880 .unwrap_or(0);
3881 let top = cluster.iter().map(|word| word.top).min().unwrap_or(0);
3882 let bottom = cluster
3883 .iter()
3884 .map(|word| word.top.saturating_add(word.height))
3885 .max()
3886 .unwrap_or(0);
3887 if right <= left || bottom <= top {
3888 continue;
3889 }
3890
3891 lines.push(SpatialOcrLine {
3892 left,
3893 top,
3894 right,
3895 bottom,
3896 text,
3897 word_count: cluster.len(),
3898 line_count: 1,
3899 line_height_sum: bottom.saturating_sub(top).max(1),
3900 });
3901 }
3902
3903 lines.sort_by_key(|line| (line.top, line.left));
3904 lines
3905}
3906
3907fn merge_spatial_ocr_lines_into_blocks(
3908 lines: &[SpatialOcrLine],
3909 image_width: u32,
3910) -> Vec<SpatialOcrLine> {
3911 if lines.is_empty() {
3912 return Vec::new();
3913 }
3914
3915 let median_height = {
3916 let mut heights: Vec<u32> = lines
3917 .iter()
3918 .map(|line| line.bottom.saturating_sub(line.top).max(1))
3919 .collect();
3920 heights.sort_unstable();
3921 heights[heights.len() / 2]
3922 };
3923 let vertical_tolerance = (median_height / 2).max(3);
3924 let max_vertical_gap = median_height.saturating_mul(2).max(8);
3925
3926 let mut blocks: Vec<SpatialOcrLine> = Vec::new();
3927 for line in lines {
3928 let merge_idx = blocks.iter().rposition(|block| {
3929 let vertical_gap = line.top.saturating_sub(block.bottom);
3930 if vertical_gap > max_vertical_gap {
3931 return false;
3932 }
3933 if line.top + vertical_tolerance < block.bottom {
3934 return false;
3935 }
3936
3937 spatial_lines_share_block_geometry(block, line, image_width, median_height)
3938 });
3939
3940 if let Some(merge_idx) = merge_idx {
3941 let block = &mut blocks[merge_idx];
3942 block.left = block.left.min(line.left);
3943 block.top = block.top.min(line.top);
3944 block.right = block.right.max(line.right);
3945 block.bottom = block.bottom.max(line.bottom);
3946 block.word_count += line.word_count;
3947 block.line_count += line.line_count;
3948 block.line_height_sum = block.line_height_sum.saturating_add(line.line_height_sum);
3949 if !block.text.ends_with('-') {
3950 block.text.push(' ');
3951 }
3952 block.text.push_str(&line.text);
3953 continue;
3954 }
3955
3956 blocks.push(line.clone());
3957 }
3958
3959 blocks
3960 .into_iter()
3961 .filter_map(|mut block| {
3962 block.text = block.text.split_whitespace().collect::<Vec<_>>().join(" ");
3963 let alphabetic = block.text.chars().filter(|ch| ch.is_alphabetic()).count();
3964 let min_chars = if block.word_count >= 4 { 10 } else { 16 };
3965 if block.text.len() < min_chars || alphabetic < 4 {
3966 return None;
3967 }
3968 Some(block)
3969 })
3970 .collect()
3971}
3972
3973fn spatial_lines_share_block_geometry(
3974 upper: &SpatialOcrLine,
3975 lower: &SpatialOcrLine,
3976 image_width: u32,
3977 median_height: u32,
3978) -> bool {
3979 let overlap_left = upper.left.max(lower.left);
3980 let overlap_right = upper.right.min(lower.right);
3981 let overlap = overlap_right.saturating_sub(overlap_left);
3982 let upper_width = upper.right.saturating_sub(upper.left).max(1);
3983 let lower_width = lower.right.saturating_sub(lower.left).max(1);
3984 let min_width = upper_width.min(lower_width);
3985 let max_width = upper_width.max(lower_width);
3986 let overlap_ratio = overlap as f64 / min_width as f64;
3987 let width_ratio = min_width as f64 / max_width as f64;
3988 let max_left_shift = ((f64::from(image_width) * 0.045).round() as u32)
3989 .max(median_height.saturating_mul(2))
3990 .max(8);
3991 let left_shift = upper.left.abs_diff(lower.left);
3992
3993 overlap_ratio >= 0.40
3994 || (overlap_ratio >= 0.15 && left_shift <= max_left_shift && width_ratio >= 0.55)
3995}
3996
3997fn is_numeric_like(text: &str) -> bool {
3998 text.chars().any(|ch| ch.is_ascii_digit())
3999}
4000
4001fn normalize_text(text: &str) -> String {
4002 text.chars()
4003 .filter(|ch| ch.is_alphanumeric())
4004 .flat_map(|ch| ch.to_lowercase())
4005 .collect()
4006}
4007
4008fn normalize_caption_text(text: &str) -> String {
4009 text.replace("CarolinaBLUTM", "CarolinaBLU™")
4010 .replace("CarolinaBLU™™", "CarolinaBLU™")
4011 .trim()
4012 .to_string()
4013}
4014
4015fn normalize_raster_cell_text(row_idx: usize, _col_idx: usize, text: String) -> String {
4016 let mut normalized = text
4017 .replace('|', " ")
4018 .replace('—', "-")
4019 .replace("AorB", "A or B")
4020 .replace("Aor B", "A or B")
4021 .replace("H,O", "H2O")
4022 .split_whitespace()
4023 .collect::<Vec<_>>()
4024 .join(" ");
4025
4026 if row_idx > 0 && !normalized.chars().any(|ch| ch.is_ascii_digit()) && normalized.len() <= 2 {
4027 return String::new();
4028 }
4029 if row_idx > 0
4030 && normalized
4031 .chars()
4032 .all(|ch| matches!(ch, 'O' | 'o' | 'S' | 'B'))
4033 {
4034 return String::new();
4035 }
4036
4037 normalized = normalized
4038 .replace(" ywL", " μL")
4039 .replace(" yuL", " μL")
4040 .replace(" yL", " μL")
4041 .replace(" wL", " μL")
4042 .replace(" uL", " μL")
4043 .replace(" pL", " μL");
4044
4045 normalized.trim().to_string()
4046}
4047
4048fn create_temp_dir(page_number: u32) -> std::io::Result<PathBuf> {
4049 let unique = SystemTime::now()
4050 .duration_since(UNIX_EPOCH)
4051 .unwrap_or_default()
4052 .as_nanos();
4053 let dir = std::env::temp_dir().join(format!(
4054 "edgeparse-raster-ocr-{}-{}-{}",
4055 std::process::id(),
4056 page_number,
4057 unique
4058 ));
4059 fs::create_dir_all(&dir)?;
4060 Ok(dir)
4061}
4062
4063fn resolve_candidate_gray(image: &ImageChunk, image_files: &[PathBuf]) -> Option<image::GrayImage> {
4064 if let Some(image_index) = image.index {
4065 if let Some(image_path) = image_files.get(image_index.saturating_sub(1) as usize) {
4066 if let Ok(gray) = image::open(image_path).map(|img| img.to_luma8()) {
4067 if gray.width() >= 80 && gray.height() >= 40 {
4068 return Some(gray);
4069 }
4070 }
4071 }
4072 }
4073 let mut best: Option<image::GrayImage> = None;
4075 let mut best_area = 0u64;
4076 for path in image_files {
4077 let Ok(gray) = image::open(path).map(|img| img.to_luma8()) else {
4078 continue;
4079 };
4080 let area = u64::from(gray.width()) * u64::from(gray.height());
4081 if area > best_area {
4082 best_area = area;
4083 best = Some(gray);
4084 }
4085 }
4086 best
4087}
4088
4089fn extract_visible_page_image_files(
4090 input_path: &Path,
4091 page_number: u32,
4092 temp_dir: &Path,
4093) -> Option<Vec<PathBuf>> {
4094 let list_output = Command::new("pdfimages")
4095 .arg("-f")
4096 .arg(page_number.to_string())
4097 .arg("-l")
4098 .arg(page_number.to_string())
4099 .arg("-list")
4100 .arg(input_path)
4101 .output()
4102 .ok()?;
4103 if !list_output.status.success() {
4104 return None;
4105 }
4106
4107 let entries = parse_pdfimages_list(&String::from_utf8_lossy(&list_output.stdout));
4108 let visible_indices: Vec<usize> = entries
4109 .iter()
4110 .enumerate()
4111 .filter_map(|(idx, entry)| (entry.image_type == "image").then_some(idx))
4112 .collect();
4113 if visible_indices.is_empty() {
4114 return Some(Vec::new());
4115 }
4116
4117 let prefix = temp_dir.join("img");
4118 let status = Command::new("pdfimages")
4119 .arg("-f")
4120 .arg(page_number.to_string())
4121 .arg("-l")
4122 .arg(page_number.to_string())
4123 .arg("-png")
4124 .arg(input_path)
4125 .arg(&prefix)
4126 .status()
4127 .ok()?;
4128 if !status.success() {
4129 return None;
4130 }
4131
4132 let mut image_files: Vec<PathBuf> = fs::read_dir(temp_dir)
4133 .ok()?
4134 .filter_map(|entry| entry.ok().map(|e| e.path()))
4135 .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("png"))
4136 .collect();
4137 image_files.sort();
4138
4139 let visible_files: Vec<PathBuf> = visible_indices
4140 .into_iter()
4141 .filter_map(|idx| image_files.get(idx).cloned())
4142 .collect();
4143 Some(visible_files)
4144}
4145
4146fn parse_pdfimages_list(output: &str) -> Vec<PdfImagesListEntry> {
4147 let mut entries = Vec::new();
4148 let mut in_rows = false;
4149
4150 for line in output.lines() {
4151 let trimmed = line.trim();
4152 if trimmed.is_empty() {
4153 continue;
4154 }
4155 if trimmed.starts_with("---") {
4156 in_rows = true;
4157 continue;
4158 }
4159 if !in_rows {
4160 continue;
4161 }
4162
4163 let mut cols = trimmed.split_whitespace();
4164 let Some(_page) = cols.next() else {
4165 continue;
4166 };
4167 let Some(_num) = cols.next() else {
4168 continue;
4169 };
4170 let Some(image_type) = cols.next() else {
4171 continue;
4172 };
4173
4174 entries.push(PdfImagesListEntry {
4175 image_type: image_type.to_string(),
4176 });
4177 }
4178
4179 entries
4180}
4181
4182#[cfg(test)]
4183mod tests {
4184 use super::*;
4185 use crate::models::enums::{PdfLayer, TextFormat, TextType};
4186 use image::GrayImage;
4187
4188 fn image_chunk() -> ImageChunk {
4189 ImageChunk {
4190 bbox: BoundingBox::new(Some(1), 0.0, 0.0, 400.0, 400.0),
4191 index: Some(1),
4192 level: None,
4193 }
4194 }
4195
4196 fn word(line: (u32, u32, u32), left: u32, text: &str) -> OcrWord {
4197 OcrWord {
4198 line_key: line,
4199 left,
4200 top: 0,
4201 width: 40,
4202 height: 12,
4203 text: text.to_string(),
4204 confidence: 90.0,
4205 }
4206 }
4207
4208 fn word_at(line: (u32, u32, u32), left: u32, top: u32, width: u32, text: &str) -> OcrWord {
4209 OcrWord {
4210 line_key: line,
4211 left,
4212 top,
4213 width,
4214 height: 12,
4215 text: text.to_string(),
4216 confidence: 90.0,
4217 }
4218 }
4219
4220 fn text_chunk(value: &str, bbox: BoundingBox) -> TextChunk {
4221 TextChunk {
4222 value: value.to_string(),
4223 bbox,
4224 font_name: "Helvetica".to_string(),
4225 font_size: 12.0,
4226 font_weight: 400.0,
4227 italic_angle: 0.0,
4228 font_color: "#000000".to_string(),
4229 contrast_ratio: 21.0,
4230 symbol_ends: Vec::new(),
4231 text_format: TextFormat::Normal,
4232 text_type: TextType::Regular,
4233 pdf_layer: PdfLayer::Main,
4234 ocg_visible: true,
4235 index: None,
4236 page_number: Some(1),
4237 level: None,
4238 mcid: None,
4239 }
4240 }
4241
4242 fn test_cell_text(cell: &TableBorderCell) -> String {
4243 cell.content
4244 .iter()
4245 .map(|token| token.base.value.trim())
4246 .filter(|value| !value.is_empty())
4247 .collect::<Vec<_>>()
4248 .join(" ")
4249 }
4250
4251 #[test]
4252 fn test_table_like_ocr_detects_repeated_columns() {
4253 let words = vec![
4254 word((1, 1, 1), 10, "Temperature"),
4255 word((1, 1, 1), 120, "Viscosity"),
4256 word((1, 1, 1), 240, "Temperature"),
4257 word((1, 1, 1), 360, "Viscosity"),
4258 word((1, 1, 2), 10, "0"),
4259 word((1, 1, 2), 120, "1.793E-06"),
4260 word((1, 1, 2), 240, "25"),
4261 word((1, 1, 2), 360, "8.930E-07"),
4262 word((1, 1, 3), 10, "1"),
4263 word((1, 1, 3), 120, "1.732E-06"),
4264 word((1, 1, 3), 240, "26"),
4265 word((1, 1, 3), 360, "8.760E-07"),
4266 ];
4267 assert!(!looks_like_chart_label_ocr(&words));
4268 assert!(looks_like_table_ocr(&words));
4269 }
4270
4271 #[test]
4272 fn test_structured_ocr_table_border_recovers_non_numeric_table() {
4273 let image = image_chunk();
4274 let words = vec![
4275 word_at((1, 1, 1), 10, 10, 80, "Tube"),
4276 word_at((1, 1, 1), 145, 10, 110, "Enzyme"),
4277 word_at((1, 1, 1), 305, 10, 70, "DNA"),
4278 word_at((1, 1, 2), 10, 42, 80, "1"),
4279 word_at((1, 1, 2), 145, 42, 110, "BamHI"),
4280 word_at((1, 1, 2), 305, 42, 70, "pUC19"),
4281 word_at((1, 1, 3), 10, 74, 80, "2"),
4282 word_at((1, 1, 3), 145, 74, 110, "HindIII"),
4283 word_at((1, 1, 3), 305, 74, 70, "lambda"),
4284 word_at((1, 1, 4), 10, 106, 80, "3"),
4285 word_at((1, 1, 4), 145, 106, 110, "EcoRI"),
4286 word_at((1, 1, 4), 305, 106, 70, "control"),
4287 ];
4288
4289 assert!(!looks_like_chart_label_ocr(&words));
4290 let table = build_structured_ocr_table_border(&words, &image).expect("structured table");
4291 assert_eq!(table.num_columns, 3);
4292 assert_eq!(table.num_rows, 4);
4293 assert_eq!(test_cell_text(&table.rows[0].cells[0]), "Tube");
4294 assert_eq!(test_cell_text(&table.rows[1].cells[1]), "BamHI");
4295 assert_eq!(test_cell_text(&table.rows[3].cells[2]), "control");
4296
4297 let engine: &dyn crate::pdf::ocr::OcrEngine =
4299 &crate::pdf::ocr::TesseractCliEngine::default();
4300 let _ = engine; let mut doc = crate::models::document::PdfDocument::new("fixture.pdf".into());
4302 doc.number_of_pages = 1;
4303 doc.kids
4304 .push(crate::models::content::ContentElement::TableBorder(table));
4305 let md = crate::output::markdown::to_markdown(&doc).expect("md");
4306 assert!(
4307 md.contains('|') || md.contains("<table"),
4308 "expected table markdown, got: {md}"
4309 );
4310 }
4311
4312 #[test]
4313 fn test_structured_ocr_table_border_scales_column_boundaries_to_page_bbox() {
4314 let image = ImageChunk {
4315 bbox: BoundingBox::new(Some(1), 56.6929, 163.6519, 555.3071, 442.0069),
4316 index: Some(1),
4317 level: None,
4318 };
4319 let words = vec![
4320 word_at((1, 1, 1), 10, 10, 110, "TempC"),
4321 word_at((1, 1, 1), 255, 10, 150, "KinViscA"),
4322 word_at((1, 1, 1), 520, 10, 110, "TempC"),
4323 word_at((1, 1, 1), 760, 10, 170, "KinViscB"),
4324 word_at((1, 1, 2), 10, 44, 24, "0"),
4325 word_at((1, 1, 2), 255, 44, 130, "1.793E-06"),
4326 word_at((1, 1, 2), 520, 44, 28, "25"),
4327 word_at((1, 1, 2), 760, 44, 130, "8.930E-07"),
4328 word_at((1, 1, 3), 10, 78, 24, "1"),
4329 word_at((1, 1, 3), 255, 78, 130, "1.732E-06"),
4330 word_at((1, 1, 3), 520, 78, 28, "26"),
4331 word_at((1, 1, 3), 760, 78, 130, "8.760E-07"),
4332 ];
4333
4334 let table = build_structured_ocr_table_border(&words, &image).expect("structured table");
4335
4336 assert_eq!(table.num_columns, 4);
4337 assert_eq!(table.num_rows, 3);
4338 assert_eq!(test_cell_text(&table.rows[1].cells[1]), "1.793E-06");
4339 assert!(table
4340 .x_coordinates
4341 .windows(2)
4342 .all(|pair| pair[1] >= pair[0]));
4343 assert!(table
4344 .x_coordinates
4345 .iter()
4346 .all(|x| *x >= image.bbox.left_x && *x <= image.bbox.right_x));
4347 }
4348
4349 #[test]
4350 fn test_chart_label_ocr_does_not_reject_five_row_table() {
4351 let words = vec![
4352 word_at((1, 1, 1), 10, 10, 80, "Tube"),
4353 word_at((1, 1, 1), 145, 10, 110, "Enzyme"),
4354 word_at((1, 1, 1), 305, 10, 70, "DNA"),
4355 word_at((1, 1, 2), 10, 42, 80, "1"),
4356 word_at((1, 1, 2), 145, 42, 110, "BamHI"),
4357 word_at((1, 1, 2), 305, 42, 70, "pUC19"),
4358 word_at((1, 1, 3), 10, 74, 80, "2"),
4359 word_at((1, 1, 3), 145, 74, 110, "HindIII"),
4360 word_at((1, 1, 3), 305, 74, 70, "lambda"),
4361 word_at((1, 1, 4), 10, 106, 80, "3"),
4362 word_at((1, 1, 4), 145, 106, 110, "EcoRI"),
4363 word_at((1, 1, 4), 305, 106, 70, "control"),
4364 word_at((1, 1, 5), 10, 138, 80, "4"),
4365 word_at((1, 1, 5), 145, 138, 110, "NotI"),
4366 word_at((1, 1, 5), 305, 138, 70, "sample"),
4367 ];
4368
4369 assert!(!looks_like_chart_label_ocr(&words));
4370 assert!(looks_like_table_ocr(&words));
4371 }
4372
4373 #[test]
4374 fn test_structured_ocr_table_border_rejects_two_column_prose_layout() {
4375 let image = image_chunk();
4376 let words = vec![
4377 word_at((1, 1, 1), 10, 10, 90, "Summary"),
4378 word_at((1, 1, 1), 220, 10, 120, "Detailed findings"),
4379 word_at((1, 1, 2), 10, 42, 90, "Background"),
4380 word_at((1, 1, 2), 220, 42, 120, "Additional context"),
4381 word_at((1, 1, 3), 10, 74, 90, "Notes"),
4382 word_at((1, 1, 3), 220, 74, 120, "Further explanation"),
4383 ];
4384
4385 assert!(build_structured_ocr_table_border(&words, &image).is_none());
4386 }
4387
4388 #[test]
4389 fn test_parse_pdfimages_list_ignores_smask_entries() {
4390 let output = "page num type width height color comp bpc enc interp object ID x-ppi y-ppi size ratio\n--------------------------------------------------------------------------------------------\n 1 0 image 1320 358 icc 3 8 image no 46 0 208 208 63.5K 4.6%\n 1 1 smask 1320 358 gray 1 8 image no 46 0 208 208 483B 0.1%\n";
4391
4392 let entries = parse_pdfimages_list(output);
4393 assert_eq!(entries.len(), 2);
4394 assert_eq!(entries[0].image_type, "image");
4395 assert_eq!(entries[1].image_type, "smask");
4396 }
4397
4398 #[test]
4399 fn test_table_like_ocr_rejects_single_line_caption() {
4400 let words = vec![
4401 word((1, 1, 1), 10, "Figure"),
4402 word((1, 1, 1), 90, "7.2"),
4403 word((1, 1, 1), 150, "Viscosity"),
4404 word((1, 1, 1), 260, "of"),
4405 word((1, 1, 1), 300, "Water"),
4406 ];
4407 assert!(!looks_like_table_ocr(&words));
4408 }
4409
4410 #[test]
4411 fn test_normalize_raster_cell_text_fixes_units_and_artifacts() {
4412 assert_eq!(
4413 normalize_raster_cell_text(1, 1, "3 ywL".to_string()),
4414 "3 μL"
4415 );
4416 assert_eq!(normalize_raster_cell_text(1, 4, "OS".to_string()), "");
4417 assert_eq!(normalize_raster_cell_text(0, 6, "H,O".to_string()), "H2O");
4418 }
4419
4420 #[test]
4421 fn test_detect_bordered_raster_grid_finds_strong_lines() {
4422 let mut image = GrayImage::from_pixel(120, 80, Luma([255]));
4423 for x in [10, 40, 80, 110] {
4424 for y in 10..71 {
4425 image.put_pixel(x, y, Luma([0]));
4426 }
4427 }
4428 for y in [10, 30, 50, 70] {
4429 for x in 10..111 {
4430 image.put_pixel(x, y, Luma([0]));
4431 }
4432 }
4433
4434 let grid = detect_bordered_raster_grid(&image).expect("grid");
4435 assert_eq!(grid.vertical_lines.len(), 4);
4436 assert_eq!(grid.horizontal_lines.len(), 4);
4437 }
4438
4439 #[test]
4440 fn test_obvious_bar_chart_raster_is_rejected() {
4441 let mut image = GrayImage::from_pixel(320, 200, Luma([255]));
4442 for &(y1, y2) in &[(25, 40), (70, 85), (115, 130), (160, 175)] {
4443 for y in y1..y2 {
4444 for x in 40..280 {
4445 image.put_pixel(x, y, Luma([80]));
4446 }
4447 }
4448 }
4449
4450 assert!(is_obvious_bar_chart_raster(&image));
4451 }
4452
4453 #[test]
4454 fn test_vertical_bar_chart_raster_is_rejected() {
4455 let mut image = GrayImage::from_pixel(360, 240, Luma([255]));
4456 for &(x1, x2, y1) in &[
4457 (40, 78, 52),
4458 (92, 126, 118),
4459 (140, 170, 146),
4460 (184, 210, 162),
4461 ] {
4462 for x in x1..x2 {
4463 for y in y1..212 {
4464 image.put_pixel(x, y, Luma([90]));
4465 }
4466 }
4467 }
4468
4469 assert!(is_obvious_bar_chart_raster(&image));
4470 }
4471
4472 #[test]
4473 fn test_light_fill_vertical_bar_chart_raster_is_rejected() {
4474 let mut image = GrayImage::from_pixel(420, 260, Luma([255]));
4475 for x in 24..396 {
4476 image.put_pixel(x, 222, Luma([170]));
4477 }
4478 for &(x1, x2, y1, shade) in &[
4479 (46, 82, 132, 222),
4480 (104, 140, 84, 214),
4481 (162, 198, 62, 206),
4482 (220, 256, 144, 228),
4483 ] {
4484 for x in x1..x2 {
4485 for y in y1..222 {
4486 image.put_pixel(x, y, Luma([shade]));
4487 }
4488 }
4489 }
4490
4491 assert!(is_obvious_bar_chart_raster(&image));
4492 }
4493
4494 #[test]
4495 fn test_grouped_vertical_bar_chart_raster_is_rejected() {
4496 let mut image = GrayImage::from_pixel(420, 240, Luma([255]));
4497 for x in 28..392 {
4498 image.put_pixel(x, 214, Luma([175]));
4499 }
4500 for &(x1, x2, y1, shade) in &[
4501 (44, 60, 98, 210),
4502 (64, 80, 140, 225),
4503 (108, 124, 116, 214),
4504 (128, 144, 148, 229),
4505 (172, 188, 88, 206),
4506 (192, 208, 128, 222),
4507 (236, 252, 104, 212),
4508 (256, 272, 156, 228),
4509 ] {
4510 for x in x1..x2 {
4511 for y in y1..214 {
4512 image.put_pixel(x, y, Luma([shade]));
4513 }
4514 }
4515 }
4516
4517 assert!(is_obvious_bar_chart_raster(&image));
4518 }
4519
4520 #[test]
4521 fn test_natural_photograph_raster_is_detected() {
4522 let w = 100u32;
4524 let h = 100u32;
4525 let mut image = GrayImage::new(w, h);
4526 for y in 0..h {
4528 for x in 0..w {
4529 let v = ((x + y) * 255 / (w + h - 2)) as u8;
4530 image.put_pixel(x, y, Luma([v]));
4531 }
4532 }
4533 assert!(is_natural_photograph_raster(&image));
4535 }
4536
4537 #[test]
4538 fn test_chart_image_is_not_classified_as_photograph() {
4539 let mut image = GrayImage::from_pixel(200, 160, Luma([255]));
4541 for x in 20..180 {
4543 image.put_pixel(x, 20, Luma([0]));
4544 image.put_pixel(x, 80, Luma([0]));
4545 image.put_pixel(x, 140, Luma([0]));
4546 }
4547 for y in 20..141 {
4548 image.put_pixel(20, y, Luma([0]));
4549 image.put_pixel(180, y, Luma([0]));
4550 }
4551 assert!(!is_natural_photograph_raster(&image));
4553 assert!(!is_dark_ui_screenshot_raster(&image));
4554 }
4555
4556 #[test]
4557 fn test_bright_natural_photograph_raster_is_detected() {
4558 let mut image = GrayImage::from_pixel(240, 180, Luma([250]));
4559 for y in 24..148 {
4560 for x in 52..156 {
4561 let tone = 72 + (((x - 52) * 11 + (y - 24) * 7) % 132) as u8;
4562 image.put_pixel(x, y, Luma([tone]));
4563 }
4564 }
4565
4566 assert!(is_natural_photograph_raster(&image));
4567 }
4568
4569 #[test]
4570 fn test_dark_ui_screenshot_raster_is_detected() {
4571 let mut image = GrayImage::from_pixel(260, 180, Luma([20]));
4572 for x in 18..242 {
4573 for y in 18..34 {
4574 image.put_pixel(x, y, Luma([210]));
4575 }
4576 }
4577 for &(x1, y1, x2, y2, shade) in &[
4578 (26, 58, 84, 108, 198),
4579 (94, 58, 152, 108, 210),
4580 (162, 58, 220, 108, 192),
4581 (26, 118, 220, 134, 224),
4582 ] {
4583 for x in x1..x2 {
4584 for y in y1..y2 {
4585 image.put_pixel(x, y, Luma([shade]));
4586 }
4587 }
4588 }
4589
4590 assert!(is_dark_ui_screenshot_raster(&image));
4591 }
4592
4593 #[test]
4594 fn test_table_like_ocr_rejects_matrix_formula_layout() {
4595 let words = vec![
4596 word_at((1, 1, 1), 14, 10, 36, "B23"),
4597 word_at((1, 1, 1), 160, 10, 22, "C1"),
4598 word_at((1, 1, 1), 230, 10, 22, "C2"),
4599 word_at((1, 1, 1), 300, 10, 22, "C3"),
4600 word_at((1, 1, 2), 20, 44, 24, "0/0"),
4601 word_at((1, 1, 2), 150, 44, 18, "0"),
4602 word_at((1, 1, 2), 220, 44, 28, "001"),
4603 word_at((1, 1, 2), 300, 44, 28, "000"),
4604 word_at((1, 1, 3), 20, 76, 24, "0/1"),
4605 word_at((1, 1, 3), 150, 76, 28, "000"),
4606 word_at((1, 1, 3), 220, 76, 28, "010"),
4607 word_at((1, 1, 3), 300, 76, 28, "000"),
4608 ];
4609
4610 assert!(looks_like_matrix_formula_ocr(&words));
4611 assert!(!looks_like_table_ocr(&words));
4612 }
4613
4614 #[test]
4615 fn test_table_like_ocr_keeps_small_numeric_table_with_real_headers() {
4616 let words = vec![
4617 word_at((1, 1, 1), 10, 10, 64, "Year"),
4618 word_at((1, 1, 1), 130, 10, 28, "Q1"),
4619 word_at((1, 1, 1), 220, 10, 28, "Q2"),
4620 word_at((1, 1, 1), 310, 10, 28, "Q3"),
4621 word_at((1, 1, 2), 10, 42, 64, "2022"),
4622 word_at((1, 1, 2), 130, 42, 24, "10"),
4623 word_at((1, 1, 2), 220, 42, 24, "25"),
4624 word_at((1, 1, 2), 310, 42, 24, "30"),
4625 word_at((1, 1, 3), 10, 74, 64, "2023"),
4626 word_at((1, 1, 3), 130, 74, 24, "11"),
4627 word_at((1, 1, 3), 220, 74, 24, "26"),
4628 word_at((1, 1, 3), 310, 74, 24, "31"),
4629 ];
4630
4631 assert!(!looks_like_matrix_formula_ocr(&words));
4632 assert!(looks_like_table_ocr(&words));
4633 }
4634
4635 #[test]
4636 fn test_matrixish_small_ocr_table_is_rejected_after_build() {
4637 let image = ImageChunk {
4638 bbox: BoundingBox::new(Some(1), 0.0, 0.0, 440.0, 120.0),
4639 index: Some(1),
4640 level: None,
4641 };
4642 let words = vec![
4643 word_at((1, 1, 1), 14, 10, 36, "B23"),
4644 word_at((1, 1, 1), 160, 10, 22, "C1"),
4645 word_at((1, 1, 1), 230, 10, 22, "C2"),
4646 word_at((1, 1, 1), 300, 10, 22, "C3"),
4647 word_at((1, 1, 2), 20, 44, 24, "0/0"),
4648 word_at((1, 1, 2), 150, 44, 18, "0"),
4649 word_at((1, 1, 2), 220, 44, 28, "001"),
4650 word_at((1, 1, 2), 300, 44, 28, "000"),
4651 word_at((1, 1, 3), 20, 76, 24, "0/1"),
4652 word_at((1, 1, 3), 150, 76, 28, "000"),
4653 word_at((1, 1, 3), 220, 76, 28, "010"),
4654 word_at((1, 1, 3), 300, 76, 28, "000"),
4655 ];
4656
4657 let table = build_structured_ocr_table_border(&words, &image).expect("structured table");
4658 assert!(is_matrixish_ocr_artifact_table(&table));
4659 }
4660
4661 #[test]
4662 fn test_small_numeric_table_with_real_headers_is_not_rejected_after_build() {
4663 let image = ImageChunk {
4664 bbox: BoundingBox::new(Some(1), 0.0, 0.0, 440.0, 140.0),
4665 index: Some(1),
4666 level: None,
4667 };
4668 let words = vec![
4669 word_at((1, 1, 1), 10, 10, 64, "Year"),
4670 word_at((1, 1, 1), 130, 10, 28, "Q1"),
4671 word_at((1, 1, 1), 220, 10, 28, "Q2"),
4672 word_at((1, 1, 1), 310, 10, 28, "Q3"),
4673 word_at((1, 1, 2), 10, 42, 64, "2022"),
4674 word_at((1, 1, 2), 130, 42, 24, "10"),
4675 word_at((1, 1, 2), 220, 42, 24, "25"),
4676 word_at((1, 1, 2), 310, 42, 24, "30"),
4677 word_at((1, 1, 3), 10, 74, 64, "2023"),
4678 word_at((1, 1, 3), 130, 74, 24, "11"),
4679 word_at((1, 1, 3), 220, 74, 24, "26"),
4680 word_at((1, 1, 3), 310, 74, 24, "31"),
4681 ];
4682
4683 let table = build_structured_ocr_table_border(&words, &image).expect("structured table");
4684 assert!(!is_matrixish_ocr_artifact_table(&table));
4685 }
4686
4687 #[test]
4688 fn test_bordered_table_raster_is_not_rejected_as_chart() {
4689 let mut image = GrayImage::from_pixel(320, 200, Luma([255]));
4690 for x in [20, 110, 210, 300] {
4691 for y in 20..181 {
4692 image.put_pixel(x, y, Luma([0]));
4693 }
4694 }
4695 for y in [20, 70, 120, 180] {
4696 for x in 20..301 {
4697 image.put_pixel(x, y, Luma([0]));
4698 }
4699 }
4700
4701 assert!(!is_obvious_bar_chart_raster(&image));
4702 }
4703
4704 #[test]
4705 fn test_morphological_erode_preserves_white_background() {
4706 let image = GrayImage::from_fn(9, 9, |x, y| {
4707 if x == 4 || y == 4 {
4708 Luma([0])
4709 } else {
4710 Luma([255])
4711 }
4712 });
4713
4714 let eroded = morphological_erode(&image, 1);
4715
4716 assert_eq!(eroded.get_pixel(0, 0).0[0], 255);
4717 assert_eq!(eroded.get_pixel(8, 8).0[0], 255);
4718 assert_eq!(eroded.get_pixel(4, 4).0[0], 255);
4719 }
4720
4721 #[test]
4722 fn test_dense_prose_image_ocr_detects_infographic_text() {
4723 let mut words = Vec::new();
4724 let mut top = 20;
4725 for line_num in 1..=8 {
4726 for (idx, (left, text)) in [
4727 (20, "Copyright"),
4728 (120, "protects"),
4729 (240, "creative"),
4730 (350, "work"),
4731 ]
4732 .into_iter()
4733 .enumerate()
4734 {
4735 words.push(OcrWord {
4736 line_key: (1, 1, line_num),
4737 left,
4738 top,
4739 width: 60,
4740 height: 14,
4741 confidence: 85.0,
4742 text: if idx == 0 && line_num % 2 == 0 {
4743 "Creators".to_string()
4744 } else {
4745 text.to_string()
4746 },
4747 });
4748 }
4749 top += 22;
4750 }
4751
4752 assert!(looks_like_dense_prose_image_ocr(&words));
4753 }
4754
4755 #[test]
4756 fn test_dense_prose_image_ocr_rejects_chart_like_words() {
4757 let words = vec![
4758 word((1, 1, 1), 10, "70.2"),
4759 word((1, 1, 1), 90, "75.6"),
4760 word((1, 1, 1), 170, "92.4"),
4761 word((1, 1, 2), 10, "80.4"),
4762 word((1, 1, 2), 90, "94.2"),
4763 word((1, 1, 2), 170, "95.5"),
4764 word((1, 1, 3), 10, "Company"),
4765 word((1, 1, 3), 90, "A"),
4766 word((1, 1, 3), 170, "B"),
4767 word((1, 1, 4), 10, "Scene"),
4768 word((1, 1, 4), 90, "Document"),
4769 word((1, 1, 5), 10, "65"),
4770 word((1, 1, 5), 90, "70"),
4771 word((1, 1, 5), 170, "75"),
4772 word((1, 1, 6), 10, "80"),
4773 word((1, 1, 6), 90, "85"),
4774 word((1, 1, 6), 170, "90"),
4775 word((1, 1, 7), 10, "95"),
4776 word((1, 1, 7), 90, "100"),
4777 ];
4778
4779 assert!(!looks_like_dense_prose_image_ocr(&words));
4780 }
4781
4782 #[test]
4783 fn test_dense_prose_image_ocr_rejects_scattered_chart_labels() {
4784 let words = vec![
4785 word_at((1, 1, 1), 20, 20, 80, "Participation"),
4786 word_at((1, 1, 1), 120, 20, 70, "of"),
4787 word_at((1, 1, 1), 210, 20, 90, "Institutions"),
4788 word_at((1, 1, 2), 310, 50, 50, "57"),
4789 word_at((1, 1, 2), 380, 50, 60, "(24%)"),
4790 word_at((1, 1, 3), 290, 86, 40, "20"),
4791 word_at((1, 1, 3), 345, 86, 50, "(8%)"),
4792 word_at((1, 1, 4), 80, 124, 120, "Government"),
4793 word_at((1, 1, 4), 260, 124, 90, "Other"),
4794 word_at((1, 1, 4), 360, 124, 60, "State"),
4795 word_at((1, 1, 5), 70, 160, 80, "Civil"),
4796 word_at((1, 1, 5), 170, 160, 80, "Society"),
4797 word_at((1, 1, 5), 280, 160, 110, "Organizations"),
4798 word_at((1, 1, 6), 300, 194, 50, "31"),
4799 word_at((1, 1, 6), 365, 194, 60, "(13%)"),
4800 word_at((1, 1, 7), 35, 228, 120, "Educational"),
4801 word_at((1, 1, 7), 180, 228, 100, "Institution"),
4802 word_at((1, 1, 8), 250, 262, 40, "16"),
4803 word_at((1, 1, 8), 305, 262, 50, "(7%)"),
4804 ];
4805
4806 assert!(looks_like_chart_label_ocr(&words));
4807 assert!(!looks_like_table_ocr(&words));
4808 assert!(!looks_like_dense_prose_image_ocr(&words));
4809 }
4810
4811 #[test]
4812 fn test_chart_label_ocr_detects_stacked_bar_chart_legend_layout() {
4813 let words = vec![
4814 word_at((1, 1, 1), 10, 15, 22, "ano"),
4815 word_at((1, 1, 1), 10, 8, 24, "MW."),
4816 word_at((1, 1, 2), 410, 25, 38, "Waste"),
4817 word_at((1, 1, 2), 452, 25, 55, "materials"),
4818 word_at((1, 1, 3), 11, 38, 21, "350"),
4819 word_at((1, 1, 4), 11, 61, 21, "300"),
4820 word_at((1, 1, 4), 411, 56, 38, "Biogas"),
4821 word_at((1, 1, 5), 7, 79, 25, "250"),
4822 word_at((1, 1, 5), 399, 87, 8, "'™"),
4823 word_at((1, 1, 5), 411, 87, 75, "Construction"),
4824 word_at((1, 1, 5), 490, 86, 33, "wood"),
4825 word_at((1, 1, 5), 527, 87, 35, "waste"),
4826 word_at((1, 1, 6), 11, 106, 21, "200"),
4827 word_at((1, 1, 7), 411, 117, 59, "General"),
4828 word_at((1, 1, 7), 467, 116, 27, "wood"),
4829 word_at((1, 1, 7), 499, 116, 54, "(10MWs)"),
4830 word_at((1, 1, 8), 11, 129, 21, "150"),
4831 word_at((1, 1, 9), 11, 152, 21, "100"),
4832 word_at((1, 1, 9), 399, 148, 7, "="),
4833 word_at((1, 1, 9), 411, 135, 46, "General"),
4834 word_at((1, 1, 9), 464, 135, 27, "wood"),
4835 word_at((1, 1, 9), 498, 146, 56, "(<LOMW)"),
4836 word_at((1, 1, 10), 13, 163, 18, "50"),
4837 word_at((1, 1, 10), 399, 178, 7, "="),
4838 word_at((1, 1, 10), 411, 176, 73, "Unutilised"),
4839 word_at((1, 1, 10), 480, 166, 29, "wood"),
4840 word_at((1, 1, 10), 516, 176, 45, "(2MWs)"),
4841 word_at((1, 1, 11), 24, 197, 7, "o"),
4842 word_at((1, 1, 12), 399, 208, 8, "m="),
4843 word_at((1, 1, 12), 411, 206, 59, "Unutilised"),
4844 word_at((1, 1, 12), 474, 206, 33, "wood"),
4845 word_at((1, 1, 12), 512, 206, 48, "(<2MW)"),
4846 word_at((1, 1, 13), 51, 217, 32, "12-13"),
4847 word_at((1, 1, 13), 96, 217, 28, "2014"),
4848 word_at((1, 1, 13), 139, 217, 28, "2015"),
4849 word_at((1, 1, 13), 182, 217, 28, "2016"),
4850 word_at((1, 1, 13), 225, 217, 28, "2017"),
4851 word_at((1, 1, 13), 268, 217, 28, "2018"),
4852 word_at((1, 1, 13), 311, 217, 28, "2019"),
4853 word_at((1, 1, 13), 354, 217, 28, "2020"),
4854 ];
4855
4856 assert!(looks_like_chart_label_ocr(&words));
4857 assert!(!looks_like_table_ocr(&words));
4858 }
4859
4860 #[test]
4861 fn test_build_numeric_table_border_rejects_sparse_chart_layout() {
4862 let image = image_chunk();
4863 let mut words = Vec::new();
4864 let columns = [20, 55, 90, 125, 160, 195, 230, 265, 300, 335, 370, 405];
4865
4866 for (idx, left) in columns.iter().enumerate() {
4867 words.push(word_at((1, 1, 1), *left, 20, 22, &format!("H{}", idx + 1)));
4868 }
4869 for (idx, left) in [20, 160, 300].into_iter().enumerate() {
4870 words.push(word_at((1, 1, 2), left, 52, 22, &format!("{}", idx + 1)));
4871 }
4872 for (idx, left) in [55, 195, 335].into_iter().enumerate() {
4873 words.push(word_at((1, 1, 3), left, 84, 22, &format!("{}", idx + 4)));
4874 }
4875 for (idx, left) in [90, 230, 370].into_iter().enumerate() {
4876 words.push(word_at((1, 1, 4), left, 116, 22, &format!("{}", idx + 7)));
4877 }
4878 for (idx, left) in columns.iter().enumerate() {
4879 words.push(word_at((1, 1, 5), *left, 148, 22, &format!("{}", idx + 10)));
4880 }
4881
4882 assert!(looks_like_chart_label_ocr(&words));
4883 assert!(!looks_like_table_ocr(&words));
4884 assert!(!looks_like_numeric_table_ocr(&words));
4885 assert!(build_numeric_table_border(&words, &image).is_none());
4886 }
4887
4888 #[test]
4889 fn test_lines_from_ocr_words_merges_wrapped_lines_into_blocks() {
4890 let words = vec![
4891 word_at((1, 1, 1), 20, 20, 64, "Copyright"),
4892 word_at((1, 1, 1), 100, 20, 56, "protects"),
4893 word_at((1, 1, 2), 20, 38, 52, "creative"),
4894 word_at((1, 1, 2), 84, 38, 36, "work"),
4895 word_at((1, 1, 3), 240, 20, 52, "Public"),
4896 word_at((1, 1, 3), 304, 20, 40, "domain"),
4897 word_at((1, 1, 4), 240, 38, 60, "expires"),
4898 word_at((1, 1, 4), 312, 38, 44, "later"),
4899 ];
4900
4901 let recovered = lines_from_ocr_words(&words, &image_chunk(), 400, 400, &[]);
4902
4903 assert_eq!(recovered.len(), 2);
4904 assert_eq!(recovered[0].value, "Copyright protects creative work");
4905 assert_eq!(recovered[1].value, "Public domain expires later");
4906 }
4907
4908 #[test]
4909 fn test_page_raster_ocr_skips_bar_chart_tables() {
4910 let mut chart = GrayImage::from_pixel(420, 260, Luma([255]));
4911 for x in 24..396 {
4912 chart.put_pixel(x, 222, Luma([170]));
4913 }
4914 for &(x1, x2, y1, shade) in &[
4915 (46, 82, 132, 222),
4916 (104, 140, 84, 214),
4917 (162, 198, 62, 206),
4918 (220, 256, 144, 228),
4919 ] {
4920 for x in x1..x2 {
4921 for y in y1..222 {
4922 chart.put_pixel(x, y, Luma([shade]));
4923 }
4924 }
4925 }
4926
4927 let page_bbox = BoundingBox::new(Some(1), 0.0, 0.0, 420.0, 260.0);
4928 let mut table = TableBorder {
4929 bbox: BoundingBox::new(Some(1), 0.0, 0.0, 420.0, 260.0),
4930 index: None,
4931 level: None,
4932 x_coordinates: vec![0.0, 210.0, 420.0],
4933 x_widths: vec![0.0; 3],
4934 y_coordinates: vec![260.0, 130.0, 0.0],
4935 y_widths: vec![0.0; 3],
4936 rows: vec![
4937 TableBorderRow {
4938 bbox: BoundingBox::new(Some(1), 0.0, 130.0, 420.0, 260.0),
4939 index: None,
4940 level: None,
4941 row_number: 0,
4942 cells: vec![
4943 TableBorderCell {
4944 bbox: BoundingBox::new(Some(1), 0.0, 130.0, 210.0, 260.0),
4945 index: None,
4946 level: None,
4947 row_number: 0,
4948 col_number: 0,
4949 row_span: 1,
4950 col_span: 1,
4951 content: Vec::new(),
4952 contents: Vec::new(),
4953 semantic_type: None,
4954 },
4955 TableBorderCell {
4956 bbox: BoundingBox::new(Some(1), 210.0, 130.0, 420.0, 260.0),
4957 index: None,
4958 level: None,
4959 row_number: 0,
4960 col_number: 1,
4961 row_span: 1,
4962 col_span: 1,
4963 content: Vec::new(),
4964 contents: Vec::new(),
4965 semantic_type: None,
4966 },
4967 ],
4968 semantic_type: None,
4969 },
4970 TableBorderRow {
4971 bbox: BoundingBox::new(Some(1), 0.0, 0.0, 420.0, 130.0),
4972 index: None,
4973 level: None,
4974 row_number: 1,
4975 cells: vec![
4976 TableBorderCell {
4977 bbox: BoundingBox::new(Some(1), 0.0, 0.0, 210.0, 130.0),
4978 index: None,
4979 level: None,
4980 row_number: 1,
4981 col_number: 0,
4982 row_span: 1,
4983 col_span: 1,
4984 content: Vec::new(),
4985 contents: Vec::new(),
4986 semantic_type: None,
4987 },
4988 TableBorderCell {
4989 bbox: BoundingBox::new(Some(1), 210.0, 0.0, 420.0, 130.0),
4990 index: None,
4991 level: None,
4992 row_number: 1,
4993 col_number: 1,
4994 row_span: 1,
4995 col_span: 1,
4996 content: Vec::new(),
4997 contents: Vec::new(),
4998 semantic_type: None,
4999 },
5000 ],
5001 semantic_type: None,
5002 },
5003 ],
5004 num_rows: 2,
5005 num_columns: 2,
5006 is_bad_table: false,
5007 is_table_transformer: true,
5008 previous_table: None,
5009 next_table: None,
5010 };
5011
5012 enrich_empty_table_from_page_raster(&chart, &page_bbox, &mut table);
5013
5014 assert!(table
5015 .rows
5016 .iter()
5017 .flat_map(|row| row.cells.iter())
5018 .all(|cell| cell.content.is_empty()));
5019 }
5020
5021 #[test]
5022 fn test_native_text_chars_in_region_ignores_distant_page_text() {
5023 let table_bbox = BoundingBox::new(Some(1), 40.0, 120.0, 360.0, 280.0);
5024 let distant_text = ContentElement::TextChunk(text_chunk(
5025 &"A".repeat(MAX_NATIVE_TEXT_CHARS_FOR_PAGE_RASTER_OCR + 40),
5026 BoundingBox::new(Some(1), 40.0, 500.0, 380.0, 560.0),
5027 ));
5028 let overlapping_text = ContentElement::TextChunk(text_chunk(
5029 "1234",
5030 BoundingBox::new(Some(1), 60.0, 160.0, 100.0, 176.0),
5031 ));
5032 let elements = vec![distant_text, overlapping_text];
5033
5034 assert!(page_native_text_chars(&elements) > MAX_NATIVE_TEXT_CHARS_FOR_PAGE_RASTER_OCR);
5035 assert_eq!(native_text_chars_in_region(&elements, &table_bbox), 4);
5036 }
5037
5038 #[test]
5039 fn test_table_needs_page_raster_ocr_for_sparse_partial_table() {
5040 let mut table = TableBorder {
5041 bbox: BoundingBox::new(Some(1), 0.0, 0.0, 300.0, 200.0),
5042 index: None,
5043 level: None,
5044 x_coordinates: vec![0.0, 60.0, 120.0, 180.0, 240.0, 300.0],
5045 x_widths: vec![0.0; 6],
5046 y_coordinates: vec![200.0, 160.0, 120.0, 80.0, 40.0, 0.0],
5047 y_widths: vec![0.0; 6],
5048 rows: Vec::new(),
5049 num_rows: 5,
5050 num_columns: 5,
5051 is_bad_table: false,
5052 is_table_transformer: true,
5053 previous_table: None,
5054 next_table: None,
5055 };
5056
5057 for row_idx in 0..5 {
5058 let mut row = TableBorderRow {
5059 bbox: BoundingBox::new(Some(1), 0.0, 0.0, 300.0, 200.0),
5060 index: None,
5061 level: None,
5062 row_number: row_idx,
5063 cells: Vec::new(),
5064 semantic_type: None,
5065 };
5066 for col_idx in 0..5 {
5067 row.cells.push(TableBorderCell {
5068 bbox: BoundingBox::new(Some(1), 0.0, 0.0, 60.0, 40.0),
5069 index: None,
5070 level: None,
5071 row_number: row_idx,
5072 col_number: col_idx,
5073 row_span: 1,
5074 col_span: 1,
5075 content: Vec::new(),
5076 contents: Vec::new(),
5077 semantic_type: None,
5078 });
5079 }
5080 table.rows.push(row);
5081 }
5082
5083 table.rows[0].cells[0].content.push(TableToken {
5084 base: text_chunk("12", BoundingBox::new(Some(1), 0.0, 0.0, 20.0, 10.0)),
5085 token_type: TableTokenType::Text,
5086 });
5087
5088 assert!(table_needs_page_raster_ocr(&table));
5089 }
5090
5091 #[test]
5092 fn test_lines_from_ocr_words_dedupes_against_native_text() {
5093 let words = vec![
5094 word_at((1, 1, 1), 20, 20, 64, "Copyright"),
5095 word_at((1, 1, 1), 100, 20, 56, "protects"),
5096 word_at((1, 1, 2), 20, 38, 52, "creative"),
5097 word_at((1, 1, 2), 84, 38, 36, "work"),
5098 ];
5099 let native = vec![TextChunk {
5100 value: "Copyright protects creative work".to_string(),
5101 bbox: BoundingBox::new(Some(1), 0.0, 0.0, 10.0, 10.0),
5102 font_name: "Native".to_string(),
5103 font_size: 12.0,
5104 font_weight: 400.0,
5105 italic_angle: 0.0,
5106 font_color: "#000000".to_string(),
5107 contrast_ratio: 21.0,
5108 symbol_ends: Vec::new(),
5109 text_format: TextFormat::Normal,
5110 text_type: TextType::Regular,
5111 pdf_layer: PdfLayer::Content,
5112 ocg_visible: true,
5113 index: None,
5114 page_number: Some(1),
5115 level: None,
5116 mcid: None,
5117 }];
5118
5119 let recovered = lines_from_ocr_words(&words, &image_chunk(), 400, 400, &native);
5120
5121 assert!(recovered.is_empty());
5122 }
5123}