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")]
302pub fn prepare_candidate(
303    id: u32,
304    data: &[u8],
305    filter: &str,
306    width: u32,
307    height: u32,
308    image: &ImageChunk,
309    text_chunks: &[TextChunk],
310    kind: RasterCandidateKind,
311) -> Option<RasterCandidate> {
312    let gray = decode_to_gray(data, filter, width, height)?;
313    if is_obvious_bar_chart(&gray) {
314        return None;
315    }
316    let bordered = detect_bordered_grid(&gray).filter(|g| grid_has_cell_ink(&gray, g));
317    // Ruled lattice in pixels → Table assembly. Otherwise keep the provisional
318    // kind from paint/page geometry (page-paint → TextBlocks, embedded → Table).
319    let kind = if bordered.is_some() {
320        RasterCandidateKind::Table
321    } else {
322        kind
323    };
324    let bordered = if kind == RasterCandidateKind::Table {
325        bordered
326    } else {
327        None
328    };
329    let hash = fnv1a_hex(gray.as_raw());
330    let overlapping: Vec<TextChunk> = text_chunks
331        .iter()
332        .filter(|t| native_text_overlaps_image(t, image))
333        .cloned()
334        .collect();
335    let (gw, gh) = (gray.width(), gray.height());
336    Some(RasterCandidate {
337        id,
338        page: image.bbox.page_number?,
339        gray: gray.into_raw(),
340        width: gw,
341        height: gh,
342        hash,
343        image_bbox: image.bbox.clone(),
344        image_index: image.index,
345        text_chunks: overlapping,
346        kind,
347        bordered_grid: bordered,
348    })
349}
350
351/// Build a [`TableBorder`] from a prepared candidate + optional OCR words.
352#[cfg(feature = "image")]
353pub fn build_table_from_candidate(
354    candidate: &RasterCandidate,
355    words: &[crate::pdf::ocr::OcrWord],
356) -> Option<TableBorder> {
357    if candidate.kind != RasterCandidateKind::Table {
358        return None;
359    }
360    let gray = GrayImage::from_raw(candidate.width, candidate.height, candidate.gray.clone())?;
361    let mut words = words.to_vec();
362    let mut grid = candidate
363        .bordered_grid
364        .clone()
365        .or_else(|| detect_ocr_word_grid(&words, candidate.width, candidate.height));
366    if grid.is_none() {
367        if let Some(ocrs_words) = recognize_words_ocrs_only(&gray) {
368            if let Some(g) = candidate
369                .bordered_grid
370                .clone()
371                .or_else(|| detect_ocr_word_grid(&ocrs_words, candidate.width, candidate.height))
372            {
373                words = ocrs_words;
374                grid = Some(g);
375            }
376        }
377    }
378    let grid = grid?;
379    build_table_cells(
380        &grid,
381        &gray,
382        &candidate.image_bbox,
383        &candidate.text_chunks,
384        &words,
385    )
386}
387
388/// Convert OCR words from a text-blocks candidate into page-space [`TextChunk`]s.
389///
390/// Native PDF text operators are authoritative for glyphs they paint. OCR words
391/// whose center falls inside a native text bbox are skipped (no IoU thresholds).
392#[cfg(feature = "image")]
393pub fn build_text_chunks_from_candidate(
394    candidate: &RasterCandidate,
395    words: &[crate::pdf::ocr::OcrWord],
396) -> Vec<TextChunk> {
397    ocr_words_to_text_chunks(candidate, words)
398}
399
400/// Map OCR words into page-space text chunks (shared by TextBlocks assembly and
401/// Table→text fallback when a lattice cannot be formed).
402#[cfg(feature = "image")]
403pub fn ocr_words_to_text_chunks(
404    candidate: &RasterCandidate,
405    words: &[crate::pdf::ocr::OcrWord],
406) -> Vec<TextChunk> {
407    if words.is_empty() {
408        return Vec::new();
409    }
410    let img = &candidate.image_bbox;
411    let pw = candidate.width.max(1) as f64;
412    let ph = candidate.height.max(1) as f64;
413    let mut out = Vec::with_capacity(words.len());
414    for (i, w) in words.iter().enumerate() {
415        let text = w.text.trim();
416        if text.is_empty() {
417            continue;
418        }
419        // Map pixel box → PDF user space. Image Y grows down; PDF Y grows up.
420        let left = img.left_x + (w.left as f64 / pw) * img.width();
421        let right = img.left_x + ((w.left + w.width) as f64 / pw) * img.width();
422        let top = img.top_y - (w.top as f64 / ph) * img.height();
423        let bottom = img.top_y - ((w.top + w.height) as f64 / ph) * img.height();
424        let bbox = BoundingBox::new(
425            Some(candidate.page),
426            left.min(right),
427            bottom.min(top),
428            left.max(right),
429            bottom.max(top),
430        );
431        let cx = bbox.center_x();
432        let cy = bbox.center_y();
433        // Skip OCR that lands on native PDF text (center-in-bbox).
434        let covered_by_native = candidate.text_chunks.iter().any(|t| {
435            cx >= t.bbox.left_x
436                && cx <= t.bbox.right_x
437                && cy >= t.bbox.bottom_y
438                && cy <= t.bbox.top_y
439        });
440        if covered_by_native {
441            continue;
442        }
443
444        let font_size = bbox.height().clamp(6.0, 72.0);
445        out.push(TextChunk {
446            value: text.to_string(),
447            bbox: bbox.clone(),
448            font_name: "OCR".to_string(),
449            font_size,
450            font_weight: 400.0,
451            italic_angle: 0.0,
452            font_color: "#000000".to_string(),
453            contrast_ratio: 21.0,
454            symbol_ends: vec![bbox.right_x],
455            text_format: TextFormat::Normal,
456            text_type: TextType::Regular,
457            pdf_layer: PdfLayer::Content,
458            ocg_visible: true,
459            index: Some(i),
460            page_number: Some(candidate.page),
461            level: None,
462            mcid: None,
463        });
464    }
465    out
466}
467
468#[cfg(feature = "image")]
469fn table_from_extracted(
470    data: &[u8],
471    filter: &str,
472    width: u32,
473    height: u32,
474    image: &ImageChunk,
475    text_chunks: &[TextChunk],
476) -> Option<TableBorder> {
477    let candidate = prepare_candidate(
478        0,
479        data,
480        filter,
481        width,
482        height,
483        image,
484        text_chunks,
485        RasterCandidateKind::Table,
486    )?;
487    let gray = GrayImage::from_raw(candidate.width, candidate.height, candidate.gray.clone())?;
488    let mut words = recognize_words(&gray);
489    let mut grid = candidate
490        .bordered_grid
491        .clone()
492        .or_else(|| detect_ocr_word_grid(&words, gray.width(), gray.height()));
493    // Host engines (PP-OCR) may return few line-level boxes that cannot seed
494    // a column lattice; retry with in-process ocrs when available.
495    if grid.is_none() {
496        if let Some(ocrs_words) = recognize_words_ocrs_only(&gray) {
497            if let Some(g) = candidate
498                .bordered_grid
499                .clone()
500                .or_else(|| detect_ocr_word_grid(&ocrs_words, gray.width(), gray.height()))
501            {
502                words = ocrs_words;
503                grid = Some(g);
504            }
505        }
506    }
507    let grid = grid?;
508    build_table_cells(
509        &grid,
510        &gray,
511        &candidate.image_bbox,
512        &candidate.text_chunks,
513        &words,
514    )
515}
516
517#[cfg(feature = "image")]
518fn build_table_cells(
519    grid: &Grid,
520    gray: &GrayImage,
521    image_bbox: &BoundingBox,
522    text_chunks: &[TextChunk],
523    words: &[crate::pdf::ocr::OcrWord],
524) -> Option<TableBorder> {
525    let num_cols = grid.v.len().checked_sub(1)?;
526    let num_rows = grid.h.len().checked_sub(1)?;
527    if num_cols < 2 || num_rows < 2 {
528        return None;
529    }
530
531    let use_ocr = !words.is_empty();
532
533    let x_coords = map_boundaries(&grid.v, image_bbox.left_x, image_bbox.right_x, gray.width())?;
534    let y_coords = map_boundaries_desc(
535        &grid.h,
536        image_bbox.bottom_y,
537        image_bbox.top_y,
538        gray.height(),
539    )?;
540
541    let mut rows = Vec::with_capacity(num_rows);
542    let mut non_empty = 0usize;
543    for row_idx in 0..num_rows {
544        let mut cells = Vec::with_capacity(num_cols);
545        for col_idx in 0..num_cols {
546            let x1 = grid.v[col_idx];
547            let x2 = grid.v[col_idx + 1];
548            let y1 = grid.h[row_idx];
549            let y2 = grid.h[row_idx + 1];
550            let cell_bbox = BoundingBox::new(
551                image_bbox.page_number,
552                x_coords[col_idx],
553                y_coords[row_idx + 1],
554                x_coords[col_idx + 1],
555                y_coords[row_idx],
556            );
557            let (text, font_name) = if use_ocr {
558                (words_in_cell(words, x1, y1, x2, y2), "OCR")
559            } else {
560                (text_chunks_in_cell(text_chunks, &cell_bbox), "PDF")
561            };
562            let mut content = Vec::new();
563            if !text.is_empty() {
564                non_empty += 1;
565                content.push(TableToken {
566                    base: TextChunk {
567                        value: text,
568                        bbox: cell_bbox.clone(),
569                        font_name: font_name.into(),
570                        font_size: (cell_bbox.height() * 0.55).max(6.0),
571                        font_weight: if row_idx == 0 { 700.0 } else { 400.0 },
572                        italic_angle: 0.0,
573                        font_color: "#000000".into(),
574                        contrast_ratio: 21.0,
575                        symbol_ends: Vec::new(),
576                        text_format: TextFormat::Normal,
577                        text_type: TextType::Regular,
578                        pdf_layer: PdfLayer::Content,
579                        ocg_visible: true,
580                        index: None,
581                        page_number: image_bbox.page_number,
582                        level: None,
583                        mcid: None,
584                    },
585                    token_type: TableTokenType::Text,
586                });
587            }
588            cells.push(TableBorderCell {
589                bbox: cell_bbox,
590                index: None,
591                level: None,
592                row_number: row_idx,
593                col_number: col_idx,
594                row_span: 1,
595                col_span: 1,
596                content,
597                contents: Vec::new(),
598                semantic_type: None,
599            });
600        }
601        let row_bbox = BoundingBox::new(
602            image_bbox.page_number,
603            image_bbox.left_x,
604            y_coords[row_idx + 1],
605            image_bbox.right_x,
606            y_coords[row_idx],
607        );
608        rows.push(TableBorderRow {
609            bbox: row_bbox,
610            index: None,
611            level: None,
612            row_number: row_idx,
613            cells,
614            semantic_type: None,
615        });
616    }
617    if non_empty == 0 {
618        return None;
619    }
620
621    let table_bbox = BoundingBox::new(
622        image_bbox.page_number,
623        *x_coords.first()?,
624        *y_coords.last()?,
625        *x_coords.last()?,
626        *y_coords.first()?,
627    );
628    Some(TableBorder {
629        bbox: table_bbox,
630        index: None,
631        level: None,
632        x_coordinates: x_coords.clone(),
633        x_widths: vec![0.0; x_coords.len()],
634        y_coordinates: y_coords.clone(),
635        y_widths: vec![0.0; y_coords.len()],
636        rows,
637        num_rows,
638        num_columns: num_cols,
639        is_bad_table: false,
640        is_table_transformer: true,
641        previous_table: None,
642        next_table: None,
643    })
644}
645
646#[cfg(feature = "image")]
647fn fnv1a_hex(bytes: &[u8]) -> String {
648    let mut hash: u64 = 0xcbf29ce484222325;
649    for &b in bytes {
650        hash ^= u64::from(b);
651        hash = hash.wrapping_mul(0x100000001b3);
652    }
653    format!("{hash:016x}")
654}
655
656#[cfg(feature = "image")]
657fn recognize_words(gray: &GrayImage) -> Vec<crate::pdf::ocr::OcrWord> {
658    #[cfg(any(not(target_arch = "wasm32"), feature = "ocr-ocrs"))]
659    {
660        use crate::pdf::ocr::{default_engine, OcrEngine};
661        return OcrEngine::recognize(&*default_engine(), gray);
662    }
663    #[cfg(all(target_arch = "wasm32", not(feature = "ocr-ocrs")))]
664    {
665        let _ = gray;
666        Vec::new()
667    }
668}
669
670/// Force in-process ocrs (skip host), used when host boxes cannot form a lattice.
671#[cfg(feature = "image")]
672fn recognize_words_ocrs_only(gray: &GrayImage) -> Option<Vec<crate::pdf::ocr::OcrWord>> {
673    #[cfg(feature = "ocr-ocrs")]
674    {
675        use crate::pdf::ocr::{OcrEngine, OcrsEngine};
676        return OcrsEngine::try_from_env().map(|e| e.recognize(gray));
677    }
678    #[cfg(not(feature = "ocr-ocrs"))]
679    {
680        let _ = gray;
681        None
682    }
683}
684
685#[cfg(feature = "image")]
686fn decode_to_gray(data: &[u8], filter: &str, width: u32, height: u32) -> Option<GrayImage> {
687    if filter == "DCTDecode" || data.starts_with(&[0xFF, 0xD8]) {
688        return image::load_from_memory(data).ok().map(|img| img.to_luma8());
689    }
690    if width == 0 || height == 0 {
691        return None;
692    }
693    let n = (width as usize).checked_mul(height as usize)?;
694    if data.len() == n {
695        return GrayImage::from_raw(width, height, data.to_vec());
696    }
697    if data.len() == n * 3 {
698        let mut luma = Vec::with_capacity(n);
699        for px in data.chunks_exact(3) {
700            let y = (0.299 * f64::from(px[0]) + 0.587 * f64::from(px[1]) + 0.114 * f64::from(px[2]))
701                .round()
702                .clamp(0.0, 255.0) as u8;
703            luma.push(y);
704        }
705        return GrayImage::from_raw(width, height, luma);
706    }
707    if data.len() == n * 4 {
708        let mut luma = Vec::with_capacity(n);
709        for px in data.chunks_exact(4) {
710            let y = (0.299 * f64::from(px[0]) + 0.587 * f64::from(px[1]) + 0.114 * f64::from(px[2]))
711                .round()
712                .clamp(0.0, 255.0) as u8;
713            luma.push(y);
714        }
715        return GrayImage::from_raw(width, height, luma);
716    }
717    image::load_from_memory(data).ok().map(|img| img.to_luma8())
718}
719
720#[cfg(feature = "image")]
721#[derive(Debug, Clone)]
722struct Grid {
723    v: Vec<u32>,
724    h: Vec<u32>,
725}
726
727#[cfg(feature = "image")]
728fn detect_bordered_grid(gray: &GrayImage) -> Option<Grid> {
729    let width = gray.width();
730    let height = gray.height();
731    if width < 100 || height < 80 {
732        return None;
733    }
734    let min_v = (f64::from(height) * MIN_LINE_DARK_RATIO).ceil() as u32;
735    let min_h = (f64::from(width) * MIN_LINE_DARK_RATIO).ceil() as u32;
736    let mut vertical: Vec<u32> =
737        merge_runs((0..width).filter(|&x| count_dark_col(gray, x) >= min_v))
738            .into_iter()
739            .map(|(a, b)| (a + b) / 2)
740            .collect();
741    let mut horizontal: Vec<u32> =
742        merge_runs((0..height).filter(|&y| count_dark_row(gray, y) >= min_h))
743            .into_iter()
744            .map(|(a, b)| (a + b) / 2)
745            .collect();
746    if vertical.len() < MIN_BORDERED_VERTICAL_LINES
747        || horizontal.len() < MIN_BORDERED_HORIZONTAL_LINES
748    {
749        return None;
750    }
751    let (&min_x, &max_x) = vertical.first().zip(vertical.last())?;
752    let (&min_y, &max_y) = horizontal.first().zip(horizontal.last())?;
753    vertical.retain(|&x| dark_ratio_col(gray, x, min_y, max_y) >= MIN_TRUE_GRID_LINE_CONTINUITY);
754    horizontal.retain(|&y| dark_ratio_row(gray, y, min_x, max_x) >= MIN_TRUE_GRID_LINE_CONTINUITY);
755    if vertical.len() < MIN_BORDERED_VERTICAL_LINES
756        || horizontal.len() < MIN_BORDERED_HORIZONTAL_LINES
757    {
758        return None;
759    }
760    Some(Grid {
761        v: vertical,
762        h: horizontal,
763    })
764}
765
766/// Lattice from OCR word centers when ruling lines are faint / incomplete.
767#[cfg(feature = "image")]
768fn detect_ocr_word_grid(
769    words: &[crate::pdf::ocr::OcrWord],
770    width: u32,
771    height: u32,
772) -> Option<Grid> {
773    if words.len() < 8 || width < 80 || height < 60 {
774        return None;
775    }
776    let mut heights: Vec<u32> = words.iter().map(|w| w.height.max(1)).collect();
777    heights.sort_unstable();
778    let med_h = heights[heights.len() / 2].max(6);
779    let y_gap = (med_h as f64 * 0.85).round() as u32;
780
781    let mut xs: Vec<u32> = words
782        .iter()
783        // Full-width line boxes (common from PP-OCR) collapse all columns.
784        .filter(|w| w.width < width.saturating_mul(45) / 100 || w.width < 80)
785        .map(|w| w.left + w.width / 2)
786        .collect();
787    if xs.len() < 6 {
788        xs = words.iter().map(|w| w.left + w.width / 2).collect();
789    }
790    let mut ys: Vec<u32> = words.iter().map(|w| w.top + w.height / 2).collect();
791    xs.sort_unstable();
792    ys.sort_unstable();
793    // Header words are wide; gap from word *width* merges real columns.
794    // Use median successive-center gap instead (within-col vs between-col).
795    let x_gap = adaptive_col_gap(&xs, med_h);
796    let col_centers = cluster_1d_centers(&xs, x_gap);
797    let row_centers = cluster_1d_centers(&ys, y_gap);
798    if col_centers.len() < 2 || row_centers.len() < 3 {
799        return None;
800    }
801    // Reject sparse one-column prose wrapped as a "table".
802    let occupied = count_occupied_cells(words, &col_centers, &row_centers, x_gap, y_gap);
803    let cells = col_centers.len() * row_centers.len();
804    if occupied < 6 || (occupied as f64) < 0.35 * cells as f64 {
805        return None;
806    }
807
808    let v = centers_to_boundaries(&col_centers, 0, width);
809    let h = centers_to_boundaries(&row_centers, 0, height);
810    if v.len() < 3 || h.len() < 4 {
811        return None;
812    }
813    Some(Grid { v, h })
814}
815
816/// Between-column gap: outlier vs typical within-column jitter of word centers.
817#[cfg(feature = "image")]
818fn adaptive_col_gap(xs_sorted: &[u32], med_h: u32) -> u32 {
819    let mut u = xs_sorted.to_vec();
820    u.dedup();
821    if u.len() < 3 {
822        return (med_h as f64 * 2.5).round().max(12.0) as u32;
823    }
824    let mut diffs: Vec<u32> = u
825        .windows(2)
826        .map(|w| w[1] - w[0])
827        .filter(|&d| d > 1)
828        .collect();
829    if diffs.is_empty() {
830        return (med_h as f64 * 2.5).round().max(12.0) as u32;
831    }
832    diffs.sort_unstable();
833    let med = diffs[diffs.len() / 2].max(1);
834    ((med as f64) * 1.35)
835        .round()
836        .max((med_h as f64) * 2.0)
837        .max(16.0) as u32
838}
839
840#[cfg(feature = "image")]
841fn cluster_1d_centers(sorted: &[u32], gap: u32) -> Vec<u32> {
842    if sorted.is_empty() {
843        return Vec::new();
844    }
845    let mut clusters: Vec<Vec<u32>> = vec![vec![sorted[0]]];
846    for &x in &sorted[1..] {
847        let last = clusters.last_mut().unwrap();
848        if x.saturating_sub(*last.last().unwrap()) <= gap {
849            last.push(x);
850        } else {
851            clusters.push(vec![x]);
852        }
853    }
854    clusters
855        .into_iter()
856        .map(|c| c.iter().sum::<u32>() / c.len() as u32)
857        .collect()
858}
859
860#[cfg(feature = "image")]
861fn centers_to_boundaries(centers: &[u32], min_edge: u32, max_edge: u32) -> Vec<u32> {
862    if centers.is_empty() {
863        return vec![min_edge, max_edge];
864    }
865    let mut b = Vec::with_capacity(centers.len() + 1);
866    b.push(min_edge);
867    for i in 0..centers.len() - 1 {
868        b.push((centers[i] + centers[i + 1]) / 2);
869    }
870    b.push(max_edge.max(min_edge + 1));
871    // Ensure strictly increasing.
872    for i in 1..b.len() {
873        if b[i] <= b[i - 1] {
874            b[i] = b[i - 1] + 1;
875        }
876    }
877    b
878}
879
880#[cfg(feature = "image")]
881fn count_occupied_cells(
882    words: &[crate::pdf::ocr::OcrWord],
883    cols: &[u32],
884    rows: &[u32],
885    x_gap: u32,
886    y_gap: u32,
887) -> usize {
888    let mut seen = std::collections::HashSet::new();
889    for w in words {
890        let cx = w.left + w.width / 2;
891        let cy = w.top + w.height / 2;
892        let Some(ci) = cols
893            .iter()
894            .position(|&c| cx.abs_diff(c) <= x_gap.saturating_mul(2))
895        else {
896            continue;
897        };
898        let Some(ri) = rows
899            .iter()
900            .position(|&r| cy.abs_diff(r) <= y_gap.saturating_mul(2))
901        else {
902            continue;
903        };
904        seen.insert((ri, ci));
905    }
906    seen.len()
907}
908
909#[cfg(feature = "image")]
910fn grid_has_cell_ink(gray: &GrayImage, grid: &Grid) -> bool {
911    let cols = grid.v.len().saturating_sub(1);
912    let rows = grid.h.len().saturating_sub(1);
913    if cols == 0 || rows == 0 {
914        return false;
915    }
916    let mut inked = 0usize;
917    let mut rows_with = 0usize;
918    let total = cols * rows;
919    for r in 0..rows {
920        let mut row_ink = false;
921        for c in 0..cols {
922            if cell_dark_ratio(gray, grid.v[c], grid.h[r], grid.v[c + 1], grid.h[r + 1]) >= 0.03 {
923                inked += 1;
924                row_ink = true;
925            }
926        }
927        if row_ink {
928            rows_with += 1;
929        }
930    }
931    (inked as f64 / total as f64) >= MIN_BORDERED_INKED_CELL_RATIO
932        && rows_with >= MIN_BORDERED_ROWS_WITH_INK
933}
934
935#[cfg(feature = "image")]
936fn is_obvious_bar_chart(gray: &GrayImage) -> bool {
937    if detect_bordered_grid(gray).is_some() {
938        return false;
939    }
940    let w = gray.width();
941    let h = gray.height();
942    if w < 120 || h < 80 {
943        return false;
944    }
945    let mut peaks = 0usize;
946    let mut run = 0u32;
947    let thresh = (h as f64 * 0.35) as u32;
948    for x in 0..w {
949        let dark = count_dark_col(gray, x);
950        if dark >= thresh {
951            run += 1;
952        } else if run > 0 {
953            if run >= 3 {
954                peaks += 1;
955            }
956            run = 0;
957        }
958    }
959    peaks >= 4
960}
961
962#[cfg(feature = "image")]
963fn count_dark_col(gray: &GrayImage, x: u32) -> u32 {
964    (0..gray.height())
965        .filter(|&y| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
966        .count() as u32
967}
968
969#[cfg(feature = "image")]
970fn count_dark_row(gray: &GrayImage, y: u32) -> u32 {
971    (0..gray.width())
972        .filter(|&x| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
973        .count() as u32
974}
975
976#[cfg(feature = "image")]
977fn dark_ratio_col(gray: &GrayImage, x: u32, y1: u32, y2: u32) -> f64 {
978    let h = y2.saturating_sub(y1).max(1);
979    let dark = (y1..y2)
980        .filter(|&y| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
981        .count() as f64;
982    dark / f64::from(h)
983}
984
985#[cfg(feature = "image")]
986fn dark_ratio_row(gray: &GrayImage, y: u32, x1: u32, x2: u32) -> f64 {
987    let w = x2.saturating_sub(x1).max(1);
988    let dark = (x1..x2)
989        .filter(|&x| gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD)
990        .count() as f64;
991    dark / f64::from(w)
992}
993
994#[cfg(feature = "image")]
995fn cell_dark_ratio(gray: &GrayImage, x1: u32, y1: u32, x2: u32, y2: u32) -> f64 {
996    let mut dark = 0u32;
997    let mut total = 0u32;
998    for y in y1..y2 {
999        for x in x1..x2 {
1000            total += 1;
1001            if gray.get_pixel(x, y).0[0] < RASTER_DARK_THRESHOLD {
1002                dark += 1;
1003            }
1004        }
1005    }
1006    if total == 0 {
1007        0.0
1008    } else {
1009        f64::from(dark) / f64::from(total)
1010    }
1011}
1012
1013#[cfg(feature = "image")]
1014fn merge_runs(xs: impl Iterator<Item = u32>) -> Vec<(u32, u32)> {
1015    let mut out = Vec::new();
1016    let mut cur: Option<(u32, u32)> = None;
1017    for x in xs {
1018        cur = match cur {
1019            None => Some((x, x)),
1020            Some((a, b)) if x <= b + 2 => Some((a, x)),
1021            Some(r) => {
1022                out.push(r);
1023                Some((x, x))
1024            }
1025        };
1026    }
1027    if let Some(r) = cur {
1028        out.push(r);
1029    }
1030    out
1031}
1032
1033#[cfg(feature = "image")]
1034fn map_boundaries(lines: &[u32], page_a: f64, page_b: f64, raster_span: u32) -> Option<Vec<f64>> {
1035    if raster_span == 0 || lines.is_empty() {
1036        return None;
1037    }
1038    let span = page_b - page_a;
1039    Some(
1040        lines
1041            .iter()
1042            .map(|&p| page_a + span * (f64::from(p) / f64::from(raster_span)))
1043            .collect(),
1044    )
1045}
1046
1047#[cfg(feature = "image")]
1048fn map_boundaries_desc(
1049    lines: &[u32],
1050    page_bottom: f64,
1051    page_top: f64,
1052    raster_span: u32,
1053) -> Option<Vec<f64>> {
1054    if raster_span == 0 || lines.is_empty() {
1055        return None;
1056    }
1057    let span = page_top - page_bottom;
1058    Some(
1059        lines
1060            .iter()
1061            .map(|&p| page_top - span * (f64::from(p) / f64::from(raster_span)))
1062            .collect(),
1063    )
1064}
1065
1066#[cfg(feature = "image")]
1067fn words_in_cell(words: &[crate::pdf::ocr::OcrWord], x1: u32, y1: u32, x2: u32, y2: u32) -> String {
1068    let mut hit: Vec<&crate::pdf::ocr::OcrWord> = words
1069        .iter()
1070        .filter(|w| {
1071            let cx = w.left + w.width / 2;
1072            let cy = w.top + w.height / 2;
1073            cx >= x1 && cx < x2 && cy >= y1 && cy < y2
1074        })
1075        .collect();
1076    hit.sort_by(|a, b| (a.top, a.left).cmp(&(b.top, b.left)));
1077    hit.iter()
1078        .map(|w| w.text.as_str())
1079        .collect::<Vec<_>>()
1080        .join(" ")
1081}
1082
1083/// Bucket PDF text whose center falls inside a cell (WASM path without OCR).
1084#[cfg(feature = "image")]
1085fn text_chunks_in_cell(text_chunks: &[TextChunk], cell: &BoundingBox) -> String {
1086    let mut hit: Vec<&TextChunk> = text_chunks
1087        .iter()
1088        .filter(|t| {
1089            if t.value.trim().is_empty() {
1090                return false;
1091            }
1092            let cx = (t.bbox.left_x + t.bbox.right_x) * 0.5;
1093            let cy = (t.bbox.bottom_y + t.bbox.top_y) * 0.5;
1094            cx >= cell.left_x && cx <= cell.right_x && cy >= cell.bottom_y && cy <= cell.top_y
1095        })
1096        .collect();
1097    hit.sort_by(|a, b| {
1098        b.bbox
1099            .top_y
1100            .partial_cmp(&a.bbox.top_y)
1101            .unwrap_or(std::cmp::Ordering::Equal)
1102            .then_with(|| {
1103                a.bbox
1104                    .left_x
1105                    .partial_cmp(&b.bbox.left_x)
1106                    .unwrap_or(std::cmp::Ordering::Equal)
1107            })
1108    });
1109    hit.iter()
1110        .map(|t| t.value.trim())
1111        .filter(|s| !s.is_empty())
1112        .collect::<Vec<_>>()
1113        .join(" ")
1114}
1115
1116#[cfg(feature = "image")]
1117/// True when a recovered bordered table looks contentful enough to keep.
1118pub fn bordered_table_is_plausible(table: &TableBorder) -> bool {
1119    if table.num_rows < 2 || table.num_columns < 2 {
1120        return false;
1121    }
1122    let mut token_counts = Vec::new();
1123    for row in &table.rows {
1124        for cell in &row.cells {
1125            let n = cell
1126                .content
1127                .iter()
1128                .map(|t| t.base.value.split_whitespace().count())
1129                .sum::<usize>();
1130            if n > 0 {
1131                token_counts.push(n);
1132            }
1133        }
1134    }
1135    if token_counts.is_empty() {
1136        return false;
1137    }
1138    let mut sorted = token_counts.clone();
1139    sorted.sort_unstable();
1140    let median = sorted[sorted.len() / 2].max(1);
1141    let max = *token_counts.iter().max().unwrap_or(&0);
1142    !(max >= 8 && max >= median.saturating_mul(8))
1143}
1144
1145#[cfg(all(test, feature = "image"))]
1146mod classify_tests {
1147    use super::*;
1148    use crate::models::chunks::ImagePaintSource;
1149    use crate::models::enums::{PdfLayer, TextFormat, TextType};
1150
1151    fn img(source: ImagePaintSource, w: f64, h: f64) -> ImageChunk {
1152        ImageChunk {
1153            bbox: BoundingBox::new(Some(1), 0.0, 0.0, w, h),
1154            index: Some(1),
1155            level: None,
1156            source,
1157        }
1158    }
1159
1160    #[test]
1161    fn full_bleed_do_image_is_text_blocks() {
1162        let image = img(ImagePaintSource::Do, 959.76, 539.41);
1163        let kind = classify_raster_candidate(&image, &[], 959.76, 540.0);
1164        assert_eq!(kind, Some(RasterCandidateKind::TextBlocks));
1165        assert!(is_full_page_image(&image, 959.76, 540.0));
1166    }
1167
1168    #[test]
1169    fn pattern_fill_is_text_blocks() {
1170        let image = img(ImagePaintSource::PatternFill, 400.0, 300.0);
1171        let kind = classify_raster_candidate(&image, &[], 400.0, 300.0);
1172        assert_eq!(kind, Some(RasterCandidateKind::TextBlocks));
1173    }
1174
1175    #[test]
1176    fn small_embedded_do_with_sparse_text_is_table() {
1177        let image = img(ImagePaintSource::Do, 200.0, 100.0);
1178        let kind = classify_raster_candidate(&image, &[], 612.0, 792.0);
1179        // Empty page text + substantial image → TextBlocks (image-only page rule)
1180        assert_eq!(kind, Some(RasterCandidateKind::TextBlocks));
1181
1182        // With surrounding native text outside the image → Table path for embedded figure
1183        let outside = TextChunk {
1184            value: "Caption outside".into(),
1185            bbox: BoundingBox::new(Some(1), 0.0, 700.0, 200.0, 720.0),
1186            font_name: "Helvetica".into(),
1187            font_size: 12.0,
1188            font_weight: 400.0,
1189            italic_angle: 0.0,
1190            font_color: "#000".into(),
1191            contrast_ratio: 21.0,
1192            symbol_ends: vec![],
1193            text_format: TextFormat::Normal,
1194            text_type: TextType::Regular,
1195            pdf_layer: PdfLayer::Main,
1196            ocg_visible: true,
1197            index: None,
1198            page_number: Some(1),
1199            level: None,
1200            mcid: None,
1201        };
1202        let kind = classify_raster_candidate(&image, &[outside], 612.0, 792.0);
1203        assert_eq!(kind, Some(RasterCandidateKind::Table));
1204    }
1205}