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