Skip to main content

edgeparse_core/pdf/
raster_table_ocr.rs

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