fix(columns): Improve multi-column layout handling

Replace center-point column assignment with maximum horizontal overlap
to prevent gutter mis-assignment. Add large-gap guards in should_join_items
to block cross-column word merging. Widen spacing threshold (0.15→0.18)
only for lowercase→lowercase junctions to fix mid-word space injection
in CID fonts while preserving word boundaries in all-caps headings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-02-14 23:54:52 -08:00
co-authored by Claude Opus 4.6
parent f77cbe9808
commit 2b352e3d31
+53 -11
View File
@@ -686,6 +686,11 @@ fn should_join_items(prev_item: &TextItem, curr_item: &TextItem) -> bool {
let gap = curr_item.x - prev_end_x;
let font_size = prev_item.font_size;
// Never join across column-scale gaps
if gap > font_size * 3.0 {
return false;
}
// When items perfectly touch (gap ≈ 0) and both are multi-character,
// they are likely separate words from different text operators (CID fonts).
// Single-character items (per-glyph positioning) should still be joined.
@@ -777,6 +782,29 @@ fn should_join_items(prev_item: &TextItem, curr_item: &TextItem) -> bool {
// With accurate widths, a gap < 15% of font size means glyphs are
// adjacent (same word). Anything larger is a deliberate space.
// For multi-char items with a lowercase→lowercase junction, use a
// slightly wider threshold (0.18) to avoid mid-word space injection
// with imprecise CID font metrics (e.g. "enterta"+"inment").
// All-caps or mixed-case junctions keep the tighter 0.15 threshold
// to preserve word boundaries (e.g. "LCOE"+"WITH").
if prev_item.text.trim().chars().count() >= 2 && curr_item.text.trim().chars().count() >= 2
{
let prev_ends_lower = prev_item
.text
.trim()
.chars()
.last()
.map_or(false, |c| c.is_lowercase());
let curr_starts_lower = curr_item
.text
.trim()
.chars()
.next()
.map_or(false, |c| c.is_lowercase());
if prev_ends_lower && curr_starts_lower {
return gap < font_size * 0.18;
}
}
return gap < font_size * 0.15;
}
@@ -792,6 +820,11 @@ fn should_join_items(prev_item: &TextItem, curr_item: &TextItem) -> bool {
// Calculate gap between items
let gap = curr_item.x - prev_end_x;
// Never join across column-scale gaps (fallback path)
if gap > char_width * 6.0 {
return false;
}
// CJK text: always join adjacent items — CJK languages don't use spaces between words.
// The Latin case-based heuristics below would incorrectly insert spaces within CJK words.
let is_cjk = prev_last.is_some_and(is_cjk_char) || curr_first.is_some_and(is_cjk_char);
@@ -2261,18 +2294,27 @@ pub fn group_into_lines(items: Vec<TextItem>) -> Vec<TextLine> {
}
}
// 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()
.filter(|i| {
let center = i.x + effective_width(i) / 2.0;
center >= column.x_min && center < column.x_max
})
.cloned()
.collect();
// Process each column's items independently, preserving column identity.
// Assign each item to the column with greatest horizontal overlap
// (instead of center-point) to avoid gutter mis-assignment.
let mut col_buckets: Vec<Vec<TextItem>> = vec![Vec::new(); columns.len()];
for item in &column_items {
let item_left = item.x;
let item_right = item.x + effective_width(item);
let mut best_col = 0;
let mut best_overlap = f32::NEG_INFINITY;
for (ci, col) in columns.iter().enumerate() {
let overlap = (item_right.min(col.x_max) - item_left.max(col.x_min)).max(0.0);
if overlap > best_overlap {
best_overlap = overlap;
best_col = ci;
}
}
col_buckets[best_col].push(item.clone());
}
let mut per_column_lines: Vec<Vec<TextLine>> = Vec::new();
for col_items in col_buckets {
let lines = group_single_column(col_items);
per_column_lines.push(lines);
}