fix: improve table row splitting for stacked sub-tables

Three fixes for better table extraction from spreadsheet-exported PDFs:

1. Convert thin filled rects (< 2pt) to PdfLine objects before line-based
   table detection. Many PDFs draw table borders as narrow filled rectangles
   instead of stroked paths — these were invisible to the line detector.

2. Relax uniform row spacing rejection (CV 0.05 → 0.02). Spreadsheet
   exports have very even row heights that were being rejected as "chart
   grids".

3. Fix continuation row merging: don't merge rows where the only non-first
   cell content is a long label (section headers like "Category No. 03").
   Don't merge first-cell-only rows with long text ("Note: ...").

Also adds multi-Y row splitting in line-based detection and column-aware
table detection skipping for multi-column pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-04-04 02:20:42 -07:00
co-authored by Claude Opus 4.6
parent 937311ce27
commit 1dcb0c6feb
4 changed files with 137 additions and 11 deletions
+13 -2
View File
@@ -107,11 +107,16 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
let looks_like_data_row = non_first_cells.len() >= 2
&& avg_cell_len <= 10.0
&& numeric_cells > non_first_cells.len() / 2;
// Classic continuation: first cell empty, content in other cells
// Classic continuation: first cell empty, content in other cells.
// Exclude rows where the non-first content is a long label (section
// header like "Category No. 03 - ...") — these are spanning headers,
// not overflow from the previous row.
let has_long_spanning_cell = non_first_cells.len() == 1 && non_first_cells[0].len() > 15;
let is_classic_continuation = first_cell.is_empty()
&& !non_first_cells.is_empty()
&& !is_short_subheader
&& !looks_like_data_row
&& !has_long_spanning_cell
&& cleaned.len() > 1;
// Wrapped-cell continuation: row has fewer filled cells than the header
@@ -137,11 +142,17 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
} else {
header_filled.saturating_sub(1)
};
// Don't merge rows where only the first cell has content and it's
// long text — these are section separators (e.g. "Note: ...") or
// section headers (e.g. "Category No. 03 - ..."), not overflow.
let is_first_cell_only = filled_cells == 1 && !first_cell.is_empty();
let first_cell_long = first_cell.len() > 15;
let is_wrapped_continuation = cleaned.len() > 1
&& filled_cells <= max_filled_for_merge
&& prev_filled > filled_cells
&& !looks_like_data_row
&& !is_short_subheader;
&& !is_short_subheader
&& !(is_first_cell_only && first_cell_long);
let is_continuation = is_classic_continuation || is_wrapped_continuation;