fix(columns): Read multi-column PDFs column-by-column instead of interleaving

Store column lines per-column instead of in a flat Vec, and use section-based
merge (spanning items define vertical zones) so each column is read top-to-bottom
before moving to the next column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-02-13 13:05:25 -08:00
co-authored by Claude Opus 4.6
parent d3d3f27451
commit d7b0149703
+28 -25
View File
@@ -2217,8 +2217,8 @@ pub fn group_into_lines(items: Vec<TextItem>) -> Vec<TextLine> {
}
}
// Process each column's items independently
let mut column_lines: Vec<TextLine> = Vec::new();
// Process each column's items independently, preserving column identity
let mut per_column_lines: Vec<Vec<TextLine>> = Vec::new();
for column in &columns {
let col_items: Vec<TextItem> = column_items
.iter()
@@ -2230,39 +2230,42 @@ pub fn group_into_lines(items: Vec<TextItem>) -> Vec<TextLine> {
.collect();
let lines = group_single_column(col_items);
column_lines.extend(lines);
per_column_lines.push(lines);
}
// Process spanning items as their own group
let spanning_lines = group_single_column(spanning_items);
// Merge: interleave spanning lines by Y position among column lines
let mut merged: Vec<TextLine> = Vec::new();
let mut span_idx = 0;
let mut col_idx = 0;
let mut spanning_lines = group_single_column(spanning_items);
// Sort spanning lines by Y descending (top-first in PDF coords)
let mut spanning_lines = spanning_lines;
spanning_lines
.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(std::cmp::Ordering::Equal));
while span_idx < spanning_lines.len() && col_idx < column_lines.len() {
// Higher Y = higher on page = comes first in reading order
if spanning_lines[span_idx].y >= column_lines[col_idx].y {
merged.push(spanning_lines[span_idx].clone());
span_idx += 1;
} else {
merged.push(column_lines[col_idx].clone());
col_idx += 1;
// 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());
}
while span_idx < spanning_lines.len() {
merged.push(spanning_lines[span_idx].clone());
span_idx += 1;
}
while col_idx < column_lines.len() {
merged.push(column_lines[col_idx].clone());
col_idx += 1;
// 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;
}
}
all_lines.extend(merged);