feat: convert thin filled rects to lines for table border detection

Many PDFs (especially spreadsheet exports) draw table borders as thin
filled rectangles (height/width < 2pt) instead of stroked paths. These
were invisible to our line-based table detector since only stroke
operations produced PdfLine objects.

Now synthesizes PdfLine from thin rects before line-based detection,
enabling table detection on border-drawn PDFs like government forms.

Also relaxes the uniform row spacing rejection threshold (CV 0.05→0.02)
to accept spreadsheet-exported tables with even row heights.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-04-04 02:12:05 -07:00
co-authored by Claude Opus 4.6
parent 9c463b9c95
commit 937311ce27
2 changed files with 58 additions and 4 deletions
+37 -1
View File
@@ -751,7 +751,43 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
// 2. Line-based detection on unclaimed items (when rects didn't find tables)
if rect_claimed.is_empty() {
let line_tables = detect_tables_from_lines(band_items, band_lines, page);
// Synthesize PdfLine objects from thin filled rects (border lines
// drawn as narrow rectangles instead of stroked paths).
let mut augmented_lines: Vec<crate::types::PdfLine> = band_lines.to_vec();
for r in band_rects {
let (mut w, mut h) = (r.width, r.height);
let (mut x, mut y) = (r.x, r.y);
if w < 0.0 {
x += w;
w = -w;
}
if h < 0.0 {
y += h;
h = -h;
}
if h < 2.0 && w >= 10.0 {
// Thin horizontal rect → horizontal line
let mid_y = y + h / 2.0;
augmented_lines.push(crate::types::PdfLine {
x1: x,
y1: mid_y,
x2: x + w,
y2: mid_y,
page,
});
} else if w < 2.0 && h >= 10.0 {
// Thin vertical rect → vertical line
let mid_x = x + w / 2.0;
augmented_lines.push(crate::types::PdfLine {
x1: mid_x,
y1: y,
x2: mid_x,
y2: y + h,
page,
});
}
}
let line_tables = detect_tables_from_lines(band_items, &augmented_lines, page);
for table in &line_tables {
for &idx in &table.item_indices {
rect_claimed.insert(idx);
+21 -3
View File
@@ -185,6 +185,11 @@ pub fn detect_tables_from_lines(items: &[TextItem], lines: &[PdfLine], page: u32
.filter(|row| row.iter().any(|cell| !cell.is_empty()))
.count();
if non_empty_rows < 2 {
log::debug!(
"detect_lines p{}: rejected — only {} non-empty rows",
page,
non_empty_rows
);
return Vec::new();
}
@@ -199,6 +204,11 @@ pub fn detect_tables_from_lines(items: &[TextItem], lines: &[PdfLine], page: u32
.count();
let density = filled_cells as f32 / total_cells as f32;
if density < 0.15 {
log::debug!(
"detect_lines p{}: rejected — low density {:.2}",
page,
density
);
return Vec::new();
}
}
@@ -243,9 +253,17 @@ pub fn detect_tables_from_lines(items: &[TextItem], lines: &[PdfLine], page: u32
.map(|s| (s - mean_spacing).powi(2))
.sum::<f32>()
/ spacings.len() as f32;
let cv = variance.sqrt() / mean_spacing; // coefficient of variation
// CV < 0.05 means nearly identical spacing — chart grid
if cv < 0.05 {
let cv = variance.sqrt() / mean_spacing;
// CV < 0.02 means nearly identical spacing — likely chart grid.
// Spreadsheet-exported tables often have uniform rows (CV 0.03-0.05),
// so we use a tighter threshold to avoid false negatives.
if cv < 0.02 {
log::debug!(
"detect_lines p{}: rejected — uniform row spacing (cv={:.4}, mean={:.1})",
page,
cv,
mean_spacing
);
return Vec::new();
}
}