use crate::graphics::extraction::{ExtractedGraphics, LineOrientation, VectorLine};
use crate::text::extraction::TextFragment;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TableDetectionError {
#[error("Invalid coordinate value: expected valid f64, found NaN or Infinity")]
InvalidCoordinate,
#[error("Invalid grid: {0}")]
InvalidGrid(String),
#[error("Internal error: {0}")]
InternalError(String),
}
#[derive(Debug, Clone)]
pub struct TableDetectionConfig {
pub min_rows: usize,
pub min_columns: usize,
pub alignment_tolerance: f64,
pub min_table_area: f64,
pub detect_borderless: bool,
}
impl Default for TableDetectionConfig {
fn default() -> Self {
Self {
min_rows: 2,
min_columns: 2,
alignment_tolerance: 2.0, min_table_area: 1000.0, detect_borderless: false, }
}
}
#[derive(Debug, Clone)]
pub struct DetectedTable {
pub bbox: BoundingBox,
pub cells: Vec<TableCell>,
pub rows: usize,
pub columns: usize,
pub confidence: f64,
}
impl DetectedTable {
pub fn new(bbox: BoundingBox, cells: Vec<TableCell>, rows: usize, columns: usize) -> Self {
let confidence = Self::calculate_confidence(&cells, rows, columns);
Self {
bbox,
cells,
rows,
columns,
confidence,
}
}
pub fn row_count(&self) -> usize {
self.rows
}
pub fn column_count(&self) -> usize {
self.columns
}
pub fn get_cell(&self, row: usize, col: usize) -> Option<&TableCell> {
if row >= self.rows || col >= self.columns {
return None;
}
let index = row * self.columns + col;
self.cells.get(index)
}
fn calculate_confidence(cells: &[TableCell], rows: usize, columns: usize) -> f64 {
if rows == 0 || columns == 0 {
return 0.0;
}
let total_cells = rows * columns;
let populated_cells = cells.iter().filter(|c| !c.text.is_empty()).count();
let population_ratio = populated_cells as f64 / total_cells as f64;
let size_bonus = ((rows + columns) as f64 / 10.0).min(0.2);
(population_ratio + size_bonus).min(1.0)
}
}
#[derive(Debug, Clone)]
pub struct TableCell {
pub row: usize,
pub column: usize,
pub bbox: BoundingBox,
pub text: String,
pub has_borders: bool,
}
impl TableCell {
pub fn new(row: usize, column: usize, bbox: BoundingBox) -> Self {
Self {
row,
column,
bbox,
text: String::new(),
has_borders: false,
}
}
pub fn set_text(&mut self, text: String) {
self.text = text;
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
}
#[derive(Debug, Clone, Copy)]
pub struct BoundingBox {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
impl BoundingBox {
pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
Self {
x,
y,
width,
height,
}
}
pub fn right(&self) -> f64 {
self.x + self.width
}
pub fn top(&self) -> f64 {
self.y + self.height
}
pub fn contains_point(&self, px: f64, py: f64) -> bool {
px >= self.x && px <= self.right() && py >= self.y && py <= self.top()
}
pub fn area(&self) -> f64 {
self.width * self.height
}
}
pub struct TableDetector {
config: TableDetectionConfig,
}
impl TableDetector {
pub fn new(config: TableDetectionConfig) -> Self {
Self { config }
}
pub fn default() -> Self {
Self::new(TableDetectionConfig::default())
}
pub fn detect(
&self,
graphics: &ExtractedGraphics,
text_fragments: &[TextFragment],
) -> Result<Vec<DetectedTable>, TableDetectionError> {
let mut tables = Vec::new();
if !graphics.has_table_structure() {
return Ok(tables);
}
if let Some(table) = self.detect_bordered_table(graphics, text_fragments)? {
tables.push(table);
}
if self.config.detect_borderless {
}
tables.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
Ok(tables)
}
fn detect_bordered_table(
&self,
graphics: &ExtractedGraphics,
text_fragments: &[TextFragment],
) -> Result<Option<DetectedTable>, TableDetectionError> {
let h_lines: Vec<&VectorLine> = graphics.horizontal_lines().collect();
let v_lines: Vec<&VectorLine> = graphics.vertical_lines().collect();
let grid = self.detect_grid_pattern(&h_lines, &v_lines)?;
if grid.rows.len() < self.config.min_rows || grid.columns.len() < self.config.min_columns {
return Ok(None);
}
let cells = self.create_cells_from_grid(&grid);
let cells_with_text = self.assign_text_to_cells(cells, text_fragments);
let bbox = self.calculate_table_bbox(&grid)?;
if bbox.area() < self.config.min_table_area {
return Ok(None);
}
let num_rows = grid.rows.len().saturating_sub(1);
let num_cols = grid.columns.len().saturating_sub(1);
let table = DetectedTable::new(bbox, cells_with_text, num_rows, num_cols);
Ok(Some(table))
}
fn detect_grid_pattern(
&self,
h_lines: &[&VectorLine],
v_lines: &[&VectorLine],
) -> Result<GridPattern, TableDetectionError> {
let mut rows = self.cluster_lines_by_position(h_lines, LineOrientation::Horizontal)?;
let columns = self.cluster_lines_by_position(v_lines, LineOrientation::Vertical)?;
rows.reverse();
Ok(GridPattern { rows, columns })
}
fn cluster_lines_by_position(
&self,
lines: &[&VectorLine],
orientation: LineOrientation,
) -> Result<Vec<f64>, TableDetectionError> {
if lines.is_empty() {
return Ok(vec![]);
}
let mut positions: Vec<f64> = lines
.iter()
.map(|line| match orientation {
LineOrientation::Horizontal => line.y1, LineOrientation::Vertical => line.x1, _ => 0.0,
})
.collect();
if positions.iter().any(|p| !p.is_finite()) {
return Err(TableDetectionError::InvalidCoordinate);
}
positions.sort_by(|a, b| a.total_cmp(b));
let mut clusters: Vec<Vec<f64>> = vec![vec![positions[0]]];
for &pos in &positions[1..] {
let last_cluster = clusters.last_mut().ok_or_else(|| {
TableDetectionError::InternalError("cluster list unexpectedly empty".to_string())
})?;
let cluster_mean = last_cluster.iter().sum::<f64>() / last_cluster.len() as f64;
if (pos - cluster_mean).abs() <= self.config.alignment_tolerance {
last_cluster.push(pos);
} else {
clusters.push(vec![pos]);
}
}
Ok(clusters
.iter()
.map(|cluster| cluster.iter().sum::<f64>() / cluster.len() as f64)
.collect())
}
fn create_cells_from_grid(&self, grid: &GridPattern) -> Vec<TableCell> {
let mut cells = Vec::new();
let num_rows = grid.rows.len().saturating_sub(1);
let num_cols = grid.columns.len().saturating_sub(1);
if num_rows == 0 || num_cols == 0 {
return cells;
}
for row_idx in 0..num_rows {
let y1 = grid.rows[row_idx];
let y2 = grid.rows[row_idx + 1];
let row_y = y1.min(y2);
let row_height = (y2 - y1).abs();
for col_idx in 0..num_cols {
let col_x = grid.columns[col_idx];
let col_width = (grid.columns[col_idx + 1] - col_x).abs();
let bbox = BoundingBox::new(col_x, row_y, col_width, row_height);
let mut cell = TableCell::new(row_idx, col_idx, bbox);
cell.has_borders = true;
cells.push(cell);
}
}
cells
}
fn assign_text_to_cells(
&self,
mut cells: Vec<TableCell>,
text_fragments: &[TextFragment],
) -> Vec<TableCell> {
if text_fragments.is_empty() || cells.is_empty() {
return cells;
}
let normalized_fragments = normalize_coordinates_if_needed(&cells, text_fragments);
for cell in &mut cells {
let mut cell_texts = Vec::new();
for fragment in &normalized_fragments {
let center_x = fragment.x + fragment.width / 2.0;
let center_y = fragment.y + fragment.height / 2.0;
if cell.bbox.contains_point(center_x, center_y) {
cell_texts.push(fragment.text.clone());
}
}
if !cell_texts.is_empty() {
cell.text = cell_texts.join(" ");
}
}
cells
}
fn calculate_table_bbox(&self, grid: &GridPattern) -> Result<BoundingBox, TableDetectionError> {
let min_x = *grid
.columns
.first()
.ok_or_else(|| TableDetectionError::InvalidGrid("no columns".to_string()))?;
let max_x = *grid
.columns
.last()
.ok_or_else(|| TableDetectionError::InvalidGrid("no columns".to_string()))?;
let first_y = *grid
.rows
.first()
.ok_or_else(|| TableDetectionError::InvalidGrid("no rows".to_string()))?;
let last_y = *grid
.rows
.last()
.ok_or_else(|| TableDetectionError::InvalidGrid("no rows".to_string()))?;
let min_y = first_y.min(last_y);
let max_y = first_y.max(last_y);
Ok(BoundingBox::new(min_x, min_y, max_x - min_x, max_y - min_y))
}
}
struct GridPattern {
rows: Vec<f64>,
columns: Vec<f64>,
}
impl Default for TableDetector {
fn default() -> Self {
Self::new(TableDetectionConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bounding_box_contains_point() {
let bbox = BoundingBox::new(100.0, 100.0, 100.0, 50.0);
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)); }
#[test]
fn test_bounding_box_area() {
let bbox = BoundingBox::new(0.0, 0.0, 100.0, 50.0);
assert!((bbox.area() - 5000.0).abs() < 0.01);
}
#[test]
fn test_table_cell_new() {
let bbox = BoundingBox::new(0.0, 0.0, 50.0, 25.0);
let cell = TableCell::new(1, 2, bbox);
assert_eq!(cell.row, 1);
assert_eq!(cell.column, 2);
assert!(cell.is_empty());
assert!(!cell.has_borders);
}
#[test]
fn test_table_cell_set_text() {
let bbox = BoundingBox::new(0.0, 0.0, 50.0, 25.0);
let mut cell = TableCell::new(0, 0, bbox);
cell.set_text("Test".to_string());
assert_eq!(cell.text, "Test");
assert!(!cell.is_empty());
}
#[test]
fn test_detected_table_get_cell() {
let bbox = BoundingBox::new(0.0, 0.0, 200.0, 100.0);
let cells = vec![
TableCell::new(0, 0, BoundingBox::new(0.0, 0.0, 100.0, 50.0)),
TableCell::new(0, 1, BoundingBox::new(100.0, 0.0, 100.0, 50.0)),
TableCell::new(1, 0, BoundingBox::new(0.0, 50.0, 100.0, 50.0)),
TableCell::new(1, 1, BoundingBox::new(100.0, 50.0, 100.0, 50.0)),
];
let table = DetectedTable::new(bbox, cells, 2, 2);
assert_eq!(table.row_count(), 2);
assert_eq!(table.column_count(), 2);
let cell = table.get_cell(0, 0).expect("cell (0,0) should exist");
assert_eq!(cell.row, 0);
assert_eq!(cell.column, 0);
assert!(table.get_cell(2, 0).is_none()); assert!(table.get_cell(0, 2).is_none()); }
#[test]
fn test_table_detection_config_default() {
let config = TableDetectionConfig::default();
assert_eq!(config.min_rows, 2);
assert_eq!(config.min_columns, 2);
assert_eq!(config.alignment_tolerance, 2.0);
assert!(!config.detect_borderless);
}
}
fn normalize_coordinates_if_needed(
cells: &[TableCell],
text_fragments: &[TextFragment],
) -> Vec<TextFragment> {
let cell_bbox = calculate_combined_bbox_cells(cells);
let text_bbox = calculate_combined_bbox_fragments(text_fragments);
let x_overlap = text_bbox.0 < cell_bbox.2 && text_bbox.2 > cell_bbox.0;
let y_overlap = text_bbox.1 < cell_bbox.3 && text_bbox.3 > cell_bbox.1;
if x_overlap && y_overlap {
return text_fragments.to_vec();
}
let text_width = text_bbox.2 - text_bbox.0;
let text_height = text_bbox.3 - text_bbox.1;
let cell_width = cell_bbox.2 - cell_bbox.0;
let cell_height = cell_bbox.3 - cell_bbox.1;
let scale_x = if text_width > 0.0 {
cell_width / text_width
} else {
1.0
};
let scale_y = if text_height > 0.0 {
cell_height / text_height
} else {
1.0
};
let translate_x = cell_bbox.0 - (text_bbox.0 * scale_x);
let translate_y = cell_bbox.1 - (text_bbox.1 * scale_y);
text_fragments
.iter()
.map(|frag| TextFragment {
text: frag.text.clone(),
x: frag.x * scale_x + translate_x,
y: frag.y * scale_y + translate_y,
width: frag.width * scale_x,
height: frag.height * scale_y,
font_size: frag.font_size,
font_name: frag.font_name.clone(),
is_bold: frag.is_bold,
is_italic: frag.is_italic,
color: frag.color,
space_decisions: Vec::new(),
})
.collect()
}
fn calculate_combined_bbox_cells(cells: &[TableCell]) -> (f64, f64, f64, f64) {
let min_x = cells.iter().map(|c| c.bbox.x).fold(f64::INFINITY, f64::min);
let max_x = cells
.iter()
.map(|c| c.bbox.right())
.fold(f64::NEG_INFINITY, f64::max);
let min_y = cells.iter().map(|c| c.bbox.y).fold(f64::INFINITY, f64::min);
let max_y = cells
.iter()
.map(|c| c.bbox.top())
.fold(f64::NEG_INFINITY, f64::max);
(min_x, min_y, max_x, max_y)
}
fn calculate_combined_bbox_fragments(fragments: &[TextFragment]) -> (f64, f64, f64, f64) {
let min_x = fragments.iter().map(|f| f.x).fold(f64::INFINITY, f64::min);
let max_x = fragments
.iter()
.map(|f| f.x + f.width)
.fold(f64::NEG_INFINITY, f64::max);
let min_y = fragments.iter().map(|f| f.y).fold(f64::INFINITY, f64::min);
let max_y = fragments
.iter()
.map(|f| f.y + f.height)
.fold(f64::NEG_INFINITY, f64::max);
(min_x, min_y, max_x, max_y)
}