Skip to main content

oxidize_pdf/text/
table_detection.rs

1//! Advanced table detection using vector graphics and text analysis.
2//!
3//! This module implements border-based table detection by combining:
4//! - Vector line extraction from PDF graphics (horizontal/vertical borders)
5//! - Text fragment positions from content streams
6//! - Grid pattern recognition and cell boundary calculation
7//!
8//! # Algorithm Overview
9//!
10//! 1. **Line Extraction**: Use `GraphicsExtractor` to get H/V lines from PDF
11//! 2. **Grid Detection**: Find intersections and regular patterns
12//! 3. **Cell Boundary Calculation**: Determine cell rectangles from line intersections
13//! 4. **Text Assignment**: Map text fragments to cells using spatial containment
14//! 5. **Table Construction**: Build `DetectedTable` with rows, columns, and cells
15//!
16//! # Example
17//!
18//! ```rust,no_run
19//! use oxidize_pdf::text::table_detection::{TableDetector, TableDetectionConfig};
20//! use oxidize_pdf::graphics::extraction::GraphicsExtractor;
21//! use oxidize_pdf::text::extraction::TextExtractor;
22//! use oxidize_pdf::parser::{PdfReader, PdfDocument};
23//! use std::fs::File;
24//!
25//! let file = File::open("document.pdf")?;
26//! let reader = PdfReader::new(file)?;
27//! let doc = PdfDocument::new(reader);
28//!
29//! // Extract graphics (lines) and text
30//! let mut graphics_ext = GraphicsExtractor::default();
31//! let graphics = graphics_ext.extract_from_page(&doc, 0)?;
32//!
33//! let mut text_ext = TextExtractor::default();
34//! let text = text_ext.extract_from_page(&doc, 0)?;
35//!
36//! // Detect tables
37//! let detector = TableDetector::default();
38//! let tables = detector.detect(&graphics, &text.fragments)?;
39//!
40//! for table in &tables {
41//!     println!("Table: {}x{} cells", table.row_count(), table.column_count());
42//! }
43//! # Ok::<(), Box<dyn std::error::Error>>(())
44//! ```
45//!
46//! Merged cells are detected from absent grid dividers; borderless tables and un-tagged
47//! multi-level headers stay flat. See `docs/TABLE_DETECTION_GUIDE.md` for the full limits.
48
49use crate::graphics::extraction::{ExtractedGraphics, LineOrientation, VectorLine};
50use crate::text::extraction::TextFragment;
51use std::collections::BTreeMap;
52use thiserror::Error;
53
54/// Errors that can occur during table detection.
55#[derive(Debug, Error)]
56pub enum TableDetectionError {
57    /// Invalid coordinate value (NaN or Infinity)
58    #[error("Invalid coordinate value: expected valid f64, found NaN or Infinity")]
59    InvalidCoordinate,
60
61    /// Grid has no rows or columns
62    #[error("Invalid grid: {0}")]
63    InvalidGrid(String),
64
65    /// Internal logic error
66    #[error("Internal error: {0}")]
67    InternalError(String),
68}
69
70/// Configuration for table detection.
71#[derive(Debug, Clone)]
72pub struct TableDetectionConfig {
73    /// Minimum number of rows to consider a valid table
74    pub min_rows: usize,
75    /// Minimum number of columns to consider a valid table
76    pub min_columns: usize,
77    /// Tolerance for line alignment (in points)
78    pub alignment_tolerance: f64,
79    /// Minimum table area (in square points)
80    pub min_table_area: f64,
81    /// Whether to detect borderless tables (alignment-based)
82    pub detect_borderless: bool,
83}
84
85impl Default for TableDetectionConfig {
86    fn default() -> Self {
87        Self {
88            min_rows: 2,
89            min_columns: 2,
90            alignment_tolerance: 2.0, // 2 points tolerance for line alignment
91            min_table_area: 1000.0,   // Minimum 1000 sq points (~35x35 pt square)
92            detect_borderless: false, // Start with bordered tables only
93        }
94    }
95}
96
97/// A detected table with cells, rows, and columns.
98#[derive(Debug, Clone)]
99#[non_exhaustive]
100pub struct DetectedTable {
101    /// Bounding box of the entire table
102    pub bbox: BoundingBox,
103    /// All cells in the table (row-major order)
104    pub cells: Vec<TableCell>,
105    /// Number of rows
106    pub rows: usize,
107    /// Number of columns
108    pub columns: usize,
109    /// Confidence score (0.0 to 1.0)
110    pub confidence: f64,
111    /// Leading header rows (0 until header detection runs; Task 8).
112    pub header_rows: usize,
113}
114
115impl DetectedTable {
116    /// Creates a new detected table.
117    pub fn new(bbox: BoundingBox, cells: Vec<TableCell>, rows: usize, columns: usize) -> Self {
118        let confidence = Self::calculate_confidence(&cells, rows, columns);
119        Self {
120            bbox,
121            cells,
122            rows,
123            columns,
124            confidence,
125            header_rows: 0,
126        }
127    }
128
129    /// Returns the number of rows.
130    pub fn row_count(&self) -> usize {
131        self.rows
132    }
133
134    /// Returns the number of columns.
135    pub fn column_count(&self) -> usize {
136        self.columns
137    }
138
139    /// Gets a cell by row and column index (0-based).
140    pub fn get_cell(&self, row: usize, col: usize) -> Option<&TableCell> {
141        if row >= self.rows || col >= self.columns {
142            return None;
143        }
144        let index = row * self.columns + col;
145        self.cells.get(index)
146    }
147
148    /// Calculates confidence score based on cell population.
149    fn calculate_confidence(cells: &[TableCell], rows: usize, columns: usize) -> f64 {
150        if rows == 0 || columns == 0 {
151            return 0.0;
152        }
153
154        let total_cells = rows * columns;
155        let populated_cells = cells.iter().filter(|c| !c.text.is_empty()).count();
156
157        // Base confidence from population ratio
158        let population_ratio = populated_cells as f64 / total_cells as f64;
159
160        // Bonus for larger tables (more likely to be intentional)
161        let size_bonus = ((rows + columns) as f64 / 10.0).min(0.2);
162
163        (population_ratio + size_bonus).min(1.0)
164    }
165}
166
167/// A single cell in a detected table.
168#[derive(Debug, Clone)]
169#[non_exhaustive]
170pub struct TableCell {
171    /// Row index (0-based)
172    pub row: usize,
173    /// Column index (0-based)
174    pub column: usize,
175    /// Cell bounding box
176    pub bbox: BoundingBox,
177    /// Text content in the cell
178    pub text: String,
179    /// Whether this cell has borders
180    pub has_borders: bool,
181    /// Number of base rows this cell spans (>= 1).
182    pub row_span: usize,
183    /// Number of base columns this cell spans (>= 1).
184    pub col_span: usize,
185}
186
187impl TableCell {
188    /// Creates a new table cell.
189    pub fn new(row: usize, column: usize, bbox: BoundingBox) -> Self {
190        Self {
191            row,
192            column,
193            bbox,
194            text: String::new(),
195            has_borders: false,
196            row_span: 1,
197            col_span: 1,
198        }
199    }
200
201    /// Sets the text content.
202    pub fn set_text(&mut self, text: String) {
203        self.text = text;
204    }
205
206    /// Checks if the cell is empty.
207    pub fn is_empty(&self) -> bool {
208        self.text.is_empty()
209    }
210}
211
212/// Bounding box for tables and cells.
213#[derive(Debug, Clone, Copy)]
214pub struct BoundingBox {
215    /// Left X coordinate
216    pub x: f64,
217    /// Bottom Y coordinate (PDF coordinate system)
218    pub y: f64,
219    /// Width
220    pub width: f64,
221    /// Height
222    pub height: f64,
223}
224
225impl BoundingBox {
226    /// Creates a new bounding box.
227    pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
228        Self {
229            x,
230            y,
231            width,
232            height,
233        }
234    }
235
236    /// Returns the right edge X coordinate.
237    pub fn right(&self) -> f64 {
238        self.x + self.width
239    }
240
241    /// Returns the top edge Y coordinate.
242    pub fn top(&self) -> f64 {
243        self.y + self.height
244    }
245
246    /// Checks if a point is inside this bounding box.
247    pub fn contains_point(&self, px: f64, py: f64) -> bool {
248        px >= self.x && px <= self.right() && py >= self.y && py <= self.top()
249    }
250
251    /// Returns the area of the bounding box.
252    pub fn area(&self) -> f64 {
253        self.width * self.height
254    }
255}
256
257/// Main table detector.
258pub struct TableDetector {
259    config: TableDetectionConfig,
260}
261
262impl TableDetector {
263    /// Creates a new table detector with the given configuration.
264    pub fn new(config: TableDetectionConfig) -> Self {
265        Self { config }
266    }
267
268    /// Creates a table detector with default configuration.
269    pub fn default() -> Self {
270        Self::new(TableDetectionConfig::default())
271    }
272
273    /// Detects tables from extracted graphics and text fragments.
274    ///
275    /// # Arguments
276    ///
277    /// * `graphics` - Extracted vector lines (H/V borders)
278    /// * `text_fragments` - Text fragments with positions
279    ///
280    /// # Returns
281    ///
282    /// A vector of detected tables, sorted by confidence (highest first).
283    pub fn detect(
284        &self,
285        graphics: &ExtractedGraphics,
286        text_fragments: &[TextFragment],
287    ) -> Result<Vec<DetectedTable>, TableDetectionError> {
288        let mut tables = Vec::new();
289
290        // Check if there are enough lines for a table
291        if !graphics.has_table_structure() {
292            return Ok(tables);
293        }
294
295        // Phase 1: Detect bordered tables from vector lines
296        if let Some(table) = self.detect_bordered_table(graphics, text_fragments)? {
297            tables.push(table);
298        }
299
300        // Phase 2: Detect borderless tables (alignment-based)
301        if self.config.detect_borderless {
302            // Enhancement: Implement borderless table detection using spatial clustering
303            // Priority: MEDIUM - Related to Issue #90 (Advanced Text Extraction)
304            // Current implementation works well for bordered tables
305            // Borderless detection would use alignment patterns and whitespace analysis
306        }
307
308        // Sort by confidence (highest first)
309        // Use total_cmp for IEEE 754 total ordering (NaN-safe, no panic)
310        tables.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
311
312        Ok(tables)
313    }
314
315    /// Detects a bordered table from vector lines.
316    fn detect_bordered_table(
317        &self,
318        graphics: &ExtractedGraphics,
319        text_fragments: &[TextFragment],
320    ) -> Result<Option<DetectedTable>, TableDetectionError> {
321        // Extract horizontal and vertical lines
322        let h_lines: Vec<&VectorLine> = graphics.horizontal_lines().collect();
323        let v_lines: Vec<&VectorLine> = graphics.vertical_lines().collect();
324
325        // Find grid pattern
326        let grid = self.detect_grid_pattern(&h_lines, &v_lines)?;
327
328        if grid.rows.len() < self.config.min_rows || grid.columns.len() < self.config.min_columns {
329            return Ok(None);
330        }
331
332        // Calculate cell boundaries
333        let cells = self.create_cells_from_grid(&grid);
334
335        // Merge base cells across absent interior dividers before text assignment
336        // so text lands in the merged (spanning) cells (issue #375).
337        let cells = self.merge_cells_across_absent_dividers(&grid, cells);
338
339        // Assign text to cells
340        let cells_with_text = self.assign_text_to_cells(cells, text_fragments);
341
342        // Create table bounding box
343        let bbox = self.calculate_table_bbox(&grid)?;
344
345        // Check minimum area
346        if bbox.area() < self.config.min_table_area {
347            return Ok(None);
348        }
349
350        // Number of rows/columns = grid positions - 1 (gaps between lines)
351        let num_rows = grid.rows.len().saturating_sub(1);
352        let num_cols = grid.columns.len().saturating_sub(1);
353
354        let mut table = DetectedTable::new(bbox, cells_with_text, num_rows, num_cols);
355        table.header_rows = Self::count_header_rows(&table, text_fragments);
356
357        Ok(Some(table))
358    }
359
360    /// Counts the leading contiguous header rows of a detected table
361    /// (issue #375, Task 8).
362    ///
363    /// A row is a header row when at least one text fragment landing inside
364    /// one of its cells carries a header structure tag (`"TH"`, or any tag
365    /// containing `"HEADER"`, case-insensitively — e.g. PDF/UA `"TH"` cells
366    /// or a custom `"TableHeader"` role). Only *leading* tagged rows count:
367    /// counting stops at the first row without a header-tagged fragment, so
368    /// a tagged row that is not contiguous with the top does not count.
369    ///
370    /// When no rows carry a header tag, a bordered table with at least two
371    /// rows falls back to treating the top row as the header (the common
372    /// convention for ruled tables without structure information).
373    fn count_header_rows(table: &DetectedTable, fragments: &[TextFragment]) -> usize {
374        fn is_header_tag(tag: &str) -> bool {
375            let t = tag.to_ascii_uppercase();
376            t == "TH" || t.contains("HEADER")
377        }
378
379        let mut tagged_leading = 0usize;
380        for r in 0..table.rows {
381            let row_cells: Vec<&TableCell> = table
382                .cells
383                .iter()
384                .filter(|c| c.row <= r && r < c.row + c.row_span)
385                .collect();
386            let has_header = fragments.iter().any(|f| {
387                f.struct_tag.as_deref().map(is_header_tag).unwrap_or(false)
388                    && row_cells.iter().any(|c| {
389                        c.bbox
390                            .contains_point(f.x + f.width / 2.0, f.y + f.height / 2.0)
391                    })
392            });
393            if has_header {
394                tagged_leading = r + 1;
395            } else {
396                break;
397            }
398        }
399
400        if tagged_leading > 0 {
401            tagged_leading
402        } else if table.rows >= 2 {
403            1
404        } else {
405            0
406        }
407    }
408
409    /// Detects a grid pattern from horizontal and vertical lines.
410    fn detect_grid_pattern(
411        &self,
412        h_lines: &[&VectorLine],
413        v_lines: &[&VectorLine],
414    ) -> Result<GridPattern, TableDetectionError> {
415        // Cluster horizontal lines by Y coordinate
416        let mut rows = self.cluster_lines_by_position(h_lines, LineOrientation::Horizontal)?;
417
418        // Cluster vertical lines by X coordinate
419        let columns = self.cluster_lines_by_position(v_lines, LineOrientation::Vertical)?;
420
421        // Reverse rows so row 0 is at the top (highest Y) for intuitive indexing
422        rows.reverse();
423
424        // Retain the raw divider segments (normalized) so absent interior
425        // dividers can be detected for merged-cell reconstruction (issue #375).
426        let h_segments: Vec<(f64, f64, f64)> = h_lines
427            .iter()
428            .map(|line| (line.y1, line.x1.min(line.x2), line.x1.max(line.x2)))
429            .collect();
430        let v_segments: Vec<(f64, f64, f64)> = v_lines
431            .iter()
432            .map(|line| (line.x1, line.y1.min(line.y2), line.y1.max(line.y2)))
433            .collect();
434
435        Ok(GridPattern {
436            rows,
437            columns,
438            h_segments,
439            v_segments,
440        })
441    }
442
443    /// Clusters lines by their primary position (Y for horizontal, X for vertical).
444    fn cluster_lines_by_position(
445        &self,
446        lines: &[&VectorLine],
447        orientation: LineOrientation,
448    ) -> Result<Vec<f64>, TableDetectionError> {
449        if lines.is_empty() {
450            return Ok(vec![]);
451        }
452
453        // Extract positions
454        let mut positions: Vec<f64> = lines
455            .iter()
456            .map(|line| match orientation {
457                LineOrientation::Horizontal => line.y1, // Y coordinate for horizontal lines
458                LineOrientation::Vertical => line.x1,   // X coordinate for vertical lines
459                _ => 0.0,
460            })
461            .collect();
462
463        // Validate no NaN or Infinity values BEFORE sorting
464        if positions.iter().any(|p| !p.is_finite()) {
465            return Err(TableDetectionError::InvalidCoordinate);
466        }
467
468        // Sort positions (safe: all values are finite after validation)
469        positions.sort_by(|a, b| a.total_cmp(b));
470
471        // Cluster by tolerance - group nearby positions
472        let mut clusters: Vec<Vec<f64>> = vec![vec![positions[0]]];
473
474        for &pos in &positions[1..] {
475            let last_cluster = clusters.last_mut().ok_or_else(|| {
476                TableDetectionError::InternalError("cluster list unexpectedly empty".to_string())
477            })?;
478            let cluster_mean = last_cluster.iter().sum::<f64>() / last_cluster.len() as f64;
479
480            if (pos - cluster_mean).abs() <= self.config.alignment_tolerance {
481                // Add to existing cluster
482                last_cluster.push(pos);
483            } else {
484                // Start new cluster
485                clusters.push(vec![pos]);
486            }
487        }
488
489        // Return mean position of each cluster
490        Ok(clusters
491            .iter()
492            .map(|cluster| cluster.iter().sum::<f64>() / cluster.len() as f64)
493            .collect())
494    }
495
496    /// Creates cell boundaries from grid pattern.
497    fn create_cells_from_grid(&self, grid: &GridPattern) -> Vec<TableCell> {
498        let mut cells = Vec::new();
499
500        // Number of cells = number of gaps between grid lines
501        let num_rows = grid.rows.len().saturating_sub(1);
502        let num_cols = grid.columns.len().saturating_sub(1);
503
504        if num_rows == 0 || num_cols == 0 {
505            return cells;
506        }
507
508        // Iterate over gaps between lines (not the lines themselves)
509        for row_idx in 0..num_rows {
510            let y1 = grid.rows[row_idx];
511            let y2 = grid.rows[row_idx + 1];
512
513            // BoundingBox expects (x, y, width, height) where y is the LOWER edge
514            let row_y = y1.min(y2);
515            let row_height = (y2 - y1).abs();
516
517            for col_idx in 0..num_cols {
518                let col_x = grid.columns[col_idx];
519                let col_width = (grid.columns[col_idx + 1] - col_x).abs();
520
521                let bbox = BoundingBox::new(col_x, row_y, col_width, row_height);
522                let mut cell = TableCell::new(row_idx, col_idx, bbox);
523                cell.has_borders = true;
524
525                cells.push(cell);
526            }
527        }
528
529        cells
530    }
531
532    /// Returns true if a vertical divider is drawn at `x` covering the Y-range
533    /// `[y0, y1]` (within `alignment_tolerance`) — i.e. some retained vertical
534    /// segment sits at that column and spans the given row band (issue #375).
535    fn divider_present_vertical(&self, grid: &GridPattern, x: f64, y0: f64, y1: f64) -> bool {
536        let tol = self.config.alignment_tolerance;
537        let (lo, hi) = (y0.min(y1), y0.max(y1));
538        grid.v_segments
539            .iter()
540            .any(|&(sx, s0, s1)| (sx - x).abs() <= tol && s0 <= lo + tol && s1 >= hi - tol)
541    }
542
543    /// Returns true if a horizontal divider is drawn at `y` covering the
544    /// X-range `[x0, x1]` (within `alignment_tolerance`) (issue #375).
545    fn divider_present_horizontal(&self, grid: &GridPattern, y: f64, x0: f64, x1: f64) -> bool {
546        let tol = self.config.alignment_tolerance;
547        let (lo, hi) = (x0.min(x1), x0.max(x1));
548        grid.h_segments
549            .iter()
550            .any(|&(sy, s0, s1)| (sy - y).abs() <= tol && s0 <= lo + tol && s1 >= hi - tol)
551    }
552
553    /// Merges base cells across *absent* interior dividers into spanning cells.
554    ///
555    /// Two adjacent base cells belong to the same merged region when the divider
556    /// on their shared edge is not drawn. Union-find groups connected base cells;
557    /// each region becomes one [`TableCell`] positioned at its top-left base
558    /// index with `row_span`/`col_span` equal to its extent (issue #375).
559    ///
560    /// A fully-ruled table (every divider present) yields all-`1` spans, i.e.
561    /// output identical to the base grid.
562    fn merge_cells_across_absent_dividers(
563        &self,
564        grid: &GridPattern,
565        cells: Vec<TableCell>,
566    ) -> Vec<TableCell> {
567        let num_rows = grid.rows.len().saturating_sub(1);
568        let num_cols = grid.columns.len().saturating_sub(1);
569        if num_rows == 0 || num_cols == 0 {
570            return cells;
571        }
572
573        // Union-find over base cells (flat index = r * num_cols + c).
574        fn find(parent: &mut [usize], i: usize) -> usize {
575            if parent[i] != i {
576                let root = find(parent, parent[i]);
577                parent[i] = root;
578            }
579            parent[i]
580        }
581
582        let mut parent: Vec<usize> = (0..num_rows * num_cols).collect();
583        for r in 0..num_rows {
584            for c in 0..num_cols {
585                // Merge right across an absent vertical divider at columns[c+1].
586                if c + 1 < num_cols {
587                    let x = grid.columns[c + 1];
588                    if !self.divider_present_vertical(grid, x, grid.rows[r], grid.rows[r + 1]) {
589                        let a = find(&mut parent, r * num_cols + c);
590                        let b = find(&mut parent, r * num_cols + c + 1);
591                        parent[a] = b;
592                    }
593                }
594                // Merge down across an absent horizontal divider at rows[r+1].
595                if r + 1 < num_rows {
596                    let y = grid.rows[r + 1];
597                    if !self.divider_present_horizontal(
598                        grid,
599                        y,
600                        grid.columns[c],
601                        grid.columns[c + 1],
602                    ) {
603                        let a = find(&mut parent, r * num_cols + c);
604                        let b = find(&mut parent, (r + 1) * num_cols + c);
605                        parent[a] = b;
606                    }
607                }
608            }
609        }
610
611        // Group base cells by root. BTreeMap keeps the output deterministic.
612        let mut groups: BTreeMap<usize, Vec<&TableCell>> = BTreeMap::new();
613        for cell in &cells {
614            let root = find(&mut parent, cell.row * num_cols + cell.column);
615            groups.entry(root).or_default().push(cell);
616        }
617
618        let mut merged = Vec::with_capacity(groups.len());
619        for group in groups.into_values() {
620            let min_r = group.iter().map(|c| c.row).min().unwrap_or(0);
621            let min_c = group.iter().map(|c| c.column).min().unwrap_or(0);
622            let max_r = group.iter().map(|c| c.row).max().unwrap_or(0);
623            let max_c = group.iter().map(|c| c.column).max().unwrap_or(0);
624
625            // Merged bbox spans the grid extents of the region (deterministic).
626            let x = grid.columns[min_c];
627            let y = grid.rows[min_r].min(grid.rows[max_r + 1]);
628            let w = (grid.columns[max_c + 1] - grid.columns[min_c]).abs();
629            let h = (grid.rows[max_r + 1] - grid.rows[min_r]).abs();
630
631            let mut cell = TableCell::new(min_r, min_c, BoundingBox::new(x, y, w, h));
632            cell.has_borders = true;
633            cell.row_span = max_r - min_r + 1;
634            cell.col_span = max_c - min_c + 1;
635            merged.push(cell);
636        }
637
638        merged.sort_by_key(|c| (c.row, c.column));
639        merged
640    }
641
642    /// Assigns text fragments to cells based on spatial containment.
643    ///
644    /// **Coordinate Space Normalization**:
645    /// Some PDFs (especially those generated by certain tools) have extreme CTM transformations
646    /// that result in text and graphics being in vastly different coordinate spaces.
647    /// This function detects such mismatches and applies affine transformation to normalize.
648    fn assign_text_to_cells(
649        &self,
650        mut cells: Vec<TableCell>,
651        text_fragments: &[TextFragment],
652    ) -> Vec<TableCell> {
653        if text_fragments.is_empty() || cells.is_empty() {
654            return cells;
655        }
656
657        // Detect coordinate space mismatch and normalize if needed
658        let normalized_fragments = normalize_coordinates_if_needed(&cells, text_fragments);
659
660        for cell in &mut cells {
661            let mut cell_texts = Vec::new();
662
663            for fragment in &normalized_fragments {
664                // Check if fragment center is inside cell
665                let center_x = fragment.x + fragment.width / 2.0;
666                let center_y = fragment.y + fragment.height / 2.0;
667
668                if cell.bbox.contains_point(center_x, center_y) {
669                    cell_texts.push(fragment.text.clone());
670                }
671            }
672
673            if !cell_texts.is_empty() {
674                cell.text = cell_texts.join(" ");
675            }
676        }
677
678        cells
679    }
680
681    /// Calculates the table bounding box from grid pattern.
682    fn calculate_table_bbox(&self, grid: &GridPattern) -> Result<BoundingBox, TableDetectionError> {
683        let min_x = *grid
684            .columns
685            .first()
686            .ok_or_else(|| TableDetectionError::InvalidGrid("no columns".to_string()))?;
687        let max_x = *grid
688            .columns
689            .last()
690            .ok_or_else(|| TableDetectionError::InvalidGrid("no columns".to_string()))?;
691
692        // Get min/max Y regardless of row order (ascending or descending)
693        let first_y = *grid
694            .rows
695            .first()
696            .ok_or_else(|| TableDetectionError::InvalidGrid("no rows".to_string()))?;
697        let last_y = *grid
698            .rows
699            .last()
700            .ok_or_else(|| TableDetectionError::InvalidGrid("no rows".to_string()))?;
701        let min_y = first_y.min(last_y);
702        let max_y = first_y.max(last_y);
703
704        Ok(BoundingBox::new(min_x, min_y, max_x - min_x, max_y - min_y))
705    }
706}
707
708/// Grid pattern detected from lines.
709struct GridPattern {
710    /// Row Y coordinates (sorted)
711    rows: Vec<f64>,
712    /// Column X coordinates (sorted)
713    columns: Vec<f64>,
714    /// Raw horizontal divider segments as `(y, x_start, x_end)` with
715    /// `x_start <= x_end`. Retained so absent interior dividers (merged cells)
716    /// can be detected instead of assuming a fully dense grid (issue #375).
717    h_segments: Vec<(f64, f64, f64)>,
718    /// Raw vertical divider segments as `(x, y_start, y_end)` with
719    /// `y_start <= y_end` (issue #375).
720    v_segments: Vec<(f64, f64, f64)>,
721}
722
723impl Default for TableDetector {
724    fn default() -> Self {
725        Self::new(TableDetectionConfig::default())
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    #[test]
734    fn test_bounding_box_contains_point() {
735        let bbox = BoundingBox::new(100.0, 100.0, 100.0, 50.0);
736
737        assert!(bbox.contains_point(150.0, 125.0)); // Center
738        assert!(bbox.contains_point(100.0, 100.0)); // Bottom-left corner
739        assert!(bbox.contains_point(200.0, 150.0)); // Top-right corner
740        assert!(!bbox.contains_point(50.0, 125.0)); // Left outside
741        assert!(!bbox.contains_point(250.0, 125.0)); // Right outside
742        assert!(!bbox.contains_point(150.0, 50.0)); // Below
743        assert!(!bbox.contains_point(150.0, 200.0)); // Above
744    }
745
746    #[test]
747    fn test_bounding_box_area() {
748        let bbox = BoundingBox::new(0.0, 0.0, 100.0, 50.0);
749        assert!((bbox.area() - 5000.0).abs() < 0.01);
750    }
751
752    #[test]
753    fn test_table_cell_new() {
754        let bbox = BoundingBox::new(0.0, 0.0, 50.0, 25.0);
755        let cell = TableCell::new(1, 2, bbox);
756
757        assert_eq!(cell.row, 1);
758        assert_eq!(cell.column, 2);
759        assert!(cell.is_empty());
760        assert!(!cell.has_borders);
761    }
762
763    #[test]
764    fn test_table_cell_set_text() {
765        let bbox = BoundingBox::new(0.0, 0.0, 50.0, 25.0);
766        let mut cell = TableCell::new(0, 0, bbox);
767
768        cell.set_text("Test".to_string());
769        assert_eq!(cell.text, "Test");
770        assert!(!cell.is_empty());
771    }
772
773    #[test]
774    fn test_detected_table_get_cell() {
775        let bbox = BoundingBox::new(0.0, 0.0, 200.0, 100.0);
776        let cells = vec![
777            TableCell::new(0, 0, BoundingBox::new(0.0, 0.0, 100.0, 50.0)),
778            TableCell::new(0, 1, BoundingBox::new(100.0, 0.0, 100.0, 50.0)),
779            TableCell::new(1, 0, BoundingBox::new(0.0, 50.0, 100.0, 50.0)),
780            TableCell::new(1, 1, BoundingBox::new(100.0, 50.0, 100.0, 50.0)),
781        ];
782
783        let table = DetectedTable::new(bbox, cells, 2, 2);
784
785        assert_eq!(table.row_count(), 2);
786        assert_eq!(table.column_count(), 2);
787
788        let cell = table.get_cell(0, 0).expect("cell (0,0) should exist");
789        assert_eq!(cell.row, 0);
790        assert_eq!(cell.column, 0);
791
792        assert!(table.get_cell(2, 0).is_none()); // Out of bounds
793        assert!(table.get_cell(0, 2).is_none()); // Out of bounds
794    }
795
796    #[test]
797    fn test_table_detection_config_default() {
798        let config = TableDetectionConfig::default();
799        assert_eq!(config.min_rows, 2);
800        assert_eq!(config.min_columns, 2);
801        assert_eq!(config.alignment_tolerance, 2.0);
802        assert!(!config.detect_borderless);
803    }
804}
805
806/// Normalizes text coordinates to match cell coordinate space if needed.
807///
808/// **Problem**: Some PDFs have extreme CTM transformations where text and graphics
809/// end up in vastly different coordinate systems (e.g., text Y=878000, cells Y=300).
810///
811/// **Solution**: Detect coordinate space mismatch and apply affine transformation
812/// (scale + translate) to map text coordinates into cell coordinate space.
813///
814/// **When applied**:
815/// - Only when there's NO overlap between text and cell bounding boxes
816/// - Preserves aspect ratio and relative positioning
817/// - Returns original fragments if coordinates already align
818fn normalize_coordinates_if_needed(
819    cells: &[TableCell],
820    text_fragments: &[TextFragment],
821) -> Vec<TextFragment> {
822    // Calculate bounding boxes for both coordinate spaces
823    let cell_bbox = calculate_combined_bbox_cells(cells);
824    let text_bbox = calculate_combined_bbox_fragments(text_fragments);
825
826    // Check if bounding boxes overlap
827    let x_overlap = text_bbox.0 < cell_bbox.2 && text_bbox.2 > cell_bbox.0;
828    let y_overlap = text_bbox.1 < cell_bbox.3 && text_bbox.3 > cell_bbox.1;
829
830    // If coordinates already overlap, no normalization needed
831    if x_overlap && y_overlap {
832        return text_fragments.to_vec();
833    }
834
835    // Calculate affine transformation: scale + translate
836    let text_width = text_bbox.2 - text_bbox.0;
837    let text_height = text_bbox.3 - text_bbox.1;
838    let cell_width = cell_bbox.2 - cell_bbox.0;
839    let cell_height = cell_bbox.3 - cell_bbox.1;
840
841    let scale_x = if text_width > 0.0 {
842        cell_width / text_width
843    } else {
844        1.0
845    };
846    let scale_y = if text_height > 0.0 {
847        cell_height / text_height
848    } else {
849        1.0
850    };
851
852    let translate_x = cell_bbox.0 - (text_bbox.0 * scale_x);
853    let translate_y = cell_bbox.1 - (text_bbox.1 * scale_y);
854
855    // Apply transformation to all fragments
856    text_fragments
857        .iter()
858        .map(|frag| TextFragment {
859            text: frag.text.clone(),
860            x: frag.x * scale_x + translate_x,
861            y: frag.y * scale_y + translate_y,
862            width: frag.width * scale_x,
863            height: frag.height * scale_y,
864            font_size: frag.font_size,
865            font_name: frag.font_name.clone(),
866            is_bold: frag.is_bold,
867            is_italic: frag.is_italic,
868            color: frag.color,
869            space_decisions: Vec::new(),
870            mcid: frag.mcid,
871            struct_tag: frag.struct_tag.clone(),
872        })
873        .collect()
874}
875
876/// Calculates combined bounding box for cells: (min_x, min_y, max_x, max_y)
877fn calculate_combined_bbox_cells(cells: &[TableCell]) -> (f64, f64, f64, f64) {
878    let min_x = cells.iter().map(|c| c.bbox.x).fold(f64::INFINITY, f64::min);
879    let max_x = cells
880        .iter()
881        .map(|c| c.bbox.right())
882        .fold(f64::NEG_INFINITY, f64::max);
883    let min_y = cells.iter().map(|c| c.bbox.y).fold(f64::INFINITY, f64::min);
884    let max_y = cells
885        .iter()
886        .map(|c| c.bbox.top())
887        .fold(f64::NEG_INFINITY, f64::max);
888    (min_x, min_y, max_x, max_y)
889}
890
891/// Calculates combined bounding box for text fragments: (min_x, min_y, max_x, max_y)
892fn calculate_combined_bbox_fragments(fragments: &[TextFragment]) -> (f64, f64, f64, f64) {
893    let min_x = fragments.iter().map(|f| f.x).fold(f64::INFINITY, f64::min);
894    let max_x = fragments
895        .iter()
896        .map(|f| f.x + f.width)
897        .fold(f64::NEG_INFINITY, f64::max);
898    let min_y = fragments.iter().map(|f| f.y).fold(f64::INFINITY, f64::min);
899    let max_y = fragments
900        .iter()
901        .map(|f| f.y + f.height)
902        .fold(f64::NEG_INFINITY, f64::max);
903    (min_x, min_y, max_x, max_y)
904}