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