feat: split stacked tables at rows without vertical borders

Rows that sit between horizontal rules but lack vertical border coverage
are not table cells — they're freestanding text (e.g. "Note: The cutoff
mark is out of 120"). These rows now split the grid into separate
sub-tables, with the unbounded text emitted as plain text between them.

Single-cell "tables" (from the split) render as plain text instead of
a degenerate 1x1 markdown table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-04-04 10:24:28 -07:00
co-authored by Claude Opus 4.6
parent 0300e96b7d
commit 999f9a2f2d
2 changed files with 83 additions and 7 deletions
+72 -7
View File
@@ -286,14 +286,79 @@ pub fn detect_tables_from_lines(items: &[TextItem], lines: &[PdfLine], page: u32
// text and the next section's "Category" header land between the same
// pair of horizontal rules. Re-assign items to sub-rows by Y proximity.
let cells = split_multi_y_rows(cells, items, &col_edges, &row_edges_desc, page);
let num_rows = cells.len();
vec![Table {
columns: col_edges,
rows: row_edges_desc[..num_rows.min(row_edges_desc.len())].to_vec(),
cells,
item_indices,
}]
// Split the grid into separate tables at rows that lack vertical border
// coverage (e.g. "Note:" footer text that sits between horizontal rules
// but outside the actual table grid). A row is "unbounded" when fewer
// than 2 vertical lines span its Y range — it's freestanding text, not
// a table cell.
let mut tables = Vec::new();
let mut current_rows: Vec<Vec<String>> = Vec::new();
for (r, row) in cells.into_iter().enumerate() {
// Determine Y range for this row
let row_top = if r < row_edges_desc.len() {
row_edges_desc[r]
} else {
row_edges_desc.last().copied().unwrap_or(0.0)
};
let row_bot = if r + 1 < row_edges_desc.len() {
row_edges_desc[r + 1]
} else {
row_edges_desc.last().copied().unwrap_or(0.0) - 15.0
};
// Count vertical lines that span this row's Y range
let v_covering = verticals
.iter()
.filter(|(_, y_min, y_max)| *y_min <= row_bot + 2.0 && *y_max >= row_top - 2.0)
.count();
if v_covering >= 2 {
current_rows.push(row);
} else {
// Flush accumulated rows as a table
if current_rows.len() >= 2 {
tables.push(Table {
columns: col_edges.clone(),
rows: Vec::new(),
cells: std::mem::take(&mut current_rows),
item_indices: item_indices.clone(),
});
} else {
current_rows.clear();
}
// The unbounded row's text becomes a standalone "table" with 1 row
// so it gets emitted as text outside the table.
let text = row
.iter()
.map(|c| c.trim())
.filter(|c| !c.is_empty())
.collect::<Vec<_>>()
.join(" ");
if !text.is_empty() {
// Emit as a 1-cell table which the markdown converter will
// render as a standalone line (single-row tables are just text).
tables.push(Table {
columns: vec![col_edges[0], *col_edges.last().unwrap_or(&col_edges[0])],
rows: Vec::new(),
cells: vec![vec![text]],
item_indices: item_indices.clone(),
});
}
}
}
// Flush remaining rows
if current_rows.len() >= 2 {
tables.push(Table {
columns: col_edges,
rows: Vec::new(),
cells: current_rows,
item_indices,
});
}
tables
}
/// Split table rows where items within cells span multiple Y positions.
+11
View File
@@ -15,6 +15,17 @@ pub fn table_to_markdown(table: &Table) -> String {
}
let num_cols = cleaned_cells[0].len();
// Single-cell "tables" are standalone text, not real tables.
// Emit as plain text (e.g. "Note: ..." between sub-tables).
if cleaned_cells.len() == 1 && num_cols == 1 {
let text = cleaned_cells[0][0].trim();
if !text.is_empty() {
return format!("{}\n", text);
}
return String::new();
}
let mut output = String::new();
// Compact format: no padding, minimal separators. Optimized for token