Skip to main content

edgeparse_core/pdf/
inmem_raster.rs

1//! In-memory raster table recovery (WASM-safe).
2//!
3//! First principles: PDF Image XObjects are the pixel source (ISO 32000).
4//! Ruled tables are recoverable from projection of dark ink — no Poppler /
5//! Tesseract *process* for page render. Cell text uses [`crate::pdf::ocr`].
6
7use lopdf::Document;
8
9use crate::models::bbox::BoundingBox;
10use crate::models::chunks::{ImageChunk, TextChunk};
11use crate::models::enums::{PdfLayer, TextFormat, TextType};
12use crate::models::table::{
13    TableBorder, TableBorderCell, TableBorderRow, TableToken, TableTokenType,
14};
15use crate::pdf::image_extractor::extract_image_data;
16
17#[cfg(feature = "image")]
18use image::GrayImage;
19
20const MAX_NATIVE_TEXT_CHARS_IN_IMAGE: usize = 250;
21const MIN_LINE_DARK_RATIO: f64 = 0.28;
22const MIN_TRUE_GRID_LINE_CONTINUITY: f64 = 0.60;
23const MIN_BORDERED_VERTICAL_LINES: usize = 3;
24const MIN_BORDERED_HORIZONTAL_LINES: usize = 3;
25const MIN_BORDERED_INKED_CELL_RATIO: f64 = 0.18;
26const MIN_BORDERED_ROWS_WITH_INK: usize = 2;
27const RASTER_DARK_THRESHOLD: u8 = 180;
28
29/// Recover bordered tables from embedded Image XObjects on a page.
30pub fn recover_embedded_raster_tables(
31    doc: &Document,
32    page_id: lopdf::ObjectId,
33    image_chunks: &[ImageChunk],
34    text_chunks: &[TextChunk],
35) -> Vec<TableBorder> {
36    #[cfg(not(feature = "image"))]
37    {
38        let _ = (doc, page_id, image_chunks, text_chunks);
39        Vec::new()
40    }
41    #[cfg(feature = "image")]
42    {
43        recover_embedded_raster_tables_image(doc, page_id, image_chunks, text_chunks)
44    }
45}
46
47#[cfg(feature = "image")]
48fn recover_embedded_raster_tables_image(
49    doc: &Document,
50    page_id: lopdf::ObjectId,
51    image_chunks: &[ImageChunk],
52    text_chunks: &[TextChunk],
53) -> Vec<TableBorder> {
54    let mut out = Vec::new();
55    for image in image_chunks {
56        if !is_table_image_candidate(image, text_chunks) {
57            continue;
58        }
59        let Some(raw_index) = image.index else {
60            continue;
61        };
62        // chunk_parser / extract_image_data use 1-based XObject order.
63        let index = raw_index.max(1);
64        let Ok(Some(extracted)) = extract_image_data(doc, page_id, index) else {
65            continue;
66        };
67        if let Some(table) = table_from_extracted(
68            &extracted.data,
69            &extracted.filter,
70            extracted.width,
71            extracted.height,
72            image,
73            text_chunks,
74        ) {
75            if bordered_table_is_plausible(&table) {
76                out.push(table);
77            }
78        }
79    }
80    out
81}
82
83#[cfg(feature = "image")]
84fn is_table_image_candidate(image: &ImageChunk, text_chunks: &[TextChunk]) -> bool {
85    let w = image.bbox.width();
86    let h = image.bbox.height();
87    if w < 80.0 || h < 40.0 {
88        return false;
89    }
90    let native_chars: usize = text_chunks
91        .iter()
92        .filter(|t| image.bbox.intersection_percent(&t.bbox) >= 0.7)
93        .map(|t| t.value.chars().filter(|c| !c.is_whitespace()).count())
94        .sum();
95    native_chars <= MAX_NATIVE_TEXT_CHARS_IN_IMAGE
96}
97
98/// Prepared OCR candidate for the two-phase (plan → OCR → finish) path.
99///
100/// Emitted only when OCR can change the output (table-like Image XObject that
101/// is not an obvious bar chart). Missing OCR words at finish time degrades to
102/// PDF-text cells when a bordered grid is already known.
103#[cfg(feature = "image")]
104#[derive(Debug, Clone)]
105pub struct RasterCandidate {
106    /// Stable id within a session (0-based).
107    pub id: u32,
108    /// 1-based PDF page number.
109    pub page: u32,
110    /// Grayscale pixels (row-major).
111    pub gray: Vec<u8>,
112    /// Pixel width.
113    pub width: u32,
114    /// Pixel height.
115    pub height: u32,
116    /// FNV-1a hash of gray pixels (hex), for OCR result caching.
117    pub hash: String,
118    /// Image placement in PDF user space.
119    pub image_bbox: BoundingBox,
120    /// Overlapping native text (PDF fallback when OCR is absent).
121    pub text_chunks: Vec<TextChunk>,
122    /// Precomputed ruled grid when ink lines are strong enough.
123    bordered_grid: Option<Grid>,
124}
125
126#[cfg(feature = "image")]
127impl RasterCandidate {
128    /// Metadata for JS without copying gray pixels.
129    pub fn meta(&self) -> RasterCandidateMeta {
130        RasterCandidateMeta {
131            id: self.id,
132            page: self.page,
133            width: self.width,
134            height: self.height,
135            hash: self.hash.clone(),
136        }
137    }
138}
139
140/// Serializable candidate metadata (no pixel buffer).
141#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
142pub struct RasterCandidateMeta {
143    /// Session-local id.
144    pub id: u32,
145    /// 1-based page.
146    pub page: u32,
147    /// Pixel width.
148    pub width: u32,
149    /// Pixel height.
150    pub height: u32,
151    /// Content hash for cache keys.
152    pub hash: String,
153}
154
155/// Collect OCR candidates from Image XObjects (no OCR yet).
156#[cfg(feature = "image")]
157pub fn collect_raster_candidates(
158    doc: &Document,
159    page_id: lopdf::ObjectId,
160    image_chunks: &[ImageChunk],
161    text_chunks: &[TextChunk],
162    next_id: &mut u32,
163) -> Vec<RasterCandidate> {
164    let mut out = Vec::new();
165    for image in image_chunks {
166        if !is_table_image_candidate(image, text_chunks) {
167            continue;
168        }
169        let Some(raw_index) = image.index else {
170            continue;
171        };
172        let index = raw_index.max(1);
173        let Ok(Some(extracted)) = extract_image_data(doc, page_id, index) else {
174            continue;
175        };
176        if let Some(cand) = prepare_candidate(
177            *next_id,
178            &extracted.data,
179            &extracted.filter,
180            extracted.width,
181            extracted.height,
182            image,
183            text_chunks,
184        ) {
185            *next_id = next_id.saturating_add(1);
186            out.push(cand);
187        }
188    }
189    out
190}
191
192#[cfg(not(feature = "image"))]
193/// Stub when the `image` feature is off.
194pub fn collect_raster_candidates(
195    _doc: &Document,
196    _page_id: lopdf::ObjectId,
197    _image_chunks: &[ImageChunk],
198    _text_chunks: &[TextChunk],
199    _next_id: &mut u32,
200) -> Vec<()> {
201    Vec::new()
202}
203
204/// Decode + pre-filter a single Image XObject into an OCR candidate.
205#[cfg(feature = "image")]
206pub fn prepare_candidate(
207    id: u32,
208    data: &[u8],
209    filter: &str,
210    width: u32,
211    height: u32,
212    image: &ImageChunk,
213    text_chunks: &[TextChunk],
214) -> Option<RasterCandidate> {
215    let gray = decode_to_gray(data, filter, width, height)?;
216    if is_obvious_bar_chart(&gray) {
217        return None;
218    }
219    let bordered = detect_bordered_grid(&gray).filter(|g| grid_has_cell_ink(&gray, g));
220    // Emit even without a ruled grid: host OCR word centers can seed the lattice
221    // (docs like 122 / BamHI). Cheap size gate already applied upstream.
222    let hash = fnv1a_hex(gray.as_raw());
223    let overlapping: Vec<TextChunk> = text_chunks
224        .iter()
225        .filter(|t| image.bbox.intersection_percent(&t.bbox) >= 0.7)
226        .cloned()
227        .collect();
228    let (gw, gh) = (gray.width(), gray.height());
229    Some(RasterCandidate {
230        id,
231        page: image.bbox.page_number?,
232        gray: gray.into_raw(),
233        width: gw,
234        height: gh,
235        hash,
236        image_bbox: image.bbox.clone(),
237        text_chunks: overlapping,
238        bordered_grid: bordered,
239    })
240}
241
242/// Build a [`TableBorder`] from a prepared candidate + optional OCR words.
243#[cfg(feature = "image")]
244pub fn build_table_from_candidate(
245    candidate: &RasterCandidate,
246    words: &[crate::pdf::ocr::OcrWord],
247) -> Option<TableBorder> {
248    let gray = GrayImage::from_raw(candidate.width, candidate.height, candidate.gray.clone())?;
249    let mut words = words.to_vec();
250    let mut grid = candidate
251        .bordered_grid
252        .clone()
253        .or_else(|| detect_ocr_word_grid(&words, candidate.width, candidate.height));
254    if grid.is_none() {
255        if let Some(ocrs_words) = recognize_words_ocrs_only(&gray) {
256            if let Some(g) = candidate
257                .bordered_grid
258                .clone()
259                .or_else(|| detect_ocr_word_grid(&ocrs_words, candidate.width, candidate.height))
260            {
261                words = ocrs_words;
262                grid = Some(g);
263            }
264        }
265    }
266    let grid = grid?;
267    build_table_cells(
268        &grid,
269        &gray,
270        &candidate.image_bbox,
271        &candidate.text_chunks,
272        &words,
273    )
274}
275
276#[cfg(feature = "image")]
277fn table_from_extracted(
278    data: &[u8],
279    filter: &str,
280    width: u32,
281    height: u32,
282    image: &ImageChunk,
283    text_chunks: &[TextChunk],
284) -> Option<TableBorder> {
285    let candidate = prepare_candidate(0, data, filter, width, height, image, text_chunks)?;
286    let gray = GrayImage::from_raw(candidate.width, candidate.height, candidate.gray.clone())?;
287    let mut words = recognize_words(&gray);
288    let mut grid = candidate
289        .bordered_grid
290        .clone()
291        .or_else(|| detect_ocr_word_grid(&words, gray.width(), gray.height()));
292    // Host engines (PP-OCR) may return few line-level boxes that cannot seed
293    // a column lattice; retry with in-process ocrs when available.
294    if grid.is_none() {
295        if let Some(ocrs_words) = recognize_words_ocrs_only(&gray) {
296            if let Some(g) = candidate
297                .bordered_grid
298                .clone()
299                .or_else(|| detect_ocr_word_grid(&ocrs_words, gray.width(), gray.height()))
300            {
301                words = ocrs_words;
302                grid = Some(g);
303            }
304        }
305    }
306    let grid = grid?;
307    build_table_cells(
308        &grid,
309        &gray,
310        &candidate.image_bbox,
311        &candidate.text_chunks,
312        &words,
313    )
314}
315
316#[cfg(feature = "image")]
317fn build_table_cells(
318    grid: &Grid,
319    gray: &GrayImage,
320    image_bbox: &BoundingBox,
321    text_chunks: &[TextChunk],
322    words: &[crate::pdf::ocr::OcrWord],
323) -> Option<TableBorder> {
324    let num_cols = grid.v.len().checked_sub(1)?;
325    let num_rows = grid.h.len().checked_sub(1)?;
326    if num_cols < 2 || num_rows < 2 {
327        return None;
328    }
329
330    let use_ocr = !words.is_empty();
331
332    let x_coords = map_boundaries(&grid.v, image_bbox.left_x, image_bbox.right_x, gray.width())?;
333    let y_coords = map_boundaries_desc(
334        &grid.h,
335        image_bbox.bottom_y,
336        image_bbox.top_y,
337        gray.height(),
338    )?;
339
340    let mut rows = Vec::with_capacity(num_rows);
341    let mut non_empty = 0usize;
342    for row_idx in 0..num_rows {
343        let mut cells = Vec::with_capacity(num_cols);
344        for col_idx in 0..num_cols {
345            let x1 = grid.v[col_idx];
346            let x2 = grid.v[col_idx + 1];
347            let y1 = grid.h[row_idx];
348            let y2 = grid.h[row_idx + 1];
349            let cell_bbox = BoundingBox::new(
350                image_bbox.page_number,
351                x_coords[col_idx],
352                y_coords[row_idx + 1],
353                x_coords[col_idx + 1],
354                y_coords[row_idx],
355            );
356            let (text, font_name) = if use_ocr {
357                (words_in_cell(words, x1, y1, x2, y2), "OCR")
358            } else {
359                (text_chunks_in_cell(text_chunks, &cell_bbox), "PDF")
360            };
361            let mut content = Vec::new();
362            if !text.is_empty() {
363                non_empty += 1;
364                content.push(TableToken {
365                    base: TextChunk {
366                        value: text,
367                        bbox: cell_bbox.clone(),
368                        font_name: font_name.into(),
369                        font_size: (cell_bbox.height() * 0.55).max(6.0),
370                        font_weight: if row_idx == 0 { 700.0 } else { 400.0 },
371                        italic_angle: 0.0,
372                        font_color: "#000000".into(),
373                        contrast_ratio: 21.0,
374                        symbol_ends: Vec::new(),
375                        text_format: TextFormat::Normal,
376                        text_type: TextType::Regular,
377                        pdf_layer: PdfLayer::Content,
378                        ocg_visible: true,
379                        index: None,
380                        page_number: image_bbox.page_number,
381                        level: None,
382                        mcid: None,
383                    },
384                    token_type: TableTokenType::Text,
385                });
386            }
387            cells.push(TableBorderCell {
388                bbox: cell_bbox,
389                index: None,
390                level: None,
391                row_number: row_idx,
392                col_number: col_idx,
393                row_span: 1,
394                col_span: 1,
395                content,
396                contents: Vec::new(),
397                semantic_type: None,
398            });
399        }
400        let row_bbox = BoundingBox::new(
401            image_bbox.page_number,
402            image_bbox.left_x,
403            y_coords[row_idx + 1],
404            image_bbox.right_x,
405            y_coords[row_idx],
406        );
407        rows.push(TableBorderRow {
408            bbox: row_bbox,
409            index: None,
410            level: None,
411            row_number: row_idx,
412            cells,
413            semantic_type: None,
414        });
415    }
416    if non_empty == 0 {
417        return None;
418    }
419
420    let table_bbox = BoundingBox::new(
421        image_bbox.page_number,
422        *x_coords.first()?,
423        *y_coords.last()?,
424        *x_coords.last()?,
425        *y_coords.first()?,
426    );
427    Some(TableBorder {
428        bbox: table_bbox,
429        index: None,
430        level: None,
431        x_coordinates: x_coords.clone(),
432        x_widths: vec![0.0; x_coords.len()],
433        y_coordinates: y_coords.clone(),
434        y_widths: vec![0.0; y_coords.len()],
435        rows,
436        num_rows,
437        num_columns: num_cols,
438        is_bad_table: false,
439        is_table_transformer: true,
440        previous_table: None,
441        next_table: None,
442    })
443}
444
445#[cfg(feature = "image")]
446fn fnv1a_hex(bytes: &[u8]) -> String {
447    let mut hash: u64 = 0xcbf29ce484222325;
448    for &b in bytes {
449        hash ^= u64::from(b);
450        hash = hash.wrapping_mul(0x100000001b3);
451    }
452    format!("{hash:016x}")
453}
454
455#[cfg(feature = "image")]
456fn recognize_words(gray: &GrayImage) -> Vec<crate::pdf::ocr::OcrWord> {
457    #[cfg(any(not(target_arch = "wasm32"), feature = "ocr-ocrs"))]
458    {
459        use crate::pdf::ocr::{default_engine, OcrEngine};
460        return OcrEngine::recognize(&*default_engine(), gray);
461    }
462    #[cfg(all(target_arch = "wasm32", not(feature = "ocr-ocrs")))]
463    {
464        let _ = gray;
465        Vec::new()
466    }
467}
468
469/// Force in-process ocrs (skip host), used when host boxes cannot form a lattice.
470#[cfg(feature = "image")]
471fn recognize_words_ocrs_only(gray: &GrayImage) -> Option<Vec<crate::pdf::ocr::OcrWord>> {
472    #[cfg(feature = "ocr-ocrs")]
473    {
474        use crate::pdf::ocr::{OcrEngine, OcrsEngine};
475        return OcrsEngine::try_from_env().map(|e| e.recognize(gray));
476    }
477    #[cfg(not(feature = "ocr-ocrs"))]
478    {
479        let _ = gray;
480        None
481    }
482}
483
484#[cfg(feature = "image")]
485fn decode_to_gray(data: &[u8], filter: &str, width: u32, height: u32) -> Option<GrayImage> {
486    if filter == "DCTDecode" || data.starts_with(&[0xFF, 0xD8]) {
487        return image::load_from_memory(data).ok().map(|img| img.to_luma8());
488    }
489    if width == 0 || height == 0 {
490        return None;
491    }
492    let n = (width as usize).checked_mul(height as usize)?;
493    if data.len() == n {
494        return GrayImage::from_raw(width, height, data.to_vec());
495    }
496    if data.len() == n * 3 {
497        let mut luma = Vec::with_capacity(n);
498        for px in data.chunks_exact(3) {
499            let y = (0.299 * f64::from(px[0]) + 0.587 * f64::from(px[1]) + 0.114 * f64::from(px[2]))
500                .round()
501                .clamp(0.0, 255.0) as u8;
502            luma.push(y);
503        }
504        return GrayImage::from_raw(width, height, luma);
505    }
506    if data.len() == n * 4 {
507        let mut luma = Vec::with_capacity(n);
508        for px in data.chunks_exact(4) {
509            let y = (0.299 * f64::from(px[0]) + 0.587 * f64::from(px[1]) + 0.114 * f64::from(px[2]))
510                .round()
511                .clamp(0.0, 255.0) as u8;
512            luma.push(y);
513        }
514        return GrayImage::from_raw(width, height, luma);
515    }
516    image::load_from_memory(data).ok().map(|img| img.to_luma8())
517}
518
519#[cfg(feature = "image")]
520#[derive(Debug, Clone)]
521struct Grid {
522    v: Vec<u32>,
523    h: Vec<u32>,
524}
525
526#[cfg(feature = "image")]
527fn detect_bordered_grid(gray: &GrayImage) -> Option<Grid> {
528    let width = gray.width();
529    let height = gray.height();
530    if width < 100 || height < 80 {
531        return None;
532    }
533    let min_v = (f64::from(height) * MIN_LINE_DARK_RATIO).ceil() as u32;
534    let min_h = (f64::from(width) * MIN_LINE_DARK_RATIO).ceil() as u32;
535    let mut vertical: Vec<u32> =
536        merge_runs((0..width).filter(|&x| count_dark_col(gray, x) >= min_v))
537            .into_iter()
538            .map(|(a, b)| (a + b) / 2)
539            .collect();
540    let mut horizontal: Vec<u32> =
541        merge_runs((0..height).filter(|&y| count_dark_row(gray, y) >= min_h))
542            .into_iter()
543            .map(|(a, b)| (a + b) / 2)
544            .collect();
545    if vertical.len() < MIN_BORDERED_VERTICAL_LINES
546        || horizontal.len() < MIN_BORDERED_HORIZONTAL_LINES
547    {
548        return None;
549    }
550    let (&min_x, &max_x) = vertical.first().zip(vertical.last())?;
551    let (&min_y, &max_y) = horizontal.first().zip(horizontal.last())?;
552    vertical.retain(|&x| dark_ratio_col(gray, x, min_y, max_y) >= MIN_TRUE_GRID_LINE_CONTINUITY);
553    horizontal.retain(|&y| dark_ratio_row(gray, y, min_x, max_x) >= MIN_TRUE_GRID_LINE_CONTINUITY);
554    if vertical.len() < MIN_BORDERED_VERTICAL_LINES
555        || horizontal.len() < MIN_BORDERED_HORIZONTAL_LINES
556    {
557        return None;
558    }
559    Some(Grid {
560        v: vertical,
561        h: horizontal,
562    })
563}
564
565/// Lattice from OCR word centers when ruling lines are faint / incomplete.
566#[cfg(feature = "image")]
567fn detect_ocr_word_grid(
568    words: &[crate::pdf::ocr::OcrWord],
569    width: u32,
570    height: u32,
571) -> Option<Grid> {
572    if words.len() < 8 || width < 80 || height < 60 {
573        return None;
574    }
575    let mut heights: Vec<u32> = words.iter().map(|w| w.height.max(1)).collect();
576    heights.sort_unstable();
577    let med_h = heights[heights.len() / 2].max(6);
578    let y_gap = (med_h as f64 * 0.85).round() as u32;
579
580    let mut xs: Vec<u32> = words
581        .iter()
582        // Full-width line boxes (common from PP-OCR) collapse all columns.
583        .filter(|w| w.width < width.saturating_mul(45) / 100 || w.width < 80)
584        .map(|w| w.left + w.width / 2)
585        .collect();
586    if xs.len() < 6 {
587        xs = words.iter().map(|w| w.left + w.width / 2).collect();
588    }
589    let mut ys: Vec<u32> = words.iter().map(|w| w.top + w.height / 2).collect();
590    xs.sort_unstable();
591    ys.sort_unstable();
592    // Header words are wide; gap from word *width* merges real columns.
593    // Use median successive-center gap instead (within-col vs between-col).
594    let x_gap = adaptive_col_gap(&xs, med_h);
595    let col_centers = cluster_1d_centers(&xs, x_gap);
596    let row_centers = cluster_1d_centers(&ys, y_gap);
597    if col_centers.len() < 2 || row_centers.len() < 3 {
598        return None;
599    }
600    // Reject sparse one-column prose wrapped as a "table".
601    let occupied = count_occupied_cells(words, &col_centers, &row_centers, x_gap, y_gap);
602    let cells = col_centers.len() * row_centers.len();
603    if occupied < 6 || (occupied as f64) < 0.35 * cells as f64 {
604        return None;
605    }
606
607    let v = centers_to_boundaries(&col_centers, 0, width);
608    let h = centers_to_boundaries(&row_centers, 0, height);
609    if v.len() < 3 || h.len() < 4 {
610        return None;
611    }
612    Some(Grid { v, h })
613}
614
615/// Between-column gap: outlier vs typical within-column jitter of word centers.
616#[cfg(feature = "image")]
617fn adaptive_col_gap(xs_sorted: &[u32], med_h: u32) -> u32 {
618    let mut u = xs_sorted.to_vec();
619    u.dedup();
620    if u.len() < 3 {
621        return (med_h as f64 * 2.5).round().max(12.0) as u32;
622    }
623    let mut diffs: Vec<u32> = u
624        .windows(2)
625        .map(|w| w[1] - w[0])
626        .filter(|&d| d > 1)
627        .collect();
628    if diffs.is_empty() {
629        return (med_h as f64 * 2.5).round().max(12.0) as u32;
630    }
631    diffs.sort_unstable();
632    let med = diffs[diffs.len() / 2].max(1);
633    ((med as f64) * 1.35)
634        .round()
635        .max((med_h as f64) * 2.0)
636        .max(16.0) as u32
637}
638
639#[cfg(feature = "image")]
640fn cluster_1d_centers(sorted: &[u32], gap: u32) -> Vec<u32> {
641    if sorted.is_empty() {
642        return Vec::new();
643    }
644    let mut clusters: Vec<Vec<u32>> = vec![vec![sorted[0]]];
645    for &x in &sorted[1..] {
646        let last = clusters.last_mut().unwrap();
647        if x.saturating_sub(*last.last().unwrap()) <= gap {
648            last.push(x);
649        } else {
650            clusters.push(vec![x]);
651        }
652    }
653    clusters
654        .into_iter()
655        .map(|c| c.iter().sum::<u32>() / c.len() as u32)
656        .collect()
657}
658
659#[cfg(feature = "image")]
660fn centers_to_boundaries(centers: &[u32], min_edge: u32, max_edge: u32) -> Vec<u32> {
661    if centers.is_empty() {
662        return vec![min_edge, max_edge];
663    }
664    let mut b = Vec::with_capacity(centers.len() + 1);
665    b.push(min_edge);
666    for i in 0..centers.len() - 1 {
667        b.push((centers[i] + centers[i + 1]) / 2);
668    }
669    b.push(max_edge.max(min_edge + 1));
670    // Ensure strictly increasing.
671    for i in 1..b.len() {
672        if b[i] <= b[i - 1] {
673            b[i] = b[i - 1] + 1;
674        }
675    }
676    b
677}
678
679#[cfg(feature = "image")]
680fn count_occupied_cells(
681    words: &[crate::pdf::ocr::OcrWord],
682    cols: &[u32],
683    rows: &[u32],
684    x_gap: u32,
685    y_gap: u32,
686) -> usize {
687    let mut seen = std::collections::HashSet::new();
688    for w in words {
689        let cx = w.left + w.width / 2;
690        let cy = w.top + w.height / 2;
691        let Some(ci) = cols
692            .iter()
693            .position(|&c| cx.abs_diff(c) <= x_gap.saturating_mul(2))
694        else {
695            continue;
696        };
697        let Some(ri) = rows
698            .iter()
699            .position(|&r| cy.abs_diff(r) <= y_gap.saturating_mul(2))
700        else {
701            continue;
702        };
703        seen.insert((ri, ci));
704    }
705    seen.len()
706}
707
708#[cfg(feature = "image")]
709fn grid_has_cell_ink(gray: &GrayImage, grid: &Grid) -> bool {
710    let cols = grid.v.len().saturating_sub(1);
711    let rows = grid.h.len().saturating_sub(1);
712    if cols == 0 || rows == 0 {
713        return false;
714    }
715    let mut inked = 0usize;
716    let mut rows_with = 0usize;
717    let total = cols * rows;
718    for r in 0..rows {
719        let mut row_ink = false;
720        for c in 0..cols {
721            if cell_dark_ratio(gray, grid.v[c], grid.h[r], grid.v[c + 1], grid.h[r + 1]) >= 0.03 {
722                inked += 1;
723                row_ink = true;
724            }
725        }
726        if row_ink {
727            rows_with += 1;
728        }
729    }
730    (inked as f64 / total as f64) >= MIN_BORDERED_INKED_CELL_RATIO
731        && rows_with >= MIN_BORDERED_ROWS_WITH_INK
732}
733
734#[cfg(feature = "image")]
735fn is_obvious_bar_chart(gray: &GrayImage) -> bool {
736    if detect_bordered_grid(gray).is_some() {
737        return false;
738    }
739    let w = gray.width();
740    let h = gray.height();
741    if w < 120 || h < 80 {
742        return false;
743    }
744    let mut peaks = 0usize;
745    let mut run = 0u32;
746    let thresh = (h as f64 * 0.35) as u32;
747    for x in 0..w {
748        let dark = count_dark_col(gray, x);
749        if dark >= thresh {
750            run += 1;
751        } else if run > 0 {
752            if run >= 3 {
753                peaks += 1;
754            }
755            run = 0;
756        }
757    }
758    peaks >= 4
759}
760
761#[cfg(feature = "image")]
762fn count_dark_col(gray: &GrayImage, x: u32) -> u32 {
763    (0..gray.height())
764        .filter(|&y| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
765        .count() as u32
766}
767
768#[cfg(feature = "image")]
769fn count_dark_row(gray: &GrayImage, y: u32) -> u32 {
770    (0..gray.width())
771        .filter(|&x| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
772        .count() as u32
773}
774
775#[cfg(feature = "image")]
776fn dark_ratio_col(gray: &GrayImage, x: u32, y1: u32, y2: u32) -> f64 {
777    let h = y2.saturating_sub(y1).max(1);
778    let dark = (y1..y2)
779        .filter(|&y| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
780        .count() as f64;
781    dark / f64::from(h)
782}
783
784#[cfg(feature = "image")]
785fn dark_ratio_row(gray: &GrayImage, y: u32, x1: u32, x2: u32) -> f64 {
786    let w = x2.saturating_sub(x1).max(1);
787    let dark = (x1..x2)
788        .filter(|&x| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
789        .count() as f64;
790    dark / f64::from(w)
791}
792
793#[cfg(feature = "image")]
794fn cell_dark_ratio(gray: &GrayImage, x1: u32, y1: u32, x2: u32, y2: u32) -> f64 {
795    let mut dark = 0u32;
796    let mut total = 0u32;
797    for y in y1..y2 {
798        for x in x1..x2 {
799            total += 1;
800            if gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD {
801                dark += 1;
802            }
803        }
804    }
805    if total == 0 {
806        0.0
807    } else {
808        f64::from(dark) / f64::from(total)
809    }
810}
811
812#[cfg(feature = "image")]
813fn merge_runs(xs: impl Iterator<Item = u32>) -> Vec<(u32, u32)> {
814    let mut out = Vec::new();
815    let mut cur: Option<(u32, u32)> = None;
816    for x in xs {
817        cur = match cur {
818            None => Some((x, x)),
819            Some((a, b)) if x <= b + 2 => Some((a, x)),
820            Some(r) => {
821                out.push(r);
822                Some((x, x))
823            }
824        };
825    }
826    if let Some(r) = cur {
827        out.push(r);
828    }
829    out
830}
831
832#[cfg(feature = "image")]
833fn map_boundaries(lines: &[u32], page_a: f64, page_b: f64, raster_span: u32) -> Option<Vec<f64>> {
834    if raster_span == 0 || lines.is_empty() {
835        return None;
836    }
837    let span = page_b - page_a;
838    Some(
839        lines
840            .iter()
841            .map(|&p| page_a + span * (f64::from(p) / f64::from(raster_span)))
842            .collect(),
843    )
844}
845
846#[cfg(feature = "image")]
847fn map_boundaries_desc(
848    lines: &[u32],
849    page_bottom: f64,
850    page_top: f64,
851    raster_span: u32,
852) -> Option<Vec<f64>> {
853    if raster_span == 0 || lines.is_empty() {
854        return None;
855    }
856    let span = page_top - page_bottom;
857    Some(
858        lines
859            .iter()
860            .map(|&p| page_top - span * (f64::from(p) / f64::from(raster_span)))
861            .collect(),
862    )
863}
864
865#[cfg(feature = "image")]
866fn words_in_cell(words: &[crate::pdf::ocr::OcrWord], x1: u32, y1: u32, x2: u32, y2: u32) -> String {
867    let mut hit: Vec<&crate::pdf::ocr::OcrWord> = words
868        .iter()
869        .filter(|w| {
870            let cx = w.left + w.width / 2;
871            let cy = w.top + w.height / 2;
872            cx >= x1 && cx < x2 && cy >= y1 && cy < y2
873        })
874        .collect();
875    hit.sort_by(|a, b| (a.top, a.left).cmp(&(b.top, b.left)));
876    hit.iter()
877        .map(|w| w.text.as_str())
878        .collect::<Vec<_>>()
879        .join(" ")
880}
881
882/// Bucket PDF text whose center falls inside a cell (WASM path without OCR).
883#[cfg(feature = "image")]
884fn text_chunks_in_cell(text_chunks: &[TextChunk], cell: &BoundingBox) -> String {
885    let mut hit: Vec<&TextChunk> = text_chunks
886        .iter()
887        .filter(|t| {
888            if t.value.trim().is_empty() {
889                return false;
890            }
891            let cx = (t.bbox.left_x + t.bbox.right_x) * 0.5;
892            let cy = (t.bbox.bottom_y + t.bbox.top_y) * 0.5;
893            cx >= cell.left_x && cx <= cell.right_x && cy >= cell.bottom_y && cy <= cell.top_y
894        })
895        .collect();
896    hit.sort_by(|a, b| {
897        b.bbox
898            .top_y
899            .partial_cmp(&a.bbox.top_y)
900            .unwrap_or(std::cmp::Ordering::Equal)
901            .then_with(|| {
902                a.bbox
903                    .left_x
904                    .partial_cmp(&b.bbox.left_x)
905                    .unwrap_or(std::cmp::Ordering::Equal)
906            })
907    });
908    hit.iter()
909        .map(|t| t.value.trim())
910        .filter(|s| !s.is_empty())
911        .collect::<Vec<_>>()
912        .join(" ")
913}
914
915#[cfg(feature = "image")]
916/// True when a recovered bordered table looks contentful enough to keep.
917pub fn bordered_table_is_plausible(table: &TableBorder) -> bool {
918    if table.num_rows < 2 || table.num_columns < 2 {
919        return false;
920    }
921    let mut token_counts = Vec::new();
922    for row in &table.rows {
923        for cell in &row.cells {
924            let n = cell
925                .content
926                .iter()
927                .map(|t| t.base.value.split_whitespace().count())
928                .sum::<usize>();
929            if n > 0 {
930                token_counts.push(n);
931            }
932        }
933    }
934    if token_counts.is_empty() {
935        return false;
936    }
937    let mut sorted = token_counts.clone();
938    sorted.sort_unstable();
939    let median = sorted[sorted.len() / 2].max(1);
940    let max = *token_counts.iter().max().unwrap_or(&0);
941    !(max >= 8 && max >= median.saturating_mul(8))
942}