1use crate::graphics::extraction::{ExtractedGraphics, LineOrientation, VectorLine};
50use crate::text::extraction::TextFragment;
51use std::collections::BTreeMap;
52use thiserror::Error;
53
54#[derive(Debug, Error)]
56pub enum TableDetectionError {
57 #[error("Invalid coordinate value: expected valid f64, found NaN or Infinity")]
59 InvalidCoordinate,
60
61 #[error("Invalid grid: {0}")]
63 InvalidGrid(String),
64
65 #[error("Internal error: {0}")]
67 InternalError(String),
68}
69
70#[derive(Debug, Clone)]
72pub struct TableDetectionConfig {
73 pub min_rows: usize,
75 pub min_columns: usize,
77 pub alignment_tolerance: f64,
79 pub min_table_area: f64,
81 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, min_table_area: 1000.0, detect_borderless: false, }
94 }
95}
96
97#[derive(Debug, Clone)]
99#[non_exhaustive]
100pub struct DetectedTable {
101 pub bbox: BoundingBox,
103 pub cells: Vec<TableCell>,
105 pub rows: usize,
107 pub columns: usize,
109 pub confidence: f64,
111 pub header_rows: usize,
113}
114
115impl DetectedTable {
116 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 pub fn row_count(&self) -> usize {
131 self.rows
132 }
133
134 pub fn column_count(&self) -> usize {
136 self.columns
137 }
138
139 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 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 let population_ratio = populated_cells as f64 / total_cells as f64;
159
160 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#[derive(Debug, Clone)]
169#[non_exhaustive]
170pub struct TableCell {
171 pub row: usize,
173 pub column: usize,
175 pub bbox: BoundingBox,
177 pub text: String,
179 pub has_borders: bool,
181 pub row_span: usize,
183 pub col_span: usize,
185}
186
187impl TableCell {
188 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 pub fn set_text(&mut self, text: String) {
203 self.text = text;
204 }
205
206 pub fn is_empty(&self) -> bool {
208 self.text.is_empty()
209 }
210}
211
212#[derive(Debug, Clone, Copy)]
214pub struct BoundingBox {
215 pub x: f64,
217 pub y: f64,
219 pub width: f64,
221 pub height: f64,
223}
224
225impl BoundingBox {
226 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 pub fn right(&self) -> f64 {
238 self.x + self.width
239 }
240
241 pub fn top(&self) -> f64 {
243 self.y + self.height
244 }
245
246 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 pub fn area(&self) -> f64 {
253 self.width * self.height
254 }
255}
256
257pub struct TableDetector {
259 config: TableDetectionConfig,
260}
261
262impl TableDetector {
263 pub fn new(config: TableDetectionConfig) -> Self {
265 Self { config }
266 }
267
268 pub fn default() -> Self {
270 Self::new(TableDetectionConfig::default())
271 }
272
273 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 if !graphics.has_table_structure() {
292 return Ok(tables);
293 }
294
295 if let Some(table) = self.detect_bordered_table(graphics, text_fragments)? {
297 tables.push(table);
298 }
299
300 if self.config.detect_borderless {
302 }
307
308 tables.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
311
312 Ok(tables)
313 }
314
315 fn detect_bordered_table(
317 &self,
318 graphics: &ExtractedGraphics,
319 text_fragments: &[TextFragment],
320 ) -> Result<Option<DetectedTable>, TableDetectionError> {
321 let h_lines: Vec<&VectorLine> = graphics.horizontal_lines().collect();
323 let v_lines: Vec<&VectorLine> = graphics.vertical_lines().collect();
324
325 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 let cells = self.create_cells_from_grid(&grid);
334
335 let cells = self.merge_cells_across_absent_dividers(&grid, cells);
338
339 let cells_with_text = self.assign_text_to_cells(cells, text_fragments);
341
342 let bbox = self.calculate_table_bbox(&grid)?;
344
345 if bbox.area() < self.config.min_table_area {
347 return Ok(None);
348 }
349
350 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 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 fn detect_grid_pattern(
411 &self,
412 h_lines: &[&VectorLine],
413 v_lines: &[&VectorLine],
414 ) -> Result<GridPattern, TableDetectionError> {
415 let mut rows = self.cluster_lines_by_position(h_lines, LineOrientation::Horizontal)?;
417
418 let columns = self.cluster_lines_by_position(v_lines, LineOrientation::Vertical)?;
420
421 rows.reverse();
423
424 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 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 let mut positions: Vec<f64> = lines
455 .iter()
456 .map(|line| match orientation {
457 LineOrientation::Horizontal => line.y1, LineOrientation::Vertical => line.x1, _ => 0.0,
460 })
461 .collect();
462
463 if positions.iter().any(|p| !p.is_finite()) {
465 return Err(TableDetectionError::InvalidCoordinate);
466 }
467
468 positions.sort_by(|a, b| a.total_cmp(b));
470
471 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 last_cluster.push(pos);
483 } else {
484 clusters.push(vec![pos]);
486 }
487 }
488
489 Ok(clusters
491 .iter()
492 .map(|cluster| cluster.iter().sum::<f64>() / cluster.len() as f64)
493 .collect())
494 }
495
496 fn create_cells_from_grid(&self, grid: &GridPattern) -> Vec<TableCell> {
498 let mut cells = Vec::new();
499
500 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 for row_idx in 0..num_rows {
510 let y1 = grid.rows[row_idx];
511 let y2 = grid.rows[row_idx + 1];
512
513 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 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 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 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 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 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 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 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 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 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 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 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 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 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
708struct GridPattern {
710 rows: Vec<f64>,
712 columns: Vec<f64>,
714 h_segments: Vec<(f64, f64, f64)>,
718 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)); assert!(bbox.contains_point(100.0, 100.0)); assert!(bbox.contains_point(200.0, 150.0)); assert!(!bbox.contains_point(50.0, 125.0)); assert!(!bbox.contains_point(250.0, 125.0)); assert!(!bbox.contains_point(150.0, 50.0)); assert!(!bbox.contains_point(150.0, 200.0)); }
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()); assert!(table.get_cell(0, 2).is_none()); }
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
806fn normalize_coordinates_if_needed(
819 cells: &[TableCell],
820 text_fragments: &[TextFragment],
821) -> Vec<TextFragment> {
822 let cell_bbox = calculate_combined_bbox_cells(cells);
824 let text_bbox = calculate_combined_bbox_fragments(text_fragments);
825
826 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 x_overlap && y_overlap {
832 return text_fragments.to_vec();
833 }
834
835 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 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
876fn 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
891fn 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}