//! Table detection and formatting.
//!
//! Detects tabular data in PDF text items and converts to markdown tables.
mod detect_heuristic;
mod detect_lines;
mod detect_rects;
mod detect_struct;
mod financial;
mod format;
mod grid;
pub mod structured;
pub use detect_heuristic::detect_tables;
pub(crate) use detect_heuristic::is_table_of_contents;
pub use detect_lines::detect_tables_from_lines;
pub(crate) use detect_rects::cluster_rects;
pub use detect_rects::{detect_tables_from_rects, RectHintRegion};
pub use detect_struct::detect_tables_from_struct_tree;
pub use format::table_to_markdown;
pub use structured::{cells_to_markdown, StructuredCell};
use crate::types::TextItem;
/// Try to build a table from items + cluster rects (calendar-style layouts).
///
/// Uses rect X positions as column boundaries to directly construct a `Table`,
/// bypassing heuristic detection. Splits merged multi-number items first.
pub(crate) fn try_build_rect_guided_table(
items: &[TextItem],
cluster_rects: &[(f32, f32, f32, f32)],
) -> Option
{
if items.is_empty() || cluster_rects.is_empty() {
return None;
}
// 1. Derive column boundaries from rect X positions (snapped to 2pt tolerance)
let mut x_lefts: Vec = cluster_rects.iter().map(|&(x, _, _, _)| x).collect();
x_lefts.sort_by(|a, b| a.total_cmp(b));
// Snap: deduplicate within 2pt tolerance
let mut col_boundaries: Vec = Vec::new();
for x in &x_lefts {
if col_boundaries
.last()
.is_none_or(|last| (*x - *last).abs() > 2.0)
{
col_boundaries.push(*x);
}
}
if col_boundaries.len() < 5 {
return None;
}
// 1b. Interpolate missing boundaries: holidays/non-work days may not have
// rects, creating gaps. Fill gaps > 1.5× median spacing with evenly spaced
// boundaries so every day gets a column.
if col_boundaries.len() >= 2 {
let mut spacings: Vec = col_boundaries.windows(2).map(|w| w[1] - w[0]).collect();
spacings.sort_by(|a, b| a.total_cmp(b));
let median_spacing = spacings[spacings.len() / 2];
let threshold = median_spacing * 1.5;
let mut filled: Vec = vec![col_boundaries[0]];
for i in 1..col_boundaries.len() {
let gap = col_boundaries[i] - col_boundaries[i - 1];
if gap > threshold {
// Insert interpolated boundaries
let n = (gap / median_spacing).round() as usize;
if n >= 2 {
let step = gap / n as f32;
for j in 1..n {
filled.push(col_boundaries[i - 1] + j as f32 * step);
}
}
}
filled.push(col_boundaries[i]);
}
col_boundaries = filled;
}
// 2. Split merged multi-number items
let mut expanded_items: Vec<(TextItem, usize)> = Vec::new();
for (idx, item) in items.iter().enumerate() {
let splits = split_merged_numbers(item, &col_boundaries);
for split_item in splits {
expanded_items.push((split_item, idx));
}
}
// 3. Derive row boundaries from item Y positions (5pt tolerance)
let mut y_values: Vec = expanded_items.iter().map(|(item, _)| item.y).collect();
y_values.sort_by(|a, b| b.total_cmp(a)); // descending
let mut row_boundaries: Vec = Vec::new();
for y in &y_values {
if row_boundaries
.last()
.is_none_or(|last| (*last - *y).abs() > 5.0)
{
row_boundaries.push(*y);
}
}
if row_boundaries.is_empty() {
return None;
}
// 4. Assign items to cells
let n_rows = row_boundaries.len();
let n_cols = col_boundaries.len();
let mut cells: Vec> = vec![vec![String::new(); n_cols]; n_rows];
let mut used_indices: Vec = Vec::new();
// Compute max X to exclude legend text beyond the table area
let col_spacing = if col_boundaries.len() >= 2 {
(col_boundaries.last().unwrap() - col_boundaries.first().unwrap())
/ (col_boundaries.len() - 1) as f32
} else {
20.0
};
let max_x = col_boundaries.last().unwrap() + col_spacing * 1.5;
for (item, orig_idx) in &expanded_items {
// Skip items beyond the table's rightmost column (legend text)
if item.x > max_x {
continue;
}
// Find row (nearest Y within tolerance)
let row = row_boundaries
.iter()
.position(|&ry| (ry - item.y).abs() <= 5.0);
// Find column: rightmost boundary ≤ item.x + tolerance.
// 4pt tolerance catches annotation items (e.g. "Memorial Day") that sit
// slightly before the next column boundary.
let col = col_boundaries.iter().rposition(|&cx| item.x >= cx - 4.0);
if let (Some(r), Some(c)) = (row, col) {
let cell = &mut cells[r][c];
if !cell.is_empty() {
cell.push(' ');
}
cell.push_str(item.text.trim());
used_indices.push(*orig_idx);
}
}
// 5. Clean up: strip tilde-leader noise from cells (legend text bleeding
// into the last column from the right side of the page)
for row in &mut cells {
for cell in row.iter_mut() {
if let Some(pos) = cell.find("~~~") {
cell.truncate(pos);
*cell = cell.trim_end().to_string();
}
}
}
// 6. Validate: at least one row should have ≥ 5 non-empty cells
let best_row_fill = cells
.iter()
.map(|row| row.iter().filter(|c| !c.is_empty()).count())
.max()
.unwrap_or(0);
if best_row_fill < 5 {
return None;
}
// Deduplicate used indices
used_indices.sort_unstable();
used_indices.dedup();
Some(Table::new(
col_boundaries,
row_boundaries,
cells,
used_indices,
))
}
/// Split a TextItem whose text contains multiple whitespace-separated tokens
/// (like "10 11 12 ... 31") into individual TextItems, each assigned to the
/// nearest column boundary.
fn split_merged_numbers(item: &TextItem, col_boundaries: &[f32]) -> Vec {
let tokens: Vec<&str> = item.text.split_whitespace().collect();
if tokens.len() <= 1 {
return vec![item.clone()];
}
// Count consecutive leading numeric tokens (day numbers like "10 11 12")
let leading_numeric = tokens
.iter()
.take_while(|t| t.chars().all(|c| c.is_ascii_digit()))
.count();
// Need at least one leading number to split
if leading_numeric == 0 {
return vec![item.clone()];
}
let token_width = item.width / tokens.len() as f32;
let mut result = Vec::with_capacity(leading_numeric + 1);
// Find the enclosing column boundary (rightmost boundary ≤ item.x + 2pt),
// then advance through successive boundaries for each leading number.
// Using rposition avoids overshooting when item.x sits between boundaries.
let start_col = col_boundaries
.iter()
.rposition(|&cx| cx <= item.x + 2.0)
.unwrap_or(0);
// Split each leading numeric token into its own item at successive columns
for (i, token) in tokens.iter().enumerate().take(leading_numeric) {
let col_idx = start_col + i;
let snapped_x = if col_idx < col_boundaries.len() {
col_boundaries[col_idx]
} else {
// Fallback: distribute evenly if we run out of boundaries
let raw_x = item.x + i as f32 * token_width + token_width / 2.0;
col_boundaries
.iter()
.rev()
.find(|&&cx| cx <= raw_x + 2.0)
.copied()
.unwrap_or(raw_x)
};
result.push(TextItem {
text: token.to_string(),
x: snapped_x,
width: token_width,
y: item.y,
height: item.height,
font: item.font.clone(),
font_size: item.font_size,
page: item.page,
is_bold: item.is_bold,
is_italic: item.is_italic,
item_type: item.item_type.clone(),
mcid: item.mcid,
});
}
// Trailing non-numeric tokens become annotation placed at last numeric column
if leading_numeric < tokens.len() {
let annotation = tokens[leading_numeric..].join(" ");
let last_x = result.last().map(|i| i.x).unwrap_or(item.x);
result.push(TextItem {
text: annotation,
x: last_x,
width: token_width,
y: item.y,
height: item.height,
font: item.font.clone(),
font_size: item.font_size,
page: item.page,
is_bold: item.is_bold,
is_italic: item.is_italic,
item_type: item.item_type.clone(),
mcid: item.mcid,
});
}
result
}
/// Detection mode controls thresholds for table validation.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum TableDetectionMode {
/// Existing behavior: items with font size smaller than body text
SmallFont,
/// New: body-font items with stricter structural criteria
BodyFont,
}
/// Build a table from layout-detected column boundaries.
///
/// When the layout engine detects multiple tabular columns (not newspaper),
/// this function uses those boundaries to construct a Table directly. This
/// handles borderless tables (no rects/lines) where columns are defined
/// purely by text alignment — common in exam/reference tables.
///
/// Requires ≥3 columns, ≥3 rows, and ≥40% cell fill rate.
pub(crate) fn try_build_table_from_columns(items: &[TextItem], page: u32) -> Option {
use crate::extractor::{
detect_columns, group_into_lines_with_thresholds, is_newspaper_layout, ColumnRegion,
};
use std::collections::HashMap;
let mut columns = detect_columns(items, page, false);
if columns.len() < 4 {
return None;
}
// Refine columns: look for header-like rows where multiple items share
// the same Y and are evenly spaced. If a wide column contains two header
// items, split it at the gap between them.
let page_items: Vec<&TextItem> = items.iter().filter(|i| i.page == page).collect();
let y_tol = 3.0;
// Find the top-most row with items in multiple columns (likely the header)
let mut ys: Vec = page_items.iter().map(|i| i.y).collect();
ys.sort_by(|a, b| b.total_cmp(a));
ys.dedup_by(|a, b| (*a - *b).abs() < y_tol);
for &header_y in ys.iter().take(5) {
let row_items: Vec<&&TextItem> = page_items
.iter()
.filter(|i| (i.y - header_y).abs() < y_tol)
.collect();
if row_items.len() < columns.len() {
continue;
}
// Check if any column contains 2+ items at this Y — needs splitting
let mut new_columns = Vec::new();
let mut did_split = false;
for col in &columns {
let col_items: Vec<&&&TextItem> = row_items
.iter()
.filter(|i| i.x >= col.x_min && i.x < col.x_max)
.collect();
if col_items.len() >= 2 {
// Sort by X and find the split point
let mut sorted: Vec = col_items.iter().map(|i| i.x).collect();
sorted.sort_by(|a, b| a.total_cmp(b));
// Split at the midpoint between the two items
let split_x = (sorted[0]
+ col_items.iter().find(|i| i.x == sorted[0]).unwrap().width
+ sorted[1])
/ 2.0;
new_columns.push(ColumnRegion {
x_min: col.x_min,
x_max: split_x,
});
new_columns.push(ColumnRegion {
x_min: split_x,
x_max: col.x_max,
});
did_split = true;
} else {
new_columns.push(col.clone());
}
}
if did_split {
log::debug!(
"column refinement: {} -> {} columns from header row at y={:.1}",
columns.len(),
new_columns.len(),
header_y
);
columns = new_columns;
break;
}
}
// Group items into per-column lines to check newspaper vs tabular
let mut col_buckets: Vec> = vec![Vec::new(); columns.len()];
let mut spanning_items: Vec = Vec::new();
for item in items {
if item.page != page {
continue;
}
// Check if item spans multiple columns
let item_left = item.x;
let item_right = item.x + item.width;
let mut spans = 0;
for col in &columns {
let overlap = (item_right.min(col.x_max) - item_left.max(col.x_min)).max(0.0);
if overlap > 0.0 {
spans += 1;
}
}
if spans > 1 {
spanning_items.push(item.clone());
continue;
}
// Assign to best-overlap column
let mut best_col = 0;
let mut best_overlap = f32::NEG_INFINITY;
for (ci, col) in columns.iter().enumerate() {
let overlap = (item_right.min(col.x_max) - item_left.max(col.x_min)).max(0.0);
if overlap > best_overlap {
best_overlap = overlap;
best_col = ci;
}
}
col_buckets[best_col].push(item.clone());
}
let thresholds = HashMap::new();
let per_column_lines: Vec> = col_buckets
.iter()
.map(|bucket| {
group_into_lines_with_thresholds(
bucket.clone(),
&thresholds,
&std::collections::HashSet::new(),
)
})
.collect();
// Must be tabular (not newspaper) layout
if is_newspaper_layout(&per_column_lines, &columns) {
return None;
}
// Collect all unique Y positions across all columns (row boundaries)
let y_tol = 5.0;
let mut row_ys: Vec = Vec::new();
for col_lines in &per_column_lines {
for line in col_lines {
let y = line.y;
if !row_ys.iter().any(|&ry| (ry - y).abs() < y_tol) {
row_ys.push(y);
}
}
}
row_ys.sort_by(|a, b| b.total_cmp(a));
if row_ys.len() < 3 || row_ys.len() > 40 {
return None;
}
// Build cell grid
let col_xs: Vec = columns.iter().map(|c| c.x_min).collect();
let mut cells: Vec> = vec![vec![String::new(); columns.len()]; row_ys.len()];
let mut item_indices: Vec = Vec::new();
for (item_idx, item) in items.iter().enumerate() {
if item.page != page {
continue;
}
// Find column
let item_left = item.x;
let item_right = item.x + item.width;
let mut best_col = None;
let mut best_overlap = 0.0f32;
let mut span_count = 0;
for (ci, col) in columns.iter().enumerate() {
let overlap = (item_right.min(col.x_max) - item_left.max(col.x_min)).max(0.0);
if overlap > 0.0 {
span_count += 1;
}
if overlap > best_overlap {
best_overlap = overlap;
best_col = Some(ci);
}
}
if span_count > 1 || best_col.is_none() {
continue; // spanning item, skip
}
let col = best_col.unwrap();
// Find row
let row = row_ys.iter().position(|&ry| (ry - item.y).abs() < y_tol);
if let Some(row) = row {
if !cells[row][col].is_empty() {
cells[row][col].push(' ');
}
cells[row][col].push_str(&item.text);
item_indices.push(item_idx);
}
}
// Validate: need reasonable fill rate
let total_cells = row_ys.len() * columns.len();
let filled_cells = cells
.iter()
.flat_map(|r| r.iter())
.filter(|c| !c.trim().is_empty())
.count();
let fill_rate = filled_cells as f32 / total_cells as f32;
if fill_rate < 0.15 {
return None;
}
// Need at least 40% of rows to have content in 2+ columns
let multi_col_rows = cells
.iter()
.filter(|row| row.iter().filter(|c| !c.trim().is_empty()).count() >= 2)
.count();
// Need majority (>50%) of rows with content in 2+ columns
if multi_col_rows * 2 < row_ys.len() {
return None;
}
// Reject prose-like content: if cells are too long on average, this is
// a multi-column text layout, not a data table. Real table cells are
// typically short (≤ 40 chars). Prose paragraphs are much longer.
let cell_lengths: Vec = cells
.iter()
.flat_map(|r| r.iter())
.filter(|c| !c.trim().is_empty())
.map(|c| c.trim().len())
.collect();
if !cell_lengths.is_empty() {
let avg_cell_len = cell_lengths.iter().sum::() as f32 / cell_lengths.len() as f32;
if avg_cell_len > 40.0 {
return None;
}
// Reject if any significant number of cells are long prose (> 80 chars)
let long_cells = cell_lengths.iter().filter(|&&len| len > 80).count();
if long_cells as f32 / cell_lengths.len() as f32 > 0.10 {
return None;
}
}
// Reject when cells look like prose sentences: if too many cells contain
// sentence-ending punctuation (.!?:) it's prose text, not table data.
let prose_cells = cells
.iter()
.flat_map(|r| r.iter())
.filter(|c| {
let t = c.trim();
t.len() > 20
&& (t.ends_with('.') || t.ends_with('!') || t.ends_with('?') || t.ends_with(':'))
})
.count();
if filled_cells > 0 && prose_cells as f32 / filled_cells as f32 > 0.15 {
return None;
}
// Reject when most content is in one column (newspaper-like asymmetry).
// Count items per column; if any column has >60% of items, it's likely
// a body text column with side annotations, not a data table.
let mut items_per_col: Vec = vec![0; columns.len()];
for row in &cells {
for (ci, cell) in row.iter().enumerate() {
if !cell.trim().is_empty() {
items_per_col[ci] += 1;
}
}
}
let max_col_items = *items_per_col.iter().max().unwrap_or(&0);
if filled_cells > 0 && max_col_items as f32 / filled_cells as f32 > 0.60 {
return None;
}
log::debug!(
"column-based table: {} cols x {} rows, fill={:.0}%, multi_col_rows={}",
columns.len(),
row_ys.len(),
fill_rate * 100.0,
multi_col_rows
);
Some(Table::new(col_xs, row_ys, cells, item_indices))
}
/// What kind of structure a detected `Table` represents. Classification is
/// computed once at construction so consumers don't have to re-analyze the
/// cells (and stay consistent across detection backends).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TableKind {
/// A real data table — renders as markdown table syntax.
#[default]
Data,
/// A table of contents — renders as a flat list with tab-aligned page
/// numbers via `format_toc_as_list`. Detected through the table pipeline
/// because TOCs share row/column structure with tables, but they are not
/// data tables and shouldn't appear in `pages_with_tables` etc.
Toc,
}
/// A detected table.
#[derive(Debug, Clone)]
pub struct Table {
/// Column boundaries (x positions)
pub columns: Vec,
/// Row boundaries (y positions, descending order)
pub rows: Vec,
/// Cell contents indexed by (row, col)
pub cells: Vec>,
/// Items that belong to this table
pub item_indices: Vec,
/// Data table vs TOC. Set by `Table::new` from `cells`.
pub kind: TableKind,
}
impl Table {
/// Build a table and classify it (data vs TOC) from its cells.
pub fn new(
columns: Vec,
rows: Vec,
cells: Vec>,
item_indices: Vec,
) -> Self {
let kind = if is_table_of_contents(&cells) {
TableKind::Toc
} else {
TableKind::Data
};
Self {
columns,
rows,
cells,
item_indices,
kind,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ItemType, TextItem};
fn make_item(text: &str, x: f32, y: f32, font_size: f32) -> TextItem {
TextItem {
text: text.into(),
x,
y,
width: 10.0,
height: font_size,
font: "F1".into(),
font_size,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
mcid: None,
}
}
fn make_char(text: &str, x: f32, y: f32, font_size: f32, width: f32) -> TextItem {
TextItem {
text: text.into(),
x,
y,
width,
height: font_size,
font: "F1".into(),
font_size,
page: 1,
is_bold: false,
is_italic: false,
item_type: ItemType::Text,
mcid: None,
}
}
#[test]
fn test_table_detection() {
let items = vec![
// Header row
make_item("Subject", 100.0, 500.0, 8.0),
make_item("Q1", 200.0, 500.0, 8.0),
make_item("Q2", 280.0, 500.0, 8.0),
make_item("Q3", 360.0, 500.0, 8.0),
// Data row 1
make_item("Math", 100.0, 480.0, 8.0),
make_item("9.0", 200.0, 480.0, 8.0),
make_item("8.5", 280.0, 480.0, 8.0),
make_item("9.5", 360.0, 480.0, 8.0),
// Data row 2
make_item("Science", 100.0, 460.0, 8.0),
make_item("8.0", 200.0, 460.0, 8.0),
make_item("9.0", 280.0, 460.0, 8.0),
make_item("8.5", 360.0, 460.0, 8.0),
// Data row 3
make_item("English", 100.0, 440.0, 8.0),
make_item("9.5", 200.0, 440.0, 8.0),
make_item("9.0", 280.0, 440.0, 8.0),
make_item("9.5", 360.0, 440.0, 8.0),
];
let tables = detect_tables(&items, 10.0, false);
assert_eq!(tables.len(), 1);
assert_eq!(tables[0].columns.len(), 4);
assert_eq!(tables[0].rows.len(), 4);
}
#[test]
fn test_table_to_markdown() {
let table = Table {
columns: vec![100.0, 200.0],
rows: vec![500.0, 480.0],
cells: vec![
vec!["Header 1".into(), "Header 2".into()],
vec!["Cell 1".into(), "Cell 2".into()],
],
item_indices: vec![],
kind: TableKind::Data,
};
let md = table_to_markdown(&table);
assert!(md.contains("|Header 1|"));
assert!(md.contains("|---|"));
assert!(md.contains("|Cell 1|"));
}
#[test]
fn test_body_font_table_detected() {
let items = vec![
// Header row
make_item("Name", 100.0, 500.0, 10.0),
make_item("Price", 200.0, 500.0, 10.0),
make_item("Qty", 300.0, 500.0, 10.0),
make_item("Total", 400.0, 500.0, 10.0),
// Data row 1
make_item("Widget", 100.0, 480.0, 10.0),
make_item("5.00", 200.0, 480.0, 10.0),
make_item("10", 300.0, 480.0, 10.0),
make_item("50.00", 400.0, 480.0, 10.0),
// Data row 2
make_item("Gadget", 100.0, 460.0, 10.0),
make_item("12.50", 200.0, 460.0, 10.0),
make_item("4", 300.0, 460.0, 10.0),
make_item("50.00", 400.0, 460.0, 10.0),
// Data row 3
make_item("Gizmo", 100.0, 440.0, 10.0),
make_item("3.25", 200.0, 440.0, 10.0),
make_item("20", 300.0, 440.0, 10.0),
make_item("65.00", 400.0, 440.0, 10.0),
];
let tables = detect_tables(&items, 10.0, false);
assert_eq!(
tables.len(),
1,
"Body-font table should be detected by Pass 2"
);
assert_eq!(tables[0].columns.len(), 4);
assert!(tables[0].rows.len() >= 3);
}
#[test]
fn test_paragraph_not_falsely_detected() {
let items = vec![
make_item(
"This is a paragraph of text that spans the full width",
72.0,
500.0,
10.0,
),
make_item(
"of the page and should not be detected as a table.",
72.0,
485.0,
10.0,
),
make_item(
"It continues for several lines with normal body text",
72.0,
470.0,
10.0,
),
make_item(
"that is left-aligned and has no columnar structure.",
72.0,
455.0,
10.0,
),
make_item(
"The paragraph keeps going with more content here.",
72.0,
440.0,
10.0,
),
make_item(
"And it has even more text on this line as well.",
72.0,
425.0,
10.0,
),
make_item(
"Finally the paragraph concludes with this last line.",
72.0,
410.0,
10.0,
),
make_item(
"One more line to have enough items for detection.",
72.0,
395.0,
10.0,
),
make_item(
"And another line of plain paragraph text content.",
72.0,
380.0,
10.0,
),
make_item(
"Last line of the paragraph ends here for the test.",
72.0,
365.0,
10.0,
),
];
let tables = detect_tables(&items, 10.0, false);
assert_eq!(
tables.len(),
0,
"Single-column paragraph must not be detected as table"
);
}
#[test]
fn test_word_level_paragraph_not_detected_as_table() {
let items = vec![
// Line 1
make_item("We", 72.0, 500.0, 10.0),
make_item("would", 95.0, 500.0, 10.0),
make_item("like", 145.0, 500.0, 10.0),
make_item("to", 180.0, 500.0, 10.0),
make_item("thank", 200.0, 500.0, 10.0),
make_item("all", 250.0, 500.0, 10.0),
make_item("the", 278.0, 500.0, 10.0),
make_item("practitioners", 305.0, 500.0, 10.0),
// Line 2
make_item("and", 72.0, 485.0, 10.0),
make_item("researchers", 105.0, 485.0, 10.0),
make_item("across", 185.0, 485.0, 10.0),
make_item("the", 232.0, 485.0, 10.0),
make_item("University", 260.0, 485.0, 10.0),
make_item("of", 335.0, 485.0, 10.0),
make_item("Leeds", 355.0, 485.0, 10.0),
// Line 3
make_item("Libraries", 72.0, 470.0, 10.0),
make_item("whose", 142.0, 470.0, 10.0),
make_item("contributions", 190.0, 470.0, 10.0),
make_item("made", 290.0, 470.0, 10.0),
make_item("this", 328.0, 470.0, 10.0),
make_item("report", 360.0, 470.0, 10.0),
// Line 4
make_item("possible", 72.0, 455.0, 10.0),
make_item("Both", 140.0, 455.0, 10.0),
make_item("constituent", 178.0, 455.0, 10.0),
make_item("studies", 262.0, 455.0, 10.0),
make_item("were", 315.0, 455.0, 10.0),
make_item("approved", 350.0, 455.0, 10.0),
];
let tables = detect_tables(&items, 10.0, false);
assert_eq!(
tables.len(),
0,
"Word-level paragraph text must not be detected as table"
);
}
#[test]
fn test_large_data_table_not_rejected() {
let mut items = Vec::new();
// Header row
items.push(make_item("Temp", 100.0, 800.0, 8.0));
items.push(make_item("Pressure", 200.0, 800.0, 8.0));
items.push(make_item("Volume", 300.0, 800.0, 8.0));
items.push(make_item("Enthalpy", 400.0, 800.0, 8.0));
// 49 data rows
for i in 1..50 {
let y = 800.0 - (i as f32 * 12.0);
items.push(make_item(&format!("{}", -40 + i * 2), 100.0, y, 8.0));
items.push(make_item(
&format!("{:.1}", 100.0 + i as f32 * 5.0),
200.0,
y,
8.0,
));
items.push(make_item(
&format!("{:.3}", 0.05 + i as f32 * 0.01),
300.0,
y,
8.0,
));
items.push(make_item(
&format!("{:.1}", 150.0 + i as f32 * 2.5),
400.0,
y,
8.0,
));
}
let tables = detect_tables(&items, 10.0, false);
assert_eq!(tables.len(), 1, "Large data table should not be rejected");
assert!(
tables[0].rows.len() >= 40,
"Large table should preserve most rows, got {}",
tables[0].rows.len()
);
}
#[test]
fn test_uniform_spacing_rows_not_merged() {
let companies = [
"SC Priority LLC",
"Craft Roofing Co",
"Alpha Roofing Inc",
"Beta Construction",
"Gamma Builders",
"Delta Roofing",
"Epsilon Contractors",
];
let mut items = Vec::new();
// Header row at y=800
items.push(make_item("No.", 50.0, 800.0, 8.0));
items.push(make_item("Company", 120.0, 800.0, 8.0));
items.push(make_item("Bid Amount", 350.0, 800.0, 8.0));
// 7 data rows, each 10pt apart (exactly the old threshold)
for (i, company) in companies.iter().enumerate() {
let y = 790.0 - (i as f32 * 10.0);
items.push(make_item(&format!("{}", i + 1), 50.0, y, 8.0));
items.push(make_item(company, 120.0, y, 8.0));
items.push(make_item(&format!("${},000", 100 + i * 10), 350.0, y, 8.0));
}
let tables = detect_tables(&items, 12.0, false);
assert_eq!(tables.len(), 1, "Should detect one table");
assert_eq!(
tables[0].rows.len(),
8,
"Each company must be on its own row, got {} rows instead of 8",
tables[0].rows.len()
);
}
#[test]
fn test_merge_adjacent_items() {
let items = vec![
make_char("J", 310.0, 532.0, 13.3, 4.0),
make_char("u", 314.0, 532.0, 13.3, 4.4),
make_char("n", 318.4, 532.0, 13.3, 4.4),
make_char("e", 322.8, 532.0, 13.3, 3.5),
// word gap (2pt)
make_char("3", 328.3, 532.0, 13.3, 4.0),
make_char("0", 332.3, 532.0, 13.3, 4.0),
make_char(",", 336.3, 532.0, 13.3, 2.0),
// large column gap (40pt)
make_char("M", 378.3, 532.0, 13.3, 7.5),
make_char("a", 385.8, 532.0, 13.3, 4.0),
make_char("r", 389.8, 532.0, 13.3, 3.5),
];
let (merged, map) = detect_heuristic::merge_adjacent_items(&items);
assert_eq!(
merged.len(),
2,
"Should produce 2 merged items, got {}",
merged.len()
);
assert!(
merged[0].text.contains("June") && merged[0].text.contains("30"),
"First merged item should be 'June 30,' but got {:?}",
merged[0].text
);
assert_eq!(merged[1].text, "Mar");
assert_eq!(
map[0].len(),
7,
"First merged item should map to 7 original chars"
);
assert_eq!(
map[1].len(),
3,
"Second merged item should map to 3 original chars"
);
}
#[test]
fn test_per_char_financial_table_detected() {
let mut items = Vec::new();
// Per-character header row
for (i, c) in "Col1".chars().enumerate() {
items.push(make_char(
&c.to_string(),
300.0 + i as f32 * 5.0,
540.0,
13.0,
5.0,
));
}
for (i, c) in "Col2".chars().enumerate() {
items.push(make_char(
&c.to_string(),
400.0 + i as f32 * 5.0,
540.0,
13.0,
5.0,
));
}
for (i, c) in "Col3".chars().enumerate() {
items.push(make_char(
&c.to_string(),
500.0 + i as f32 * 5.0,
540.0,
13.0,
5.0,
));
}
// Data rows with multi-word items
let data = [
("Revenue", 520.0, "1,000", "2,000", "3,000"),
("Expenses", 505.0, "500", "800", "1,200"),
("Net Income", 490.0, "500", "1,200", "1,800"),
("Taxes", 475.0, "100", "200", "300"),
];
for (label, y, v1, v2, v3) in &data {
items.push(make_item(label, 50.0, *y, 12.0));
items.push(make_item(v1, 310.0, *y, 12.0));
items.push(make_item(v2, 410.0, *y, 12.0));
items.push(make_item(v3, 510.0, *y, 12.0));
}
let tables = detect_tables(&items, 13.0, false);
assert!(
!tables.is_empty(),
"Per-character financial table should be detected"
);
}
#[test]
fn test_short_subheader_not_merged_as_continuation() {
// Simulate a table with section sub-headers (like month names) that have
// an empty first column and short text in a single other column.
// These should NOT be merged into the previous row as continuation text.
let table = Table {
columns: vec![50.0, 150.0, 300.0, 450.0],
rows: vec![500.0, 480.0, 460.0, 440.0, 420.0, 400.0],
cells: vec![
// Header row
vec!["No.".into(), "Date".into(), "Title".into(), "Amount".into()],
// Sub-header: month name in 1 column, rest empty
vec!["".into(), "JAN".into(), "".into(), "".into()],
// Data row
vec!["1".into(), "8/1".into(), "Item A".into(), "100".into()],
vec!["2".into(), "15/1".into(), "Item B".into(), "200".into()],
// Another sub-header
vec!["".into(), "FEB".into(), "".into(), "".into()],
// Data row
vec!["3".into(), "5/2".into(), "Item C".into(), "300".into()],
],
item_indices: vec![],
kind: TableKind::Data,
};
let md = table_to_markdown(&table);
// JAN and FEB should be on their own rows, not merged into adjacent rows
assert!(
md.contains("|JAN|"),
"JAN should be on its own row, got:\n{}",
md
);
assert!(
md.contains("|FEB|"),
"FEB should be on its own row, got:\n{}",
md
);
// Verify they're NOT merged into data rows
assert!(
!md.contains("15/1 FEB"),
"FEB should not be merged into data row, got:\n{}",
md
);
assert!(
!md.contains("8/1 JAN"),
"JAN should not be merged into data row, got:\n{}",
md
);
}
// ── Rect-guided table builder tests ─────────────────────────────
#[test]
fn rect_guided_basic() {
// 7 column boundaries (like days of week), items "1"-"7" at matching X
let col_xs: Vec = (0..7).map(|i| 50.0 + i as f32 * 30.0).collect();
let cluster_rects: Vec<(f32, f32, f32, f32)> =
col_xs.iter().map(|&x| (x, 100.0, 28.0, 15.0)).collect();
let items: Vec = (1..=7)
.map(|i| make_item(&i.to_string(), col_xs[i - 1] + 2.0, 110.0, 7.0))
.collect();
let table = try_build_rect_guided_table(&items, &cluster_rects);
assert!(table.is_some(), "Should produce a table from 7 columns");
let table = table.unwrap();
assert_eq!(table.columns.len(), 7);
assert_eq!(table.rows.len(), 1);
for (i, cell) in table.cells[0].iter().enumerate() {
assert_eq!(cell, &(i + 1).to_string());
}
}
#[test]
fn rect_guided_split_merged() {
// One merged item "10 11 12" spanning 3 column boundaries
let col_xs: Vec = (0..7).map(|i| 50.0 + i as f32 * 30.0).collect();
let cluster_rects: Vec<(f32, f32, f32, f32)> =
col_xs.iter().map(|&x| (x, 100.0, 28.0, 15.0)).collect();
// Single items for cols 0-3, merged "4 5 6" spanning cols 4-6
let mut items = vec![
make_item("1", col_xs[0] + 2.0, 110.0, 7.0),
make_item("2", col_xs[1] + 2.0, 110.0, 7.0),
make_item("3", col_xs[2] + 2.0, 110.0, 7.0),
];
// Merged item spanning from col 3 to col 5 (width covers 3 columns)
let mut merged = make_item("4 5 6", col_xs[3], 110.0, 7.0);
merged.width = 3.0 * 30.0; // spans 3 column widths
items.push(merged);
let table = try_build_rect_guided_table(&items, &cluster_rects);
assert!(table.is_some(), "Should handle merged number items");
let table = table.unwrap();
// Check that "4", "5", "6" ended up in separate columns
let row = &table.cells[0];
assert!(
row.contains(&"4".to_string()),
"Should have '4' in a cell: {:?}",
row
);
assert!(
row.contains(&"5".to_string()),
"Should have '5' in a cell: {:?}",
row
);
assert!(
row.contains(&"6".to_string()),
"Should have '6' in a cell: {:?}",
row
);
}
#[test]
fn rect_guided_with_annotations() {
// Day numbers on one row, annotations on a second row
let col_xs: Vec = (0..7).map(|i| 50.0 + i as f32 * 30.0).collect();
let cluster_rects: Vec<(f32, f32, f32, f32)> =
col_xs.iter().map(|&x| (x, 100.0, 28.0, 15.0)).collect();
let mut items: Vec = (1..=7)
.map(|i| make_item(&i.to_string(), col_xs[i - 1] + 2.0, 115.0, 7.0))
.collect();
// Add annotation "Holiday" under day 4
items.push(make_item("Holiday", col_xs[3] + 2.0, 105.0, 6.0));
let table = try_build_rect_guided_table(&items, &cluster_rects);
assert!(table.is_some());
let table = table.unwrap();
assert_eq!(
table.rows.len(),
2,
"Should have 2 rows (days + annotations)"
);
// The annotation row should have "Holiday" in column 3
assert_eq!(table.cells[1][3], "Holiday");
}
#[test]
fn rect_guided_too_few_columns() {
// Only 3 column boundaries → should return None (need ≥ 5)
let cluster_rects = vec![
(50.0, 100.0, 28.0, 15.0),
(80.0, 100.0, 28.0, 15.0),
(110.0, 100.0, 28.0, 15.0),
];
let items = vec![
make_item("A", 52.0, 110.0, 7.0),
make_item("B", 82.0, 110.0, 7.0),
make_item("C", 112.0, 110.0, 7.0),
];
let table = try_build_rect_guided_table(&items, &cluster_rects);
assert!(table.is_none(), "Should reject fewer than 5 columns");
}
#[test]
fn split_merged_numbers_single_token() {
let col_boundaries = vec![50.0, 80.0, 110.0, 140.0, 170.0];
let item = make_item("Holiday", 52.0, 110.0, 7.0);
let result = split_merged_numbers(&item, &col_boundaries);
assert_eq!(result.len(), 1, "Single-token item should not be split");
assert_eq!(result[0].text, "Holiday");
}
#[test]
fn split_leading_numbers_with_annotation() {
// "11 Veterans Day" → "11" split off, "Veterans Day" as annotation
let col_boundaries = vec![50.0, 80.0, 110.0, 140.0, 170.0];
let mut item = make_item("11 Veterans Day", 110.0, 110.0, 7.0);
item.width = 90.0; // spans 3 tokens
let result = split_merged_numbers(&item, &col_boundaries);
assert_eq!(result.len(), 2, "Should split into number + annotation");
assert_eq!(result[0].text, "11");
assert_eq!(result[1].text, "Veterans Day");
}
#[test]
fn split_multiple_leading_numbers_with_annotation() {
// "24 25 Memorial Day" → "24", "25" split, "Memorial Day" trails
let col_xs: Vec = (0..7).map(|i| 50.0 + i as f32 * 30.0).collect();
let mut item = make_item("24 25 Memorial Day", col_xs[3], 110.0, 7.0);
item.width = 4.0 * 30.0; // spans 4 tokens
let result = split_merged_numbers(&item, &col_xs);
assert_eq!(result.len(), 3, "Should split into 2 numbers + annotation");
assert_eq!(result[0].text, "24");
assert_eq!(result[1].text, "25");
assert_eq!(result[2].text, "Memorial Day");
}
#[test]
fn split_no_leading_numbers() {
// "Memorial Day" → no leading numeric, returned as-is
let col_boundaries = vec![50.0, 80.0, 110.0, 140.0, 170.0];
let item = make_item("Memorial Day", 52.0, 110.0, 7.0);
let result = split_merged_numbers(&item, &col_boundaries);
assert_eq!(result.len(), 1);
assert_eq!(result[0].text, "Memorial Day");
}
#[test]
fn rect_guided_tilde_cleanup() {
// Items with tilde noise should have it stripped
let col_xs: Vec = (0..7).map(|i| 50.0 + i as f32 * 30.0).collect();
let cluster_rects: Vec<(f32, f32, f32, f32)> =
col_xs.iter().map(|&x| (x, 100.0, 28.0, 15.0)).collect();
let mut items: Vec = (1..=7)
.map(|i| make_item(&i.to_string(), col_xs[i - 1] + 2.0, 110.0, 7.0))
.collect();
// Day 7 has tilde-leader legend text bleeding in
items[6] = make_item("7 ~~~~~~~ Legend text here", col_xs[6] + 2.0, 110.0, 7.0);
let table = try_build_rect_guided_table(&items, &cluster_rects).unwrap();
assert_eq!(table.cells[0][6], "7", "Tilde noise should be stripped");
}
}