feat(layout): relative valley column detection for justified text (#15)

* feat(layout): relative valley column detection for justified text

Add fallback column detection using relative valley analysis for PDFs with
justified text where item widths extend past gutter boundaries. The absolute
valley detector fails on these layouts because gutter bins are at ~40% of
peak (well above the 15% noise threshold).

The relative valley detector smooths the histogram with a 5-bin moving
average, finds local minima where contrast < 0.60 of surrounding peaks,
and validates with peak balance >= 0.40. Limited to single best valley
(max 2 columns) and requires >= 100 items per page.

Tested on IRS Publication 17 (2002), a 289-page 2-column justified text
document: column detection went from ~40 pages to 165 pages.

190 passed, 0 regressions across 191 eval PDFs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(layout): tighten relative valley thresholds to reduce false positives

Reduce PEAK_WINDOW from 40 to 25 bins (50pt) so valleys are only validated
against nearby peaks, not distant ones. Add MIN_PEAK_HEIGHT of 20 (smoothed)
to reject sparse pages where histogram peaks are too low to indicate dense
two-column text.

Previous thresholds caused 13 regressions across the eval suite by splitting
tables, TOCs, checklists, and forms. Now: 188 passed, 0 regressions (2 minor
metadata-only diffs on IRS P17 and 9978293).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(layout): skip relative valley detection on pages with tables

Table column gaps in the histogram look identical to text column gutters
but the table pipeline already handles reading order for those pages.
Pass page_has_table flag through detect_columns to suppress the relative
valley fallback on pages where tables were detected.

This eliminates all remaining regressions from relative valley detection:
190 passed, 0 regressions across 191 eval PDFs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(layout): prose density validation for relative valley detection

Add columns_have_prose() to validate relative valley column splits.
Checks that both sides of a proposed split contain paragraph-like
content (fill ratio >= 40%, avg items/line <= 3.5) before committing
to a column split. Combined with the table-page guard, this prevents
false column splits on financial statements, forms, and tabular
layouts where long labels or dot leaders fill the column width.

Also tightens find_relative_valleys() thresholds (PEAK_WINDOW 40->25,
MIN_PEAK_HEIGHT 5->20) to reduce false positive valley candidates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-03-24 13:11:58 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3a15235244
commit 95aac6a7cd
5 changed files with 495 additions and 32 deletions
+4 -4
View File
@@ -913,7 +913,7 @@ mod tests {
items.push(make_item("Left text here", 72.0, y, 200.0));
items.push(make_item("Right text here", 350.0, y, 200.0));
}
let cols = detect_columns(&items, 1);
let cols = detect_columns(&items, 1, false);
assert_eq!(cols.len(), 2, "Expected 2 columns, got {:?}", cols);
assert!(cols[0].x_min < cols[1].x_min);
}
@@ -928,7 +928,7 @@ mod tests {
items.push(make_item("Col two", 220.0, y, 140.0));
items.push(make_item("Col three", 390.0, y, 140.0));
}
let cols = detect_columns(&items, 1);
let cols = detect_columns(&items, 1, false);
assert_eq!(cols.len(), 3, "Expected 3 columns, got {:?}", cols);
}
@@ -946,7 +946,7 @@ mod tests {
let y = 700.0 - (i as f32) * 14.0;
items.push(make_item("wide", 72.0, y, 320.0));
}
let cols = detect_columns(&items, 1);
let cols = detect_columns(&items, 1, false);
assert!(
cols.len() >= 2,
"Width bleed should not prevent column detection, got {:?}",
@@ -967,7 +967,7 @@ mod tests {
468.0,
));
}
let cols = detect_columns(&items, 1);
let cols = detect_columns(&items, 1, false);
assert!(
cols.len() <= 1,
"Full-width text should not be split into columns, got {:?}",