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, ImagePaintSource, 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/// How OCR words from a raster candidate should be assembled.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub enum RasterCandidateKind {
33    /// Ruled / table-like image → [`TableBorder`].
34    Table,
35    /// Pattern-painted (or otherwise non-tabular) raster → free-form [`TextChunk`]s.
36    TextBlocks,
37}
38
39/// Recover bordered tables from embedded Image XObjects on a page.
40pub fn recover_embedded_raster_tables(
41    doc: &Document,
42    page_id: lopdf::ObjectId,
43    image_chunks: &[ImageChunk],
44    text_chunks: &[TextChunk],
45) -> Vec<TableBorder> {
46    #[cfg(not(feature = "image"))]
47    {
48        let _ = (doc, page_id, image_chunks, text_chunks);
49        Vec::new()
50    }
51    #[cfg(feature = "image")]
52    {
53        recover_embedded_raster_tables_image(doc, page_id, image_chunks, text_chunks)
54    }
55}
56
57#[cfg(feature = "image")]
58fn recover_embedded_raster_tables_image(
59    doc: &Document,
60    page_id: lopdf::ObjectId,
61    image_chunks: &[ImageChunk],
62    text_chunks: &[TextChunk],
63) -> Vec<TableBorder> {
64    let mut out = Vec::new();
65    for image in image_chunks {
66        if !is_table_image_candidate(image, text_chunks) {
67            continue;
68        }
69        let Some(raw_index) = image.index else {
70            continue;
71        };
72        // chunk_parser / extract_image_data use 1-based XObject order.
73        let index = raw_index.max(1);
74        let Ok(Some(extracted)) = extract_image_data(doc, page_id, index) else {
75            continue;
76        };
77        if let Some(table) = table_from_extracted(
78            &extracted.data,
79            &extracted.filter,
80            extracted.width,
81            extracted.height,
82            image,
83            text_chunks,
84        ) {
85            if bordered_table_is_plausible(&table) {
86                out.push(table);
87            }
88        }
89    }
90    out
91}
92
93#[cfg(feature = "image")]
94fn is_table_image_candidate(image: &ImageChunk, text_chunks: &[TextChunk]) -> bool {
95    let w = image.bbox.width();
96    let h = image.bbox.height();
97    if w < 80.0 || h < 40.0 {
98        return false;
99    }
100    native_text_chars_in_image(image, text_chunks) <= MAX_NATIVE_TEXT_CHARS_IN_IMAGE
101}
102
103fn native_text_chars_in_image(image: &ImageChunk, text_chunks: &[TextChunk]) -> usize {
104    text_chunks
105        .iter()
106        .filter(|t| native_text_overlaps_image(t, image))
107        .map(|t| t.value.chars().filter(|c| !c.is_whitespace()).count())
108        .sum()
109}
110
111/// Native PDF text that intersects the image bbox (operator texts painted on
112/// top of / inside the image placement).
113fn native_text_overlaps_image(text: &TextChunk, image: &ImageChunk) -> bool {
114    // Center-in-bbox: the glyph run's position is defined by PDF text operators.
115    let cx = text.bbox.center_x();
116    let cy = text.bbox.center_y();
117    cx >= image.bbox.left_x
118        && cx <= image.bbox.right_x
119        && cy >= image.bbox.bottom_y
120        && cy <= image.bbox.top_y
121}
122
123/// True when the image placement coincides with the page box (full-bleed
124/// slide / scan). Uses ±1 pt PDF coordinate epsilon — geometric identity,
125/// not a coverage heuristic.
126fn is_full_page_image(image: &ImageChunk, page_width: f64, page_height: f64) -> bool {
127    if page_width <= 1.0 || page_height <= 1.0 {
128        return false;
129    }
130    const EPS: f64 = 1.0;
131    image.bbox.width() + EPS >= page_width && image.bbox.height() + EPS >= page_height
132}
133
134/// Decide whether OCR can add information, and which assembly path to use.
135///
136/// First principles (ISO 32000):
137/// 1. PDF text operators are authoritative where present.
138/// 2. An Image XObject may encode glyphs the text layer lacks → OCR candidate.
139/// 3. Assembly kind:
140///    - page-paint rasters (Pattern fill, or `Do` spanning the MediaBox, or
141///      image-only pages) → [`TextBlocks`]
142///    - embedded figures with sparse native text → [`Table`] (ruled / word lattice)
143/// 4. Pixel ruled-grid detection in [`prepare_candidate`] may upgrade to Table.
144fn classify_raster_candidate(
145    image: &ImageChunk,
146    text_chunks: &[TextChunk],
147    page_width: f64,
148    page_height: f64,
149) -> Option<RasterCandidateKind> {
150    let sparse_native =
151        native_text_chars_in_image(image, text_chunks) <= MAX_NATIVE_TEXT_CHARS_IN_IMAGE;
152
153    // Pattern-painted images *are* the page content (Skia/Penpot/print).
154    if image.source == ImagePaintSource::PatternFill {
155        return Some(RasterCandidateKind::TextBlocks);
156    }
157
158    // Full-bleed Do/Inline: the image placement is the page.
159    if is_full_page_image(image, page_width, page_height) && sparse_native {
160        return Some(RasterCandidateKind::TextBlocks);
161    }
162
163    // Image-only page (no PDF text operators): every substantial image is content.
164    if text_chunks.is_empty() && image.bbox.width() >= 80.0 && image.bbox.height() >= 40.0 {
165        return Some(RasterCandidateKind::TextBlocks);
166    }
167
168    // Embedded figure that may encode a table lattice.
169    if is_table_image_candidate(image, text_chunks) {
170        return Some(RasterCandidateKind::Table);
171    }
172
173    None
174}
175
176/// Prepared OCR candidate for the two-phase (plan → OCR → finish) path.
177///
178/// Emitted when OCR can change the output (table-like or pattern-painted Image
179/// XObject). Missing OCR words at finish time degrades to PDF-text cells when
180/// a bordered grid is already known (tables only).
181#[cfg(feature = "image")]
182#[derive(Debug, Clone)]
183pub struct RasterCandidate {
184    /// Stable id within a session (0-based).
185    pub id: u32,
186    /// 1-based PDF page number.
187    pub page: u32,
188    /// Grayscale pixels (row-major).
189    pub gray: Vec<u8>,
190    /// Pixel width.
191    pub width: u32,
192    /// Pixel height.
193    pub height: u32,
194    /// FNV-1a hash of gray pixels (hex), for OCR result caching.
195    pub hash: String,
196    /// Image placement in PDF user space.
197    pub image_bbox: BoundingBox,
198    /// 1-based Image XObject index (matches [`ImageChunk::index`]).
199    pub image_index: Option<u32>,
200    /// Overlapping native text (PDF fallback when OCR is absent).
201    pub text_chunks: Vec<TextChunk>,
202    /// How OCR words should be assembled.
203    pub kind: RasterCandidateKind,
204    /// Precomputed ruled grid when ink lines are strong enough.
205    bordered_grid: Option<Grid>,
206}
207
208#[cfg(feature = "image")]
209impl RasterCandidate {
210    /// Metadata for JS without copying gray pixels.
211    pub fn meta(&self) -> RasterCandidateMeta {
212        RasterCandidateMeta {
213            id: self.id,
214            page: self.page,
215            width: self.width,
216            height: self.height,
217            hash: self.hash.clone(),
218            kind: self.kind,
219        }
220    }
221}
222
223/// Serializable candidate metadata (no pixel buffer).
224#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
225pub struct RasterCandidateMeta {
226    /// Session-local id.
227    pub id: u32,
228    /// 1-based page.
229    pub page: u32,
230    /// Pixel width.
231    pub width: u32,
232    /// Pixel height.
233    pub height: u32,
234    /// Content hash for cache keys.
235    pub hash: String,
236    /// How OCR words should be assembled.
237    #[serde(default = "default_candidate_kind")]
238    pub kind: RasterCandidateKind,
239}
240
241fn default_candidate_kind() -> RasterCandidateKind {
242    RasterCandidateKind::Table
243}
244
245/// Collect OCR candidates from Image XObjects (no OCR yet).
246#[cfg(feature = "image")]
247pub fn collect_raster_candidates(
248    doc: &Document,
249    page_id: lopdf::ObjectId,
250    image_chunks: &[ImageChunk],
251    text_chunks: &[TextChunk],
252    page_width: f64,
253    page_height: f64,
254    next_id: &mut u32,
255) -> Vec<RasterCandidate> {
256    let mut out = Vec::new();
257    for image in image_chunks {
258        let Some(kind) = classify_raster_candidate(image, text_chunks, page_width, page_height)
259        else {
260            continue;
261        };
262        let Some(raw_index) = image.index else {
263            continue;
264        };
265        let index = raw_index.max(1);
266        let Ok(Some(extracted)) = extract_image_data(doc, page_id, index) else {
267            continue;
268        };
269        if let Some(cand) = prepare_candidate(
270            *next_id,
271            &extracted.data,
272            &extracted.filter,
273            extracted.width,
274            extracted.height,
275            image,
276            text_chunks,
277            kind,
278        ) {
279            *next_id = next_id.saturating_add(1);
280            out.push(cand);
281        }
282    }
283    out
284}
285
286#[cfg(not(feature = "image"))]
287/// Stub when the `image` feature is off.
288pub fn collect_raster_candidates(
289    _doc: &Document,
290    _page_id: lopdf::ObjectId,
291    _image_chunks: &[ImageChunk],
292    _text_chunks: &[TextChunk],
293    _page_width: f64,
294    _page_height: f64,
295    _next_id: &mut u32,
296) -> Vec<()> {
297    Vec::new()
298}
299
300/// Decode + pre-filter a single Image XObject into an OCR candidate.
301#[cfg(feature = "image")]
302#[allow(clippy::too_many_arguments)]
303pub fn prepare_candidate(
304    id: u32,
305    data: &[u8],
306    filter: &str,
307    width: u32,
308    height: u32,
309    image: &ImageChunk,
310    text_chunks: &[TextChunk],
311    kind: RasterCandidateKind,
312) -> Option<RasterCandidate> {
313    let gray = decode_to_gray(data, filter, width, height)?;
314    if is_obvious_bar_chart(&gray) {
315        return None;
316    }
317    let bordered = detect_bordered_grid(&gray).filter(|g| grid_has_cell_ink(&gray, g));
318    // Ruled lattice in pixels → Table assembly. Otherwise keep the provisional
319    // kind from paint/page geometry (page-paint → TextBlocks, embedded → Table).
320    let kind = if bordered.is_some() {
321        RasterCandidateKind::Table
322    } else {
323        kind
324    };
325    let bordered = if kind == RasterCandidateKind::Table {
326        bordered
327    } else {
328        None
329    };
330    let hash = fnv1a_hex(gray.as_raw());
331    let overlapping: Vec<TextChunk> = text_chunks
332        .iter()
333        .filter(|t| native_text_overlaps_image(t, image))
334        .cloned()
335        .collect();
336    let (gw, gh) = (gray.width(), gray.height());
337    Some(RasterCandidate {
338        id,
339        page: image.bbox.page_number?,
340        gray: gray.into_raw(),
341        width: gw,
342        height: gh,
343        hash,
344        image_bbox: image.bbox.clone(),
345        image_index: image.index,
346        text_chunks: overlapping,
347        kind,
348        bordered_grid: bordered,
349    })
350}
351
352/// Build a [`TableBorder`] from a prepared candidate + optional OCR words.
353#[cfg(feature = "image")]
354pub fn build_table_from_candidate(
355    candidate: &RasterCandidate,
356    words: &[crate::pdf::ocr::OcrWord],
357) -> Option<TableBorder> {
358    if candidate.kind != RasterCandidateKind::Table {
359        return None;
360    }
361    let gray = GrayImage::from_raw(candidate.width, candidate.height, candidate.gray.clone())?;
362    let mut words = words.to_vec();
363    let mut grid = candidate
364        .bordered_grid
365        .clone()
366        .or_else(|| detect_ocr_word_grid(&words, candidate.width, candidate.height));
367    if grid.is_none() {
368        if let Some(ocrs_words) = recognize_words_ocrs_only(&gray) {
369            if let Some(g) = candidate
370                .bordered_grid
371                .clone()
372                .or_else(|| detect_ocr_word_grid(&ocrs_words, candidate.width, candidate.height))
373            {
374                words = ocrs_words;
375                grid = Some(g);
376            }
377        }
378    }
379    let grid = grid?;
380    build_table_cells(
381        &grid,
382        &gray,
383        &candidate.image_bbox,
384        &candidate.text_chunks,
385        &words,
386    )
387}
388
389/// Convert OCR words from a text-blocks candidate into page-space [`TextChunk`]s.
390///
391/// Native PDF text operators are authoritative for glyphs they paint. OCR words
392/// whose center falls inside a native text bbox are skipped (no IoU thresholds).
393#[cfg(feature = "image")]
394pub fn build_text_chunks_from_candidate(
395    candidate: &RasterCandidate,
396    words: &[crate::pdf::ocr::OcrWord],
397) -> Vec<TextChunk> {
398    ocr_words_to_text_chunks(candidate, words)
399}
400
401/// Map OCR words into page-space text chunks (shared by TextBlocks assembly and
402/// Table→text fallback when a lattice cannot be formed).
403#[cfg(feature = "image")]
404pub fn ocr_words_to_text_chunks(
405    candidate: &RasterCandidate,
406    words: &[crate::pdf::ocr::OcrWord],
407) -> Vec<TextChunk> {
408    if words.is_empty() {
409        return Vec::new();
410    }
411    let img = &candidate.image_bbox;
412    let pw = candidate.width.max(1) as f64;
413    let ph = candidate.height.max(1) as f64;
414    let mut out = Vec::with_capacity(words.len());
415    for (i, w) in words.iter().enumerate() {
416        let text = w.text.trim();
417        if text.is_empty() {
418            continue;
419        }
420        // Map pixel box → PDF user space. Image Y grows down; PDF Y grows up.
421        let left = img.left_x + (w.left as f64 / pw) * img.width();
422        let right = img.left_x + ((w.left + w.width) as f64 / pw) * img.width();
423        let top = img.top_y - (w.top as f64 / ph) * img.height();
424        let bottom = img.top_y - ((w.top + w.height) as f64 / ph) * img.height();
425        let bbox = BoundingBox::new(
426            Some(candidate.page),
427            left.min(right),
428            bottom.min(top),
429            left.max(right),
430            bottom.max(top),
431        );
432        let cx = bbox.center_x();
433        let cy = bbox.center_y();
434        // Skip OCR that lands on native PDF text (center-in-bbox).
435        let covered_by_native = candidate.text_chunks.iter().any(|t| {
436            cx >= t.bbox.left_x
437                && cx <= t.bbox.right_x
438                && cy >= t.bbox.bottom_y
439                && cy <= t.bbox.top_y
440        });
441        if covered_by_native {
442            continue;
443        }
444
445        let font_size = bbox.height().clamp(6.0, 72.0);
446        out.push(TextChunk {
447            value: text.to_string(),
448            bbox: bbox.clone(),
449            font_name: "OCR".to_string(),
450            font_size,
451            font_weight: 400.0,
452            italic_angle: 0.0,
453            font_color: "#000000".to_string(),
454            contrast_ratio: 21.0,
455            symbol_ends: vec![bbox.right_x],
456            text_format: TextFormat::Normal,
457            text_type: TextType::Regular,
458            pdf_layer: PdfLayer::Content,
459            ocg_visible: true,
460            index: Some(i),
461            page_number: Some(candidate.page),
462            level: None,
463            mcid: None,
464        });
465    }
466    out
467}
468
469#[cfg(feature = "image")]
470fn table_from_extracted(
471    data: &[u8],
472    filter: &str,
473    width: u32,
474    height: u32,
475    image: &ImageChunk,
476    text_chunks: &[TextChunk],
477) -> Option<TableBorder> {
478    let candidate = prepare_candidate(
479        0,
480        data,
481        filter,
482        width,
483        height,
484        image,
485        text_chunks,
486        RasterCandidateKind::Table,
487    )?;
488    let gray = GrayImage::from_raw(candidate.width, candidate.height, candidate.gray.clone())?;
489    let mut words = recognize_words(&gray);
490    let mut grid = candidate
491        .bordered_grid
492        .clone()
493        .or_else(|| detect_ocr_word_grid(&words, gray.width(), gray.height()));
494    // Host engines (PP-OCR) may return few line-level boxes that cannot seed
495    // a column lattice; retry with in-process ocrs when available.
496    if grid.is_none() {
497        if let Some(ocrs_words) = recognize_words_ocrs_only(&gray) {
498            if let Some(g) = candidate
499                .bordered_grid
500                .clone()
501                .or_else(|| detect_ocr_word_grid(&ocrs_words, gray.width(), gray.height()))
502            {
503                words = ocrs_words;
504                grid = Some(g);
505            }
506        }
507    }
508    let grid = grid?;
509    build_table_cells(
510        &grid,
511        &gray,
512        &candidate.image_bbox,
513        &candidate.text_chunks,
514        &words,
515    )
516}
517
518#[cfg(feature = "image")]
519fn build_table_cells(
520    grid: &Grid,
521    gray: &GrayImage,
522    image_bbox: &BoundingBox,
523    text_chunks: &[TextChunk],
524    words: &[crate::pdf::ocr::OcrWord],
525) -> Option<TableBorder> {
526    let num_cols = grid.v.len().checked_sub(1)?;
527    let num_rows = grid.h.len().checked_sub(1)?;
528    if num_cols < 2 || num_rows < 2 {
529        return None;
530    }
531
532    let use_ocr = !words.is_empty();
533
534    let x_coords = map_boundaries(&grid.v, image_bbox.left_x, image_bbox.right_x, gray.width())?;
535    let y_coords = map_boundaries_desc(
536        &grid.h,
537        image_bbox.bottom_y,
538        image_bbox.top_y,
539        gray.height(),
540    )?;
541
542    let mut rows = Vec::with_capacity(num_rows);
543    let mut non_empty = 0usize;
544    for row_idx in 0..num_rows {
545        let mut cells = Vec::with_capacity(num_cols);
546        for col_idx in 0..num_cols {
547            let x1 = grid.v[col_idx];
548            let x2 = grid.v[col_idx + 1];
549            let y1 = grid.h[row_idx];
550            let y2 = grid.h[row_idx + 1];
551            let cell_bbox = BoundingBox::new(
552                image_bbox.page_number,
553                x_coords[col_idx],
554                y_coords[row_idx + 1],
555                x_coords[col_idx + 1],
556                y_coords[row_idx],
557            );
558            let (text, font_name) = if use_ocr {
559                (words_in_cell(words, x1, y1, x2, y2), "OCR")
560            } else {
561                (text_chunks_in_cell(text_chunks, &cell_bbox), "PDF")
562            };
563            let mut content = Vec::new();
564            if !text.is_empty() {
565                non_empty += 1;
566                content.push(TableToken {
567                    base: TextChunk {
568                        value: text,
569                        bbox: cell_bbox.clone(),
570                        font_name: font_name.into(),
571                        font_size: (cell_bbox.height() * 0.55).max(6.0),
572                        font_weight: if row_idx == 0 { 700.0 } else { 400.0 },
573                        italic_angle: 0.0,
574                        font_color: "#000000".into(),
575                        contrast_ratio: 21.0,
576                        symbol_ends: Vec::new(),
577                        text_format: TextFormat::Normal,
578                        text_type: TextType::Regular,
579                        pdf_layer: PdfLayer::Content,
580                        ocg_visible: true,
581                        index: None,
582                        page_number: image_bbox.page_number,
583                        level: None,
584                        mcid: None,
585                    },
586                    token_type: TableTokenType::Text,
587                });
588            }
589            cells.push(TableBorderCell {
590                bbox: cell_bbox,
591                index: None,
592                level: None,
593                row_number: row_idx,
594                col_number: col_idx,
595                row_span: 1,
596                col_span: 1,
597                content,
598                contents: Vec::new(),
599                semantic_type: None,
600            });
601        }
602        let row_bbox = BoundingBox::new(
603            image_bbox.page_number,
604            image_bbox.left_x,
605            y_coords[row_idx + 1],
606            image_bbox.right_x,
607            y_coords[row_idx],
608        );
609        rows.push(TableBorderRow {
610            bbox: row_bbox,
611            index: None,
612            level: None,
613            row_number: row_idx,
614            cells,
615            semantic_type: None,
616        });
617    }
618    if non_empty == 0 {
619        return None;
620    }
621
622    let table_bbox = BoundingBox::new(
623        image_bbox.page_number,
624        *x_coords.first()?,
625        *y_coords.last()?,
626        *x_coords.last()?,
627        *y_coords.first()?,
628    );
629    Some(TableBorder {
630        bbox: table_bbox,
631        index: None,
632        level: None,
633        x_coordinates: x_coords.clone(),
634        x_widths: vec![0.0; x_coords.len()],
635        y_coordinates: y_coords.clone(),
636        y_widths: vec![0.0; y_coords.len()],
637        rows,
638        num_rows,
639        num_columns: num_cols,
640        is_bad_table: false,
641        is_table_transformer: true,
642        previous_table: None,
643        next_table: None,
644    })
645}
646
647#[cfg(feature = "image")]
648fn fnv1a_hex(bytes: &[u8]) -> String {
649    let mut hash: u64 = 0xcbf29ce484222325;
650    for &b in bytes {
651        hash ^= u64::from(b);
652        hash = hash.wrapping_mul(0x100000001b3);
653    }
654    format!("{hash:016x}")
655}
656
657#[cfg(feature = "image")]
658fn recognize_words(gray: &GrayImage) -> Vec<crate::pdf::ocr::OcrWord> {
659    #[cfg(any(not(target_arch = "wasm32"), feature = "ocr-ocrs"))]
660    {
661        use crate::pdf::ocr::{default_engine, OcrEngine};
662        return OcrEngine::recognize(&*default_engine(), gray);
663    }
664    #[cfg(all(target_arch = "wasm32", not(feature = "ocr-ocrs")))]
665    {
666        let _ = gray;
667        Vec::new()
668    }
669}
670
671/// Force in-process ocrs (skip host), used when host boxes cannot form a lattice.
672#[cfg(feature = "image")]
673fn recognize_words_ocrs_only(gray: &GrayImage) -> Option<Vec<crate::pdf::ocr::OcrWord>> {
674    #[cfg(feature = "ocr-ocrs")]
675    {
676        use crate::pdf::ocr::{OcrEngine, OcrsEngine};
677        return OcrsEngine::try_from_env().map(|e| e.recognize(gray));
678    }
679    #[cfg(not(feature = "ocr-ocrs"))]
680    {
681        let _ = gray;
682        None
683    }
684}
685
686#[cfg(feature = "image")]
687fn decode_to_gray(data: &[u8], filter: &str, width: u32, height: u32) -> Option<GrayImage> {
688    crate::pdf::image_codecs::decode_image_to_gray(data, filter, width, height).map(|d| d.gray)
689}
690
691#[cfg(feature = "image")]
692#[derive(Debug, Clone)]
693struct Grid {
694    v: Vec<u32>,
695    h: Vec<u32>,
696}
697
698#[cfg(feature = "image")]
699fn detect_bordered_grid(gray: &GrayImage) -> Option<Grid> {
700    let width = gray.width();
701    let height = gray.height();
702    if width < 100 || height < 80 {
703        return None;
704    }
705    let min_v = (f64::from(height) * MIN_LINE_DARK_RATIO).ceil() as u32;
706    let min_h = (f64::from(width) * MIN_LINE_DARK_RATIO).ceil() as u32;
707    let mut vertical: Vec<u32> =
708        merge_runs((0..width).filter(|&x| count_dark_col(gray, x) >= min_v))
709            .into_iter()
710            .map(|(a, b)| (a + b) / 2)
711            .collect();
712    let mut horizontal: Vec<u32> =
713        merge_runs((0..height).filter(|&y| count_dark_row(gray, y) >= min_h))
714            .into_iter()
715            .map(|(a, b)| (a + b) / 2)
716            .collect();
717    if vertical.len() < MIN_BORDERED_VERTICAL_LINES
718        || horizontal.len() < MIN_BORDERED_HORIZONTAL_LINES
719    {
720        return None;
721    }
722    let (&min_x, &max_x) = vertical.first().zip(vertical.last())?;
723    let (&min_y, &max_y) = horizontal.first().zip(horizontal.last())?;
724    vertical.retain(|&x| dark_ratio_col(gray, x, min_y, max_y) >= MIN_TRUE_GRID_LINE_CONTINUITY);
725    horizontal.retain(|&y| dark_ratio_row(gray, y, min_x, max_x) >= MIN_TRUE_GRID_LINE_CONTINUITY);
726    if vertical.len() < MIN_BORDERED_VERTICAL_LINES
727        || horizontal.len() < MIN_BORDERED_HORIZONTAL_LINES
728    {
729        return None;
730    }
731    Some(Grid {
732        v: vertical,
733        h: horizontal,
734    })
735}
736
737/// Lattice from OCR word centers when ruling lines are faint / incomplete.
738#[cfg(feature = "image")]
739fn detect_ocr_word_grid(
740    words: &[crate::pdf::ocr::OcrWord],
741    width: u32,
742    height: u32,
743) -> Option<Grid> {
744    if words.len() < 8 || width < 80 || height < 60 {
745        return None;
746    }
747    let mut heights: Vec<u32> = words.iter().map(|w| w.height.max(1)).collect();
748    heights.sort_unstable();
749    let med_h = heights[heights.len() / 2].max(6);
750    let y_gap = (med_h as f64 * 0.85).round() as u32;
751
752    let mut xs: Vec<u32> = words
753        .iter()
754        // Full-width line boxes (common from PP-OCR) collapse all columns.
755        .filter(|w| w.width < width.saturating_mul(45) / 100 || w.width < 80)
756        .map(|w| w.left + w.width / 2)
757        .collect();
758    if xs.len() < 6 {
759        xs = words.iter().map(|w| w.left + w.width / 2).collect();
760    }
761    let mut ys: Vec<u32> = words.iter().map(|w| w.top + w.height / 2).collect();
762    xs.sort_unstable();
763    ys.sort_unstable();
764    // Header words are wide; gap from word *width* merges real columns.
765    // Use median successive-center gap instead (within-col vs between-col).
766    let x_gap = adaptive_col_gap(&xs, med_h);
767    let col_centers = cluster_1d_centers(&xs, x_gap);
768    let row_centers = cluster_1d_centers(&ys, y_gap);
769    if col_centers.len() < 2 || row_centers.len() < 3 {
770        return None;
771    }
772    // Reject sparse one-column prose wrapped as a "table".
773    let occupied = count_occupied_cells(words, &col_centers, &row_centers, x_gap, y_gap);
774    let cells = col_centers.len() * row_centers.len();
775    if occupied < 6 || (occupied as f64) < 0.35 * cells as f64 {
776        return None;
777    }
778
779    let v = centers_to_boundaries(&col_centers, 0, width);
780    let h = centers_to_boundaries(&row_centers, 0, height);
781    if v.len() < 3 || h.len() < 4 {
782        return None;
783    }
784    Some(Grid { v, h })
785}
786
787/// Between-column gap: outlier vs typical within-column jitter of word centers.
788#[cfg(feature = "image")]
789fn adaptive_col_gap(xs_sorted: &[u32], med_h: u32) -> u32 {
790    let mut u = xs_sorted.to_vec();
791    u.dedup();
792    if u.len() < 3 {
793        return (med_h as f64 * 2.5).round().max(12.0) as u32;
794    }
795    let mut diffs: Vec<u32> = u
796        .windows(2)
797        .map(|w| w[1] - w[0])
798        .filter(|&d| d > 1)
799        .collect();
800    if diffs.is_empty() {
801        return (med_h as f64 * 2.5).round().max(12.0) as u32;
802    }
803    diffs.sort_unstable();
804    let med = diffs[diffs.len() / 2].max(1);
805    ((med as f64) * 1.35)
806        .round()
807        .max((med_h as f64) * 2.0)
808        .max(16.0) as u32
809}
810
811#[cfg(feature = "image")]
812fn cluster_1d_centers(sorted: &[u32], gap: u32) -> Vec<u32> {
813    if sorted.is_empty() {
814        return Vec::new();
815    }
816    let mut clusters: Vec<Vec<u32>> = vec![vec![sorted[0]]];
817    for &x in &sorted[1..] {
818        let last = clusters.last_mut().unwrap();
819        if x.saturating_sub(*last.last().unwrap()) <= gap {
820            last.push(x);
821        } else {
822            clusters.push(vec![x]);
823        }
824    }
825    clusters
826        .into_iter()
827        .map(|c| c.iter().sum::<u32>() / c.len() as u32)
828        .collect()
829}
830
831#[cfg(feature = "image")]
832fn centers_to_boundaries(centers: &[u32], min_edge: u32, max_edge: u32) -> Vec<u32> {
833    if centers.is_empty() {
834        return vec![min_edge, max_edge];
835    }
836    let mut b = Vec::with_capacity(centers.len() + 1);
837    b.push(min_edge);
838    for i in 0..centers.len() - 1 {
839        b.push((centers[i] + centers[i + 1]) / 2);
840    }
841    b.push(max_edge.max(min_edge + 1));
842    // Ensure strictly increasing.
843    for i in 1..b.len() {
844        if b[i] <= b[i - 1] {
845            b[i] = b[i - 1] + 1;
846        }
847    }
848    b
849}
850
851#[cfg(feature = "image")]
852fn count_occupied_cells(
853    words: &[crate::pdf::ocr::OcrWord],
854    cols: &[u32],
855    rows: &[u32],
856    x_gap: u32,
857    y_gap: u32,
858) -> usize {
859    let mut seen = std::collections::HashSet::new();
860    for w in words {
861        let cx = w.left + w.width / 2;
862        let cy = w.top + w.height / 2;
863        let Some(ci) = cols
864            .iter()
865            .position(|&c| cx.abs_diff(c) <= x_gap.saturating_mul(2))
866        else {
867            continue;
868        };
869        let Some(ri) = rows
870            .iter()
871            .position(|&r| cy.abs_diff(r) <= y_gap.saturating_mul(2))
872        else {
873            continue;
874        };
875        seen.insert((ri, ci));
876    }
877    seen.len()
878}
879
880#[cfg(feature = "image")]
881fn grid_has_cell_ink(gray: &GrayImage, grid: &Grid) -> bool {
882    let cols = grid.v.len().saturating_sub(1);
883    let rows = grid.h.len().saturating_sub(1);
884    if cols == 0 || rows == 0 {
885        return false;
886    }
887    let mut inked = 0usize;
888    let mut rows_with = 0usize;
889    let total = cols * rows;
890    for r in 0..rows {
891        let mut row_ink = false;
892        for c in 0..cols {
893            if cell_dark_ratio(gray, grid.v[c], grid.h[r], grid.v[c + 1], grid.h[r + 1]) >= 0.03 {
894                inked += 1;
895                row_ink = true;
896            }
897        }
898        if row_ink {
899            rows_with += 1;
900        }
901    }
902    (inked as f64 / total as f64) >= MIN_BORDERED_INKED_CELL_RATIO
903        && rows_with >= MIN_BORDERED_ROWS_WITH_INK
904}
905
906#[cfg(feature = "image")]
907fn is_obvious_bar_chart(gray: &GrayImage) -> bool {
908    if detect_bordered_grid(gray).is_some() {
909        return false;
910    }
911    let w = gray.width();
912    let h = gray.height();
913    if w < 120 || h < 80 {
914        return false;
915    }
916    let mut peaks = 0usize;
917    let mut run = 0u32;
918    let thresh = (h as f64 * 0.35) as u32;
919    for x in 0..w {
920        let dark = count_dark_col(gray, x);
921        if dark >= thresh {
922            run += 1;
923        } else if run > 0 {
924            if run >= 3 {
925                peaks += 1;
926            }
927            run = 0;
928        }
929    }
930    peaks >= 4
931}
932
933#[cfg(feature = "image")]
934fn count_dark_col(gray: &GrayImage, x: u32) -> u32 {
935    (0..gray.height())
936        .filter(|&y| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
937        .count() as u32
938}
939
940#[cfg(feature = "image")]
941fn count_dark_row(gray: &GrayImage, y: u32) -> u32 {
942    (0..gray.width())
943        .filter(|&x| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
944        .count() as u32
945}
946
947#[cfg(feature = "image")]
948fn dark_ratio_col(gray: &GrayImage, x: u32, y1: u32, y2: u32) -> f64 {
949    let h = y2.saturating_sub(y1).max(1);
950    let dark = (y1..y2)
951        .filter(|&y| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
952        .count() as f64;
953    dark / f64::from(h)
954}
955
956#[cfg(feature = "image")]
957fn dark_ratio_row(gray: &GrayImage, y: u32, x1: u32, x2: u32) -> f64 {
958    let w = x2.saturating_sub(x1).max(1);
959    let dark = (x1..x2)
960        .filter(|&x| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
961        .count() as f64;
962    dark / f64::from(w)
963}
964
965#[cfg(feature = "image")]
966fn cell_dark_ratio(gray: &GrayImage, x1: u32, y1: u32, x2: u32, y2: u32) -> f64 {
967    let mut dark = 0u32;
968    let mut total = 0u32;
969    for y in y1..y2 {
970        for x in x1..x2 {
971            total += 1;
972            if gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD {
973                dark += 1;
974            }
975        }
976    }
977    if total == 0 {
978        0.0
979    } else {
980        f64::from(dark) / f64::from(total)
981    }
982}
983
984#[cfg(feature = "image")]
985fn merge_runs(xs: impl Iterator<Item = u32>) -> Vec<(u32, u32)> {
986    let mut out = Vec::new();
987    let mut cur: Option<(u32, u32)> = None;
988    for x in xs {
989        cur = match cur {
990            None => Some((x, x)),
991            Some((a, b)) if x <= b + 2 => Some((a, x)),
992            Some(r) => {
993                out.push(r);
994                Some((x, x))
995            }
996        };
997    }
998    if let Some(r) = cur {
999        out.push(r);
1000    }
1001    out
1002}
1003
1004#[cfg(feature = "image")]
1005fn map_boundaries(lines: &[u32], page_a: f64, page_b: f64, raster_span: u32) -> Option<Vec<f64>> {
1006    if raster_span == 0 || lines.is_empty() {
1007        return None;
1008    }
1009    let span = page_b - page_a;
1010    Some(
1011        lines
1012            .iter()
1013            .map(|&p| page_a + span * (f64::from(p) / f64::from(raster_span)))
1014            .collect(),
1015    )
1016}
1017
1018#[cfg(feature = "image")]
1019fn map_boundaries_desc(
1020    lines: &[u32],
1021    page_bottom: f64,
1022    page_top: f64,
1023    raster_span: u32,
1024) -> Option<Vec<f64>> {
1025    if raster_span == 0 || lines.is_empty() {
1026        return None;
1027    }
1028    let span = page_top - page_bottom;
1029    Some(
1030        lines
1031            .iter()
1032            .map(|&p| page_top - span * (f64::from(p) / f64::from(raster_span)))
1033            .collect(),
1034    )
1035}
1036
1037#[cfg(feature = "image")]
1038fn words_in_cell(words: &[crate::pdf::ocr::OcrWord], x1: u32, y1: u32, x2: u32, y2: u32) -> String {
1039    let mut hit: Vec<&crate::pdf::ocr::OcrWord> = words
1040        .iter()
1041        .filter(|w| {
1042            let cx = w.left + w.width / 2;
1043            let cy = w.top + w.height / 2;
1044            cx >= x1 && cx < x2 && cy >= y1 && cy < y2
1045        })
1046        .collect();
1047    hit.sort_by_key(|a| (a.top, a.left));
1048    hit.iter()
1049        .map(|w| w.text.as_str())
1050        .collect::<Vec<_>>()
1051        .join(" ")
1052}
1053
1054/// Bucket PDF text whose center falls inside a cell (WASM path without OCR).
1055#[cfg(feature = "image")]
1056fn text_chunks_in_cell(text_chunks: &[TextChunk], cell: &BoundingBox) -> String {
1057    let mut hit: Vec<&TextChunk> = text_chunks
1058        .iter()
1059        .filter(|t| {
1060            if t.value.trim().is_empty() {
1061                return false;
1062            }
1063            let cx = (t.bbox.left_x + t.bbox.right_x) * 0.5;
1064            let cy = (t.bbox.bottom_y + t.bbox.top_y) * 0.5;
1065            cx >= cell.left_x && cx <= cell.right_x && cy >= cell.bottom_y && cy <= cell.top_y
1066        })
1067        .collect();
1068    hit.sort_by(|a, b| {
1069        b.bbox
1070            .top_y
1071            .partial_cmp(&a.bbox.top_y)
1072            .unwrap_or(std::cmp::Ordering::Equal)
1073            .then_with(|| {
1074                a.bbox
1075                    .left_x
1076                    .partial_cmp(&b.bbox.left_x)
1077                    .unwrap_or(std::cmp::Ordering::Equal)
1078            })
1079    });
1080    hit.iter()
1081        .map(|t| t.value.trim())
1082        .filter(|s| !s.is_empty())
1083        .collect::<Vec<_>>()
1084        .join(" ")
1085}
1086
1087#[cfg(feature = "image")]
1088/// True when a recovered bordered table looks contentful enough to keep.
1089pub fn bordered_table_is_plausible(table: &TableBorder) -> bool {
1090    if table.num_rows < 2 || table.num_columns < 2 {
1091        return false;
1092    }
1093    let mut token_counts = Vec::new();
1094    for row in &table.rows {
1095        for cell in &row.cells {
1096            let n = cell
1097                .content
1098                .iter()
1099                .map(|t| t.base.value.split_whitespace().count())
1100                .sum::<usize>();
1101            if n > 0 {
1102                token_counts.push(n);
1103            }
1104        }
1105    }
1106    if token_counts.is_empty() {
1107        return false;
1108    }
1109    let mut sorted = token_counts.clone();
1110    sorted.sort_unstable();
1111    let median = sorted[sorted.len() / 2].max(1);
1112    let max = *token_counts.iter().max().unwrap_or(&0);
1113    !(max >= 8 && max >= median.saturating_mul(8))
1114}
1115
1116#[cfg(all(test, feature = "image"))]
1117mod classify_tests {
1118    use super::*;
1119    use crate::models::chunks::ImagePaintSource;
1120    use crate::models::enums::{PdfLayer, TextFormat, TextType};
1121
1122    fn img(source: ImagePaintSource, w: f64, h: f64) -> ImageChunk {
1123        ImageChunk {
1124            bbox: BoundingBox::new(Some(1), 0.0, 0.0, w, h),
1125            index: Some(1),
1126            level: None,
1127            source,
1128        }
1129    }
1130
1131    #[test]
1132    fn full_bleed_do_image_is_text_blocks() {
1133        let image = img(ImagePaintSource::Do, 959.76, 539.41);
1134        let kind = classify_raster_candidate(&image, &[], 959.76, 540.0);
1135        assert_eq!(kind, Some(RasterCandidateKind::TextBlocks));
1136        assert!(is_full_page_image(&image, 959.76, 540.0));
1137    }
1138
1139    #[test]
1140    fn pattern_fill_is_text_blocks() {
1141        let image = img(ImagePaintSource::PatternFill, 400.0, 300.0);
1142        let kind = classify_raster_candidate(&image, &[], 400.0, 300.0);
1143        assert_eq!(kind, Some(RasterCandidateKind::TextBlocks));
1144    }
1145
1146    #[test]
1147    fn small_embedded_do_with_sparse_text_is_table() {
1148        let image = img(ImagePaintSource::Do, 200.0, 100.0);
1149        let kind = classify_raster_candidate(&image, &[], 612.0, 792.0);
1150        // Empty page text + substantial image → TextBlocks (image-only page rule)
1151        assert_eq!(kind, Some(RasterCandidateKind::TextBlocks));
1152
1153        // With surrounding native text outside the image → Table path for embedded figure
1154        let outside = TextChunk {
1155            value: "Caption outside".into(),
1156            bbox: BoundingBox::new(Some(1), 0.0, 700.0, 200.0, 720.0),
1157            font_name: "Helvetica".into(),
1158            font_size: 12.0,
1159            font_weight: 400.0,
1160            italic_angle: 0.0,
1161            font_color: "#000".into(),
1162            contrast_ratio: 21.0,
1163            symbol_ends: vec![],
1164            text_format: TextFormat::Normal,
1165            text_type: TextType::Regular,
1166            pdf_layer: PdfLayer::Main,
1167            ocg_visible: true,
1168            index: None,
1169            page_number: Some(1),
1170            level: None,
1171            mcid: None,
1172        };
1173        let kind = classify_raster_candidate(&image, &[outside], 612.0, 792.0);
1174        assert_eq!(kind, Some(RasterCandidateKind::Table));
1175    }
1176}