fix(extractor): Y-interleave multi-column merge for correct line breaks

Replace section-based column merge (which emitted all left-column lines
then all right-column lines) with Y-interleaved merge that combines
lines at the same Y position from different columns into a single line.

This fixes tabular layouts (like court dockets) where rows span across
columns — previously hearing descriptions were detached from their
entries and line breaks between rows were lost.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-02-17 13:26:50 -08:00
co-authored by Claude Opus 4.6
parent 342eb86d06
commit e403acd7f7
+36 -28
View File
@@ -3031,38 +3031,46 @@ pub fn group_into_lines(items: Vec<TextItem>) -> Vec<TextLine> {
}
// Process spanning items as their own group
let mut spanning_lines = group_single_column(spanning_items);
let spanning_lines = group_single_column(spanning_items);
// Sort spanning lines by Y descending (top-first in PDF coords)
spanning_lines
.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(std::cmp::Ordering::Equal));
// Section-based merge: spanning items define vertical sections.
// Within each section, emit all column lines (left-to-right, top-to-bottom)
// before emitting the spanning line.
let mut merged: Vec<TextLine> = Vec::new();
let mut col_cursors: Vec<usize> = vec![0; per_column_lines.len()];
for span_line in &spanning_lines {
let span_y = span_line.y;
// Emit all column lines above this spanning line, column by column
for (ci, col_lines) in per_column_lines.iter().enumerate() {
while col_cursors[ci] < col_lines.len()
&& col_lines[col_cursors[ci]].y >= span_y
{
merged.push(col_lines[col_cursors[ci]].clone());
col_cursors[ci] += 1;
}
}
merged.push(span_line.clone());
// Y-interleaved merge: combine all column lines and spanning lines,
// sort by Y position, and merge lines at the same Y from different
// columns into a single line. This correctly handles both tabular
// layouts (where rows span columns) and newspaper-style columns.
let mut all_page_lines: Vec<TextLine> = Vec::new();
all_page_lines.extend(spanning_lines);
for col_lines in per_column_lines {
all_page_lines.extend(col_lines);
}
// Emit remaining column lines below all spanning items, column by column
for (ci, col_lines) in per_column_lines.iter().enumerate() {
while col_cursors[ci] < col_lines.len() {
merged.push(col_lines[col_cursors[ci]].clone());
col_cursors[ci] += 1;
// Sort by Y descending (top-first), then by X for same-Y lines
all_page_lines.sort_by(|a, b| {
b.y.partial_cmp(&a.y)
.unwrap_or(std::cmp::Ordering::Equal)
.then(
a.items
.first()
.map(|i| i.x)
.unwrap_or(0.0)
.partial_cmp(&b.items.first().map(|i| i.x).unwrap_or(0.0))
.unwrap_or(std::cmp::Ordering::Equal),
)
});
// Merge lines at the same Y (within tolerance) into single lines
let y_tol = 3.0;
let mut merged: Vec<TextLine> = Vec::new();
for line in all_page_lines {
if let Some(last) = merged.last_mut() {
if last.page == line.page && (last.y - line.y).abs() < y_tol {
last.items.extend(line.items);
last.items.sort_by(|a, b| {
a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal)
});
continue;
}
}
merged.push(line);
}
all_lines.extend(merged);