fix(tables): Handle vertically-merged cells and protect header from continuation merge

Rects spanning multiple grid rows (e.g. classification labels) now have
their text consolidated into the first sub-row via propagate_merged_cells().
Also prevent continuation-row merge from folding body data into the header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-02-18 16:40:57 -08:00
co-authored by Claude Opus 4.6
parent 28313e1f2d
commit b6828fff8c
4 changed files with 76 additions and 14 deletions
+63 -1
View File
@@ -242,7 +242,11 @@ pub(crate) fn detect_table_from_rect_group(
}
// Build table: assign text items to cells
let (cells, item_indices) = assign_items_to_grid(items, &col_edges, &row_edges, page);
let (mut cells, item_indices) = assign_items_to_grid(items, &col_edges, &row_edges, page);
// Consolidate vertically-merged cells: rects spanning multiple grid rows
// should have their text collected into the first sub-row.
propagate_merged_cells(&mut cells, &col_edges, &row_edges, group_rects);
// Compute column centers and row centers for the Table struct
let columns: Vec<f32> = (0..num_cols)
@@ -385,3 +389,61 @@ pub(crate) fn assign_items_to_grid(
(cells, indices)
}
/// Consolidate text in vertically-merged cells.
///
/// When a single rect spans multiple grid rows (e.g. a "Classification" label
/// covering several price sub-rows), text ends up in only one sub-row while the
/// others have an empty cell. This function detects such spans and moves all
/// text into the first sub-row, clearing the rest so that downstream
/// continuation-merge in `clean_table_cells` collapses sub-rows correctly.
fn propagate_merged_cells(
cells: &mut [Vec<String>],
col_edges: &[f32],
row_edges: &[f32],
group_rects: &[(f32, f32, f32, f32)],
) {
let num_cols = col_edges.len() - 1;
let num_rows = row_edges.len() - 1;
let tol = 6.0;
for col in 0..num_cols {
for rect in group_rects {
let (rx, ry, rw, rh) = *rect;
// Rect must cover this column
if rx > col_edges[col] + tol || (rx + rw) < col_edges[col + 1] - tol {
continue;
}
// Find first and last grid rows that the rect spans
let first_row = (0..num_rows)
.find(|&r| ry <= row_edges[r] + tol && (ry + rh) >= row_edges[r + 1] - tol);
let last_row = (0..num_rows)
.rfind(|&r| ry <= row_edges[r] + tol && (ry + rh) >= row_edges[r + 1] - tol);
let (first, last) = match (first_row, last_row) {
(Some(f), Some(l)) if l > f => (f, l),
_ => continue, // Single row or no match — skip
};
// Collect all text from sub-rows within the merged range
let mut combined = String::new();
for row in cells.iter().take(last + 1).skip(first) {
let text = row[col].trim();
if !text.is_empty() {
if !combined.is_empty() {
combined.push(' ');
}
combined.push_str(text);
}
}
// Place combined text in the first sub-row, clear the rest
cells[first][col] = combined;
for row in cells.iter_mut().take(last + 1).skip(first + 1) {
row[col] = String::new();
}
}
}
}
+1 -1
View File
@@ -88,7 +88,7 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
// Check if this is a continuation row (first column is empty but others have content)
let is_continuation = first_cell.is_empty()
&& row.iter().skip(1).any(|c| !c.trim().is_empty())
&& !cleaned.is_empty();
&& cleaned.len() > 1; // Don't merge into the first row (header)
if is_continuation {
// Merge with previous row