use crate::graphics::extraction::{ExtractedGraphics, LineOrientation, VectorLine};
use crate::text::extraction::TextFragment;
use std::collections::BTreeMap;
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)]
#[non_exhaustive]
pub struct DetectedTable {
pub bbox: BoundingBox,
pub cells: Vec<TableCell>,
pub rows: usize,
pub columns: usize,
pub confidence: f64,
pub header_rows: usize,
}
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,
header_rows: 0,
}
}
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)]
#[non_exhaustive]
pub struct TableCell {
pub row: usize,
pub column: usize,
pub bbox: BoundingBox,
pub text: String,
pub has_borders: bool,
pub row_span: usize,
pub col_span: usize,
}
impl TableCell {
pub fn new(row: usize, column: usize, bbox: BoundingBox) -> Self {
Self {
row,
column,
bbox,
text: String::new(),
has_borders: false,
row_span: 1,
col_span: 1,
}
}
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 = self.merge_cells_across_absent_dividers(&grid, cells);
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 mut table = DetectedTable::new(bbox, cells_with_text, num_rows, num_cols);
table.header_rows = Self::count_header_rows(&table, text_fragments);
Ok(Some(table))
}
fn count_header_rows(table: &DetectedTable, fragments: &[TextFragment]) -> usize {
fn is_header_tag(tag: &str) -> bool {
let t = tag.to_ascii_uppercase();
t == "TH" || t.contains("HEADER")
}
let mut tagged_leading = 0usize;
for r in 0..table.rows {
let row_cells: Vec<&TableCell> = table
.cells
.iter()
.filter(|c| c.row <= r && r < c.row + c.row_span)
.collect();
let has_header = fragments.iter().any(|f| {
f.struct_tag.as_deref().map(is_header_tag).unwrap_or(false)
&& row_cells.iter().any(|c| {
c.bbox
.contains_point(f.x + f.width / 2.0, f.y + f.height / 2.0)
})
});
if has_header {
tagged_leading = r + 1;
} else {
break;
}
}
if tagged_leading > 0 {
tagged_leading
} else if table.rows >= 2 {
1
} else {
0
}
}
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();
let h_segments: Vec<(f64, f64, f64)> = h_lines
.iter()
.map(|line| (line.y1, line.x1.min(line.x2), line.x1.max(line.x2)))
.collect();
let v_segments: Vec<(f64, f64, f64)> = v_lines
.iter()
.map(|line| (line.x1, line.y1.min(line.y2), line.y1.max(line.y2)))
.collect();
Ok(GridPattern {
rows,
columns,
h_segments,
v_segments,
})
}
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 divider_present_vertical(&self, grid: &GridPattern, x: f64, y0: f64, y1: f64) -> bool {
let tol = self.config.alignment_tolerance;
let (lo, hi) = (y0.min(y1), y0.max(y1));
grid.v_segments
.iter()
.any(|&(sx, s0, s1)| (sx - x).abs() <= tol && s0 <= lo + tol && s1 >= hi - tol)
}
fn divider_present_horizontal(&self, grid: &GridPattern, y: f64, x0: f64, x1: f64) -> bool {
let tol = self.config.alignment_tolerance;
let (lo, hi) = (x0.min(x1), x0.max(x1));
grid.h_segments
.iter()
.any(|&(sy, s0, s1)| (sy - y).abs() <= tol && s0 <= lo + tol && s1 >= hi - tol)
}
fn merge_cells_across_absent_dividers(
&self,
grid: &GridPattern,
cells: Vec<TableCell>,
) -> Vec<TableCell> {
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;
}
fn find(parent: &mut [usize], i: usize) -> usize {
if parent[i] != i {
let root = find(parent, parent[i]);
parent[i] = root;
}
parent[i]
}
let mut parent: Vec<usize> = (0..num_rows * num_cols).collect();
for r in 0..num_rows {
for c in 0..num_cols {
if c + 1 < num_cols {
let x = grid.columns[c + 1];
if !self.divider_present_vertical(grid, x, grid.rows[r], grid.rows[r + 1]) {
let a = find(&mut parent, r * num_cols + c);
let b = find(&mut parent, r * num_cols + c + 1);
parent[a] = b;
}
}
if r + 1 < num_rows {
let y = grid.rows[r + 1];
if !self.divider_present_horizontal(
grid,
y,
grid.columns[c],
grid.columns[c + 1],
) {
let a = find(&mut parent, r * num_cols + c);
let b = find(&mut parent, (r + 1) * num_cols + c);
parent[a] = b;
}
}
}
}
let mut groups: BTreeMap<usize, Vec<&TableCell>> = BTreeMap::new();
for cell in &cells {
let root = find(&mut parent, cell.row * num_cols + cell.column);
groups.entry(root).or_default().push(cell);
}
let mut merged = Vec::with_capacity(groups.len());
for group in groups.into_values() {
let min_r = group.iter().map(|c| c.row).min().unwrap_or(0);
let min_c = group.iter().map(|c| c.column).min().unwrap_or(0);
let max_r = group.iter().map(|c| c.row).max().unwrap_or(0);
let max_c = group.iter().map(|c| c.column).max().unwrap_or(0);
let x = grid.columns[min_c];
let y = grid.rows[min_r].min(grid.rows[max_r + 1]);
let w = (grid.columns[max_c + 1] - grid.columns[min_c]).abs();
let h = (grid.rows[max_r + 1] - grid.rows[min_r]).abs();
let mut cell = TableCell::new(min_r, min_c, BoundingBox::new(x, y, w, h));
cell.has_borders = true;
cell.row_span = max_r - min_r + 1;
cell.col_span = max_c - min_c + 1;
merged.push(cell);
}
merged.sort_by_key(|c| (c.row, c.column));
merged
}
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>,
h_segments: Vec<(f64, f64, f64)>,
v_segments: Vec<(f64, f64, 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(),
mcid: frag.mcid,
struct_tag: frag.struct_tag.clone(),
})
.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)
}