use std::collections::{HashMap, HashSet};
use crate::text_utils::{effective_width, sort_line_items};
use crate::types::{TextItem, TextLine};
use log::debug;
#[derive(Debug, Clone)]
pub(crate) struct ColumnRegion {
pub(crate) x_min: f32,
pub(crate) x_max: f32,
}
pub(crate) fn detect_columns(
items: &[TextItem],
page: u32,
page_has_table: bool,
) -> Vec<ColumnRegion> {
const BIN_WIDTH: f32 = 2.0;
const MIN_GUTTER_WIDTH: f32 = 8.0;
const MIN_VERTICAL_SPAN_RATIO: f32 = 0.30;
const MIN_ITEMS_PER_COLUMN: usize = 10;
const NOISE_FRACTION: f32 = 0.15;
let page_items: Vec<&TextItem> = items
.iter()
.filter(|i| i.page == page && crate::extractor::is_text_layout_item(i))
.collect();
if page_items.is_empty() {
return vec![];
}
debug!("page {}: detect_columns: {} items", page, page_items.len());
let x_min = page_items.iter().map(|i| i.x).fold(f32::INFINITY, f32::min);
let x_max = page_items
.iter()
.map(|i| i.x + effective_width(i))
.fold(f32::NEG_INFINITY, f32::max);
let page_width = x_max - x_min;
if page_width < 200.0 {
return vec![ColumnRegion { x_min, x_max }];
}
if page_items.len() < 20 {
return vec![ColumnRegion { x_min, x_max }];
}
let wide_threshold = page_width * 0.6;
let num_bins = ((page_width / BIN_WIDTH).ceil() as usize).max(1);
let mut histogram = vec![0u32; num_bins];
for item in &page_items {
let w = effective_width(item);
if w > wide_threshold {
continue;
}
let left = ((item.x - x_min) / BIN_WIDTH).floor() as usize;
let right = (((item.x + w) - x_min) / BIN_WIDTH).ceil() as usize;
let left = left.min(num_bins);
let right = right.min(num_bins);
for count in histogram.iter_mut().take(right).skip(left) {
*count += 1;
}
}
let max_count = *histogram.iter().max().unwrap_or(&0);
let noise_threshold = (max_count as f32 * NOISE_FRACTION) as u32;
let mut valleys: Vec<(usize, usize)> = Vec::new();
let mut valley_start: Option<usize> = None;
for (i, &count) in histogram.iter().enumerate() {
if count <= noise_threshold {
if valley_start.is_none() {
valley_start = Some(i);
}
} else if let Some(start) = valley_start {
valleys.push((start, i));
valley_start = None;
}
}
if let Some(start) = valley_start {
valleys.push((start, num_bins));
}
let margin_threshold = page_width * 0.05;
let valleys: Vec<(usize, usize)> = valleys
.into_iter()
.filter(|&(start, end)| {
let width_pts = (end - start) as f32 * BIN_WIDTH;
if width_pts < MIN_GUTTER_WIDTH {
return false;
}
let center_pts = ((start + end) as f32 / 2.0) * BIN_WIDTH;
center_pts > margin_threshold && center_pts < (page_width - margin_threshold)
})
.collect();
if valleys.is_empty() && page_items.len() >= 100 && !page_has_table {
let rel_valleys = find_relative_valleys(
&histogram,
num_bins,
x_min,
BIN_WIDTH,
page_width,
margin_threshold,
);
if !rel_valleys.is_empty() {
let result = validate_and_build_columns(
&rel_valleys,
&page_items,
x_min,
BIN_WIDTH,
x_max,
MIN_ITEMS_PER_COLUMN,
MIN_VERTICAL_SPAN_RATIO,
page,
true, );
if result.len() > 1 {
if columns_have_prose(&result, &page_items) {
debug!(
"page {}: relative valley detection found {} columns",
page,
result.len()
);
return result;
} else {
debug!(
"page {}: relative valley rejected — columns lack prose density",
page,
);
}
}
}
if let Some(columns) = try_xy_cut_split(&page_items, x_min, x_max, page) {
return columns;
}
return vec![ColumnRegion { x_min, x_max }];
}
let result = validate_and_build_columns(
&valleys,
&page_items,
x_min,
BIN_WIDTH,
x_max,
MIN_ITEMS_PER_COLUMN,
MIN_VERTICAL_SPAN_RATIO,
page,
true, );
if result.len() > 1 {
return result;
}
let result = validate_and_build_columns(
&valleys,
&page_items,
x_min,
BIN_WIDTH,
x_max,
MIN_ITEMS_PER_COLUMN,
MIN_VERTICAL_SPAN_RATIO,
page,
false, );
if result.len() > 1 {
return result;
}
if page_items.len() >= 20 && !page_has_table {
if let Some(columns) = try_xy_cut_split(&page_items, x_min, x_max, page) {
return columns;
}
}
vec![ColumnRegion { x_min, x_max }]
}
fn try_xy_cut_split(
page_items: &[&TextItem],
page_x_min: f32,
page_x_max: f32,
page: u32,
) -> Option<Vec<ColumnRegion>> {
const MIN_GAP: f32 = 15.0; const MIN_ITEMS_MAJOR: usize = 10; const MIN_ITEMS_MINOR: usize = 3;
let page_width = page_x_max - page_x_min;
if page_width < 200.0 {
return None;
}
let mut edges: Vec<(f32, f32)> = page_items
.iter()
.map(|i| (i.x, i.x + effective_width(i)))
.collect();
edges.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut left_edges: Vec<f32> = page_items.iter().map(|i| i.x).collect();
left_edges.sort_by(|a, b| a.total_cmp(b));
let mut sorted_by_left: Vec<(f32, f32)> = page_items
.iter()
.map(|i| (i.x, i.x + effective_width(i)))
.collect();
sorted_by_left.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut best_gap = 0.0f32;
let mut best_split = 0.0f32;
let mut max_right_so_far = f32::NEG_INFINITY;
for i in 0..sorted_by_left.len() - 1 {
let (_, right) = sorted_by_left[i];
max_right_so_far = max_right_so_far.max(right);
let (next_left, _) = sorted_by_left[i + 1];
let gap = next_left - max_right_so_far;
if gap > best_gap {
best_gap = gap;
best_split = (max_right_so_far + next_left) / 2.0;
}
}
if best_gap < MIN_GAP {
return None;
}
let margin = page_width * 0.10;
if best_split - page_x_min < margin || page_x_max - best_split < margin {
return None;
}
let left_count = page_items
.iter()
.filter(|i| i.x + effective_width(i) / 2.0 <= best_split)
.count();
let right_count = page_items
.iter()
.filter(|i| i.x + effective_width(i) / 2.0 > best_split)
.count();
let (minor, major) = if left_count <= right_count {
(left_count, right_count)
} else {
(right_count, left_count)
};
if major < MIN_ITEMS_MAJOR || minor < MIN_ITEMS_MINOR {
return None;
}
let left_items: Vec<&&TextItem> = page_items
.iter()
.filter(|i| i.x + effective_width(i) / 2.0 <= best_split)
.collect();
let right_items: Vec<&&TextItem> = page_items
.iter()
.filter(|i| i.x + effective_width(i) / 2.0 > best_split)
.collect();
let l_y_min = left_items.iter().map(|i| i.y).fold(f32::INFINITY, f32::min);
let l_y_max = left_items
.iter()
.map(|i| i.y)
.fold(f32::NEG_INFINITY, f32::max);
let r_y_min = right_items
.iter()
.map(|i| i.y)
.fold(f32::INFINITY, f32::min);
let r_y_max = right_items
.iter()
.map(|i| i.y)
.fold(f32::NEG_INFINITY, f32::max);
let overlap_min = l_y_min.max(r_y_min);
let overlap_max = l_y_max.min(r_y_max);
let overlap = (overlap_max - overlap_min).max(0.0);
let y_range = (l_y_max.max(r_y_max) - l_y_min.min(r_y_min)).max(1.0);
if overlap / y_range < 0.20 {
return None;
}
debug!(
"page {}: XY-cut split at x={:.1} (gap={:.1}pt, left={}, right={})",
page, best_split, best_gap, left_count, right_count
);
Some(vec![
ColumnRegion {
x_min: page_x_min,
x_max: best_split,
},
ColumnRegion {
x_min: best_split,
x_max: page_x_max,
},
])
}
fn columns_have_prose(columns: &[ColumnRegion], items: &[&TextItem]) -> bool {
const Y_TOL: f32 = 3.0; const LINE_FILL_THRESHOLD: f32 = 0.45; const MIN_PROSE_RATIO: f32 = 0.40; const MIN_LINES: usize = 8; const MIN_COL_WIDTH: f32 = 120.0; const MAX_AVG_ITEMS_PER_LINE: f32 = 3.5;
for col in columns {
let col_width = col.x_max - col.x_min;
if col_width < MIN_COL_WIDTH {
return false;
}
let col_items: Vec<&TextItem> = items
.iter()
.filter(|i| {
let center = i.x + effective_width(i) / 2.0;
center >= col.x_min && center <= col.x_max
})
.copied()
.collect();
if col_items.len() < MIN_LINES {
return false;
}
let mut sorted: Vec<&TextItem> = col_items;
sorted.sort_by(|a, b| b.y.total_cmp(&a.y));
let mut full_lines = 0usize;
let mut total_lines = 0usize;
let mut total_items_in_lines = 0usize;
let mut line_items: Vec<&TextItem> = Vec::new();
let mut line_y = f32::NAN;
let flush_line = |line_items: &[&TextItem],
full: &mut usize,
total: &mut usize,
total_items: &mut usize| {
if line_items.is_empty() {
return;
}
*total += 1;
*total_items += line_items.len();
let left = line_items
.iter()
.map(|i| i.x.max(col.x_min))
.fold(f32::INFINITY, f32::min);
let right = line_items
.iter()
.map(|i| (i.x + effective_width(i)).min(col.x_max))
.fold(f32::NEG_INFINITY, f32::max);
let span = (right - left).max(0.0);
if span >= col_width * LINE_FILL_THRESHOLD {
*full += 1;
}
};
for item in &sorted {
if line_items.is_empty() || (line_y - item.y).abs() < Y_TOL {
if line_items.is_empty() {
line_y = item.y;
}
line_items.push(item);
} else {
flush_line(
&line_items,
&mut full_lines,
&mut total_lines,
&mut total_items_in_lines,
);
line_items.clear();
line_y = item.y;
line_items.push(item);
}
}
flush_line(
&line_items,
&mut full_lines,
&mut total_lines,
&mut total_items_in_lines,
);
if total_lines < MIN_LINES {
return false;
}
let ratio = full_lines as f32 / total_lines as f32;
let avg_items = total_items_in_lines as f32 / total_lines as f32;
debug!(
"columns_have_prose: col [{:.0}..{:.0}] lines={} full={} ratio={:.2} avg_items={:.1}",
col.x_min, col.x_max, total_lines, full_lines, ratio, avg_items
);
if ratio < MIN_PROSE_RATIO {
return false;
}
if avg_items > MAX_AVG_ITEMS_PER_LINE {
return false;
}
}
true
}
fn find_relative_valleys(
histogram: &[u32],
num_bins: usize,
_x_min: f32,
bin_width: f32,
page_width: f32,
margin_threshold: f32,
) -> Vec<(usize, usize)> {
const MIN_GUTTER_BINS: usize = 2; const CONTRAST_THRESHOLD: f32 = 0.60; const PEAK_WINDOW: usize = 25; const MIN_PEAK_HEIGHT: f32 = 20.0;
if num_bins < 10 {
return vec![];
}
let mut smoothed = vec![0.0f32; num_bins];
let half_win = 2usize;
for (i, s) in smoothed.iter_mut().enumerate().take(num_bins) {
let lo = i.saturating_sub(half_win);
let hi = (i + half_win + 1).min(num_bins);
let sum: u32 = histogram[lo..hi].iter().sum();
*s = sum as f32 / (hi - lo) as f32;
}
let mut candidates: Vec<(usize, f32, f32)> = Vec::new();
for i in PEAK_WINDOW..num_bins.saturating_sub(PEAK_WINDOW) {
let val = smoothed[i];
if val < 1.0 {
continue; }
let local_lo = i.saturating_sub(3);
let local_hi = (i + 4).min(num_bins);
let is_local_min = (local_lo..local_hi).all(|j| smoothed[j] >= val - 0.5);
if !is_local_min {
continue;
}
let left_peak = smoothed[i.saturating_sub(PEAK_WINDOW)..i]
.iter()
.cloned()
.fold(0.0f32, f32::max);
let right_peak = smoothed[(i + 1)..(i + 1 + PEAK_WINDOW).min(num_bins)]
.iter()
.cloned()
.fold(0.0f32, f32::max);
if left_peak < MIN_PEAK_HEIGHT || right_peak < MIN_PEAK_HEIGHT {
continue;
}
let peak_balance = left_peak.min(right_peak) / left_peak.max(right_peak);
if peak_balance < 0.40 {
continue;
}
let ref_peak = left_peak.min(right_peak);
let contrast = val / ref_peak;
if contrast < CONTRAST_THRESHOLD {
let center_pts = i as f32 * bin_width;
if center_pts > margin_threshold && center_pts < (page_width - margin_threshold) {
candidates.push((i, val, contrast));
}
}
}
if candidates.is_empty() {
return vec![];
}
let mut valleys: Vec<(usize, usize)> = Vec::new();
let mut best_bin = candidates[0].0;
let mut best_contrast = candidates[0].2;
for window in candidates.windows(2) {
let (prev_bin, _, _) = window[0];
let (next_bin, _, next_contrast) = window[1];
if next_bin - prev_bin <= 5 {
if next_contrast < best_contrast {
best_bin = next_bin;
best_contrast = next_contrast;
}
} else {
let half = MIN_GUTTER_BINS;
valleys.push((
best_bin.saturating_sub(half),
(best_bin + half + 1).min(num_bins),
));
best_bin = next_bin;
best_contrast = next_contrast;
}
}
let half = MIN_GUTTER_BINS;
valleys.push((
best_bin.saturating_sub(half),
(best_bin + half + 1).min(num_bins),
));
if valleys.len() > 1 {
let mut best_idx = 0;
let mut best_c = f32::MAX;
for (vi, v) in valleys.iter().enumerate() {
let mid = (v.0 + v.1) / 2;
if let Some(c) = candidates
.iter()
.filter(|(b, _, _)| (*b as isize - mid as isize).unsigned_abs() <= 5)
.map(|(_, _, c)| *c)
.reduce(f32::min)
{
if c < best_c {
best_c = c;
best_idx = vi;
}
}
}
return vec![valleys[best_idx]];
}
valleys
}
fn is_list_marker_column(items: &[&&TextItem]) -> bool {
const LIST_MARKERS: &[char] = &['•', '●', '○', '◦', '▪', '▫', '◆', '◇', '■', '□'];
if items.is_empty() {
return false;
}
let marker_count = items
.iter()
.filter(|i| {
let t = i.text.trim();
let mut chars = t.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => LIST_MARKERS.contains(&c),
_ => false,
}
})
.count();
marker_count as f32 / items.len() as f32 >= 0.8
}
#[allow(clippy::too_many_arguments)]
fn validate_and_build_columns(
valleys: &[(usize, usize)],
page_items: &[&TextItem],
x_min: f32,
bin_width: f32,
x_max: f32,
min_items: usize,
min_vertical_span: f32,
page: u32,
center_assign: bool,
) -> Vec<ColumnRegion> {
let x_span = page_items
.iter()
.map(|i| i.x + effective_width(i))
.fold(f32::NEG_INFINITY, f32::max)
- page_items.iter().map(|i| i.x).fold(f32::INFINITY, f32::min);
let narrow: Vec<&&TextItem> = page_items
.iter()
.filter(|i| effective_width(i) <= x_span * 0.6)
.collect();
let span_items: &[&&TextItem] = if narrow.is_empty() { &[] } else { &narrow };
let y_min = span_items.iter().map(|i| i.y).fold(f32::INFINITY, f32::min);
let y_max = span_items
.iter()
.map(|i| i.y)
.fold(f32::NEG_INFINITY, f32::max);
let y_range = y_max - y_min;
let mut valid_valleys: Vec<(usize, usize, usize, usize)> = Vec::new();
for &(start, end) in valleys {
let gutter_left = x_min + start as f32 * bin_width;
let gutter_right = x_min + end as f32 * bin_width;
let gutter_center = (gutter_left + gutter_right) / 2.0;
let left_items: Vec<&&TextItem> = page_items
.iter()
.filter(|i| {
if center_assign {
i.x + effective_width(i) / 2.0 <= gutter_center
} else {
i.x + effective_width(i) <= gutter_center
}
})
.collect();
let right_items: Vec<&&TextItem> = page_items
.iter()
.filter(|i| {
if center_assign {
i.x + effective_width(i) / 2.0 > gutter_center
} else {
i.x >= gutter_center
}
})
.collect();
let (smaller, larger) = if left_items.len() <= right_items.len() {
(left_items.len(), right_items.len())
} else {
(right_items.len(), left_items.len())
};
if larger < min_items || smaller < 3 {
debug!(
" valley rejected: counts smaller={} larger={}",
smaller, larger
);
continue;
}
let smaller_items: &[&&TextItem] = if left_items.len() <= right_items.len() {
&left_items
} else {
&right_items
};
if is_list_marker_column(smaller_items) {
debug!(" valley rejected: list-marker column");
continue;
}
if y_range > 0.0 {
let left_y_min = left_items.iter().map(|i| i.y).fold(f32::INFINITY, f32::min);
let left_y_max = left_items
.iter()
.map(|i| i.y)
.fold(f32::NEG_INFINITY, f32::max);
let right_y_min = right_items
.iter()
.map(|i| i.y)
.fold(f32::INFINITY, f32::min);
let right_y_max = right_items
.iter()
.map(|i| i.y)
.fold(f32::NEG_INFINITY, f32::max);
let overlap_min = left_y_min.max(right_y_min);
let overlap_max = left_y_max.min(right_y_max);
let overlap = (overlap_max - overlap_min).max(0.0);
if overlap / y_range < min_vertical_span {
debug!(
" valley rejected: overlap {:.0}/{:.0} = {:.2} < {:.2}",
overlap,
y_range,
overlap / y_range,
min_vertical_span
);
continue;
}
}
valid_valleys.push((start, end, left_items.len(), right_items.len()));
}
if valid_valleys.is_empty() {
debug!(
"page {}: {} valleys found but none passed validation",
page,
valleys.len()
);
return vec![ColumnRegion { x_min, x_max }];
}
debug!(
"page {}: {} columns detected (boundaries: {:?})",
page,
valid_valleys.len() + 1,
valid_valleys
.iter()
.map(|(s, e, _, _)| x_min + ((*s + *e) as f32 / 2.0) * bin_width)
.collect::<Vec<_>>()
);
if valid_valleys.len() > 3 {
valid_valleys.sort_by(|a, b| {
let score_a = (a.1 - a.0) as f32 * (a.2.min(a.3) as f32);
let score_b = (b.1 - b.0) as f32 * (b.2.min(b.3) as f32);
score_b
.partial_cmp(&score_a)
.unwrap_or(std::cmp::Ordering::Equal)
});
valid_valleys.truncate(3);
valid_valleys.sort_by_key(|v| v.0);
}
let mut columns = Vec::new();
let mut col_start = x_min;
for &(start, end, _, _) in &valid_valleys {
let gutter_center = x_min + ((start + end) as f32 / 2.0) * bin_width;
columns.push(ColumnRegion {
x_min: col_start,
x_max: gutter_center,
});
col_start = gutter_center;
}
columns.push(ColumnRegion {
x_min: col_start,
x_max,
});
columns
}
fn identify_spanning_lines(items: &[TextItem], columns: &[ColumnRegion]) -> Vec<bool> {
let n = items.len();
let mut mask = vec![false; n];
if n < 3 || columns.len() < 2 {
return mask;
}
let max_col_width = columns
.iter()
.map(|c| c.x_max - c.x_min)
.fold(0.0_f32, f32::max);
let span_threshold = max_col_width * 1.3;
let gutters: Vec<f32> = columns.windows(2).map(|c| c[0].x_max).collect();
let gutter_tol = 15.0;
let y_tol = 5.0;
let mut indexed: Vec<(usize, f32)> =
items.iter().enumerate().map(|(i, it)| (i, it.y)).collect();
indexed.sort_by(|a, b| b.1.total_cmp(&a.1));
let mut groups: Vec<Vec<usize>> = Vec::new();
let mut current_group: Vec<usize> = Vec::new();
let mut current_y = f32::NAN;
for (idx, y) in indexed {
if current_group.is_empty() || (current_y - y).abs() < y_tol {
if current_group.is_empty() {
current_y = y;
}
current_group.push(idx);
} else {
groups.push(std::mem::take(&mut current_group));
current_y = y;
current_group.push(idx);
}
}
if !current_group.is_empty() {
groups.push(current_group);
}
for group in groups {
if group.len() < 2 {
continue;
}
let mut sorted_by_x: Vec<usize> = group;
sorted_by_x.sort_by(|&a, &b| {
items[a]
.x
.partial_cmp(&items[b].x)
.unwrap_or(std::cmp::Ordering::Equal)
});
let line_left = items[sorted_by_x[0]].x;
let last = *sorted_by_x.last().unwrap();
let line_right = items[last].x + effective_width(&items[last]);
let span = line_right - line_left;
if span <= span_threshold {
continue;
}
let has_gutter_gap = sorted_by_x.windows(2).any(|pair| {
let left_end = items[pair[0]].x + effective_width(&items[pair[0]]);
let right_start = items[pair[1]].x;
let gap = right_start - left_end;
if gap < 5.0 {
return false;
}
gutters
.iter()
.any(|&g| g > left_end - gutter_tol && g < right_start + gutter_tol)
});
if !has_gutter_gap {
for &idx in &sorted_by_x {
mask[idx] = true;
}
}
}
mask
}
fn spans_multiple_columns(item: &TextItem, columns: &[ColumnRegion]) -> bool {
let w = effective_width(item);
let item_right = item.x + w;
let overlap_count = columns
.iter()
.filter(|col| {
let overlap_start = item.x.max(col.x_min);
let overlap_end = item_right.min(col.x_max);
let overlap = (overlap_end - overlap_start).max(0.0);
overlap > (col.x_max - col.x_min) * 0.10 || overlap > 20.0
})
.count();
overlap_count >= 2
}
fn is_page_number(item: &TextItem) -> bool {
let text = item.text.trim();
if text.is_empty() || text.len() > 4 {
return false;
}
if !text.chars().all(|c| c.is_ascii_digit()) {
return false;
}
item.y > 720.0 || item.y < 100.0
}
pub(crate) fn is_newspaper_layout(
per_column_lines: &[Vec<TextLine>],
columns: &[ColumnRegion],
) -> bool {
if per_column_lines.len() < 2 {
return false;
}
let min_lines = per_column_lines.iter().map(|c| c.len()).min().unwrap_or(0);
let max_lines = per_column_lines.iter().map(|c| c.len()).max().unwrap_or(0);
if min_lines < 5 {
return false;
}
if min_lines < 15 {
if columns.len() == 2 && per_column_lines.len() == 2 {
let w0 = columns[0].x_max - columns[0].x_min;
let w1 = columns[1].x_max - columns[1].x_min;
let width_ratio = w0.min(w1) / w0.max(w1);
let line_balance = if max_lines > 0 {
min_lines as f32 / max_lines as f32
} else {
1.0
};
let narrow_width = w0.min(w1);
if width_ratio < 0.50 && line_balance < 0.35 && max_lines >= 20 && narrow_width >= 160.0
{
let narrower_idx = if w0 < w1 { 0 } else { 1 };
let fewest_idx = if per_column_lines[0].len() <= per_column_lines[1].len() {
0
} else {
1
};
if narrower_idx == fewest_idx {
let narrow = &per_column_lines[narrower_idx];
let wide = &per_column_lines[1 - narrower_idx];
let avg_gap = |lines: &[TextLine]| -> f32 {
if lines.len() < 2 {
return 0.0;
}
let mut ys: Vec<f32> = lines.iter().map(|l| l.y).collect();
ys.sort_by(|a, b| a.total_cmp(b));
let span = ys.last().unwrap() - ys.first().unwrap();
span / (lines.len() as f32 - 1.0)
};
let narrow_gap = avg_gap(narrow);
let wide_gap = avg_gap(wide);
if wide_gap > 0.0 && narrow_gap / wide_gap >= 2.5 {
return true;
}
}
}
}
return false;
}
let balance_ratio = min_lines as f32 / max_lines as f32;
if balance_ratio > 0.7 {
return true;
}
let y_tol = 5.0; let (smallest_idx, _) = per_column_lines
.iter()
.enumerate()
.min_by_key(|(_, c)| c.len())
.unwrap();
let smallest = &per_column_lines[smallest_idx];
let mut collisions = 0u32;
for line in smallest {
for (ci, col) in per_column_lines.iter().enumerate() {
if ci == smallest_idx {
continue;
}
if col.iter().any(|ol| (ol.y - line.y).abs() < y_tol) {
collisions += 1;
break;
}
}
}
let ratio = collisions as f32 / smallest.len() as f32;
ratio > 0.5
}
fn split_column_stragglers(lines: Vec<TextLine>) -> (Vec<TextLine>, Vec<TextLine>) {
if lines.len() < 3 {
return (lines, Vec::new());
}
let mut gaps: Vec<f32> = Vec::new();
for i in 0..lines.len() - 1 {
gaps.push(lines[i].y - lines[i + 1].y);
}
let mut sorted_gaps = gaps.clone();
sorted_gaps.sort_by(|a, b| a.total_cmp(b));
let median_gap = sorted_gaps[sorted_gaps.len() / 2];
let threshold = (median_gap * 3.0).max(30.0);
let mut split_indices: Vec<usize> = Vec::new();
for (i, &gap) in gaps.iter().enumerate() {
if gap > threshold {
split_indices.push(i);
}
}
if split_indices.is_empty() {
return (lines, Vec::new());
}
let mut segments: Vec<(usize, usize)> = Vec::new();
let mut start = 0usize;
for &si in &split_indices {
segments.push((start, si + 1));
start = si + 1;
}
segments.push((start, lines.len()));
let (core_seg, _) = segments
.iter()
.enumerate()
.max_by_key(|(_, (s, e))| e - s)
.unwrap();
let (cs, ce) = segments[core_seg];
let mut core = Vec::with_capacity(ce - cs);
let mut stragglers = Vec::new();
for (i, line) in lines.into_iter().enumerate() {
if i >= cs && i < ce {
core.push(line);
} else {
stragglers.push(line);
}
}
(core, stragglers)
}
pub fn group_into_lines(items: Vec<TextItem>) -> Vec<TextLine> {
group_into_lines_with_thresholds(items, &HashMap::new(), &HashSet::new())
}
pub fn group_into_lines_preserving_all_text(items: Vec<TextItem>) -> Vec<TextLine> {
group_into_lines_with_thresholds_and_regions_impl(
items,
&HashMap::new(),
&HashSet::new(),
&HashMap::new(),
&HashMap::new(),
false,
)
}
pub(crate) fn group_into_lines_with_thresholds(
items: Vec<TextItem>,
page_thresholds: &HashMap<u32, f32>,
table_pages: &HashSet<u32>,
) -> Vec<TextLine> {
group_into_lines_with_thresholds_and_charts(
items,
page_thresholds,
table_pages,
&HashMap::new(),
)
}
pub(crate) fn group_into_lines_with_thresholds_and_charts(
items: Vec<TextItem>,
page_thresholds: &HashMap<u32, f32>,
table_pages: &HashSet<u32>,
chart_regions: &HashMap<u32, Vec<(f32, f32, f32, f32)>>,
) -> Vec<TextLine> {
group_into_lines_with_thresholds_and_regions(
items,
page_thresholds,
table_pages,
chart_regions,
&HashMap::new(),
)
}
pub(crate) fn group_into_lines_with_thresholds_and_regions(
items: Vec<TextItem>,
page_thresholds: &HashMap<u32, f32>,
table_pages: &HashSet<u32>,
chart_regions: &HashMap<u32, Vec<(f32, f32, f32, f32)>>,
image_regions: &HashMap<u32, Vec<super::reading_order::ImageRegion>>,
) -> Vec<TextLine> {
group_into_lines_with_thresholds_and_regions_impl(
items,
page_thresholds,
table_pages,
chart_regions,
image_regions,
true,
)
}
fn group_into_lines_with_thresholds_and_regions_impl(
items: Vec<TextItem>,
page_thresholds: &HashMap<u32, f32>,
table_pages: &HashSet<u32>,
chart_regions: &HashMap<u32, Vec<(f32, f32, f32, f32)>>,
image_regions: &HashMap<u32, Vec<super::reading_order::ImageRegion>>,
filter_page_numbers: bool,
) -> Vec<TextLine> {
if items.is_empty() {
return Vec::new();
}
let items = if filter_page_numbers {
items
.into_iter()
.filter(|item| !is_page_number(item))
.collect()
} else {
items
};
let mut pages: Vec<u32> = items.iter().map(|i| i.page).collect();
pages.sort();
pages.dedup();
let mut all_lines = Vec::new();
for page in pages {
let page_items: Vec<TextItem> = items.iter().filter(|i| i.page == page).cloned().collect();
let adaptive_threshold = page_thresholds.get(&page).copied().unwrap_or(0.10);
if !chart_regions.contains_key(&page) {
let preliminary_columns =
detect_columns(&page_items, page, table_pages.contains(&page));
let detected_split =
(preliminary_columns.len() == 2).then_some(preliminary_columns[0].x_max);
if let Some(band) = image_regions.get(&page).and_then(|regions| {
super::reading_order::infer_image_anchored_flow(
&page_items,
regions,
detected_split,
)
}) {
debug!(
"page {}: image-anchored region graph split={:.1} y=[{:.1}..{:.1}]",
page, band.split_x, band.y_bottom, band.y_top
);
for node in super::reading_order::build_region_graph(page_items, band) {
debug!(
"page {}: region node {:?} items={}",
page,
node.kind,
node.items.len()
);
all_lines.extend(group_single_column(node.items, adaptive_threshold));
}
continue;
}
}
debug!(
"page {}: grouping chart-aware={} regions={:?}",
page,
chart_regions.contains_key(&page),
chart_regions.get(&page).map(|v| v
.iter()
.map(|&(a, b, c, d)| (a as i32, b as i32, c as i32, d as i32))
.collect::<Vec<_>>())
);
let columns = match chart_regions.get(&page).filter(|r| !r.is_empty()) {
Some(regions) => {
let col_input: Vec<TextItem> = page_items
.iter()
.filter(|it| {
let cx = it.x + it.width / 2.0;
!regions.iter().any(|&(x0, y0, x1, y1)| {
cx >= x0 - 2.0 && cx <= x1 + 2.0 && it.y >= y0 - 2.0 && it.y <= y1 + 2.0
})
})
.cloned()
.collect();
detect_columns(&col_input, page, table_pages.contains(&page))
}
None => detect_columns(&page_items, page, table_pages.contains(&page)),
};
if columns.len() <= 1 {
let lines = group_single_column(page_items, adaptive_threshold);
all_lines.extend(lines);
} else {
let spanning_mask = identify_spanning_lines(&page_items, &columns);
let premasked_count = spanning_mask.iter().filter(|&&m| m).count();
if premasked_count > 0 {
debug!(
"page {}: pre-masked {} spanning-line items",
page, premasked_count
);
}
let mut spanning_items: Vec<TextItem> = Vec::new();
let mut column_items: Vec<TextItem> = Vec::new();
for (i, item) in page_items.into_iter().enumerate() {
if spanning_mask[i] || spans_multiple_columns(&item, &columns) {
spanning_items.push(item);
} else {
column_items.push(item);
}
}
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());
}
debug!(
"page {}: {} columns, {} spanning items",
page,
columns.len(),
spanning_items.len()
);
for (ci, col) in columns.iter().enumerate() {
debug!(
" col {}: x=[{:.0}..{:.0}] {} items",
ci,
col.x_min,
col.x_max,
col_buckets[ci].len()
);
}
if log::log_enabled!(log::Level::Trace) {
for (ci, bucket) in col_buckets.iter().enumerate() {
for item in bucket {
log::trace!(
" col {} <- x={:7.1} y={:7.1} {:?}",
ci,
item.x,
item.y,
super::trace_text_preview(&item.text, 60)
);
}
}
}
let mut per_column_lines: Vec<Vec<TextLine>> = Vec::new();
for col_items in col_buckets {
let lines = group_single_column(col_items, adaptive_threshold);
per_column_lines.push(lines);
}
let spanning_lines = group_single_column(spanning_items, adaptive_threshold);
let is_newspaper = is_newspaper_layout(&per_column_lines, &columns);
debug!(
"page {}: layout={}",
page,
if is_newspaper { "newspaper" } else { "tabular" }
);
if is_newspaper {
let mut core_columns: Vec<Vec<TextLine>> = Vec::new();
let mut col_stragglers: Vec<Vec<TextLine>> = Vec::new();
for col in per_column_lines {
let (core, stragglers) = split_column_stragglers(col);
core_columns.push(core);
col_stragglers.push(stragglers);
}
let col_top = core_columns
.iter()
.filter(|c| !c.is_empty())
.map(|c| c.iter().map(|l| l.y).fold(f32::NEG_INFINITY, f32::max))
.fold(f32::INFINITY, f32::min);
let margin = 5.0;
let mut above: Vec<TextLine> = Vec::new();
let mut below_spanning: Vec<TextLine> = Vec::new();
for line in spanning_lines {
if line.y > col_top + margin {
above.push(line);
} else {
below_spanning.push(line);
}
}
let mut col_below: Vec<Vec<TextLine>> = vec![Vec::new(); core_columns.len()];
for (ci, stragglers) in col_stragglers.into_iter().enumerate() {
for line in stragglers {
if line.y > col_top + margin {
above.push(line);
} else {
col_below[ci].push(line);
}
}
}
above.sort_by(|a, b| b.y.total_cmp(&a.y));
below_spanning.sort_by(|a, b| b.y.total_cmp(&a.y));
all_lines.extend(above);
for col in core_columns {
all_lines.extend(col);
}
for cb in col_below {
all_lines.extend(cb);
}
all_lines.extend(below_spanning);
} else {
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);
}
all_page_lines.sort_by(|a, b| {
b.y.total_cmp(&a.y).then(
a.items
.first()
.map(|i| i.x)
.unwrap_or(0.0)
.total_cmp(&b.items.first().map(|i| i.x).unwrap_or(0.0)),
)
});
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);
sort_line_items(&mut last.items);
continue;
}
}
merged.push(line);
}
all_lines.extend(merged);
}
}
}
all_lines
}
fn should_use_y_sorting(items: &[TextItem]) -> bool {
if items.len() < 5 {
return false; }
let y_positions: Vec<f32> = items.iter().map(|i| i.y).collect();
let mut large_jumps_up = 0;
let mut large_jumps_down = 0;
let jump_threshold = 50.0;
for window in y_positions.windows(2) {
let delta = window[1] - window[0];
if delta > jump_threshold {
large_jumps_up += 1; } else if delta < -jump_threshold {
large_jumps_down += 1; }
}
let total_jumps = large_jumps_up + large_jumps_down;
if total_jumps < 3 {
return false; }
let chaos_ratio = large_jumps_up as f32 / total_jumps as f32;
chaos_ratio > 0.4
}
fn group_single_column(items: Vec<TextItem>, adaptive_threshold: f32) -> Vec<TextLine> {
if items.is_empty() {
return Vec::new();
}
let use_y_sorting = should_use_y_sorting(&items);
let items = if use_y_sorting {
let mut sorted = items;
sorted.sort_by(|a, b| b.y.total_cmp(&a.y).then(a.x.total_cmp(&b.x)));
sorted
} else {
items
};
let mut lines: Vec<TextLine> = Vec::new();
let y_tolerance = 3.0;
for item in items {
let should_merge = lines.last().is_some_and(|last_line| {
if last_line.page != item.page {
return false;
}
let y_diff = (last_line.y - item.y).abs();
if y_diff >= y_tolerance {
return false;
}
let has_y_change = y_diff > 0.5;
if has_y_change {
if let Some(first_item) = last_line.items.first() {
let at_same_x = (item.x - first_item.x).abs() < 5.0;
if at_same_x {
return false;
}
if let Some(last_item) = last_line.items.last() {
if item.x < last_item.x - 10.0 {
return false;
}
}
}
}
if let Some(last_item) = last_line.items.last() {
let gap = item.x - (last_item.x + last_item.width);
if gap > (item.font_size.max(last_item.font_size) * 3.0).max(30.0)
&& item
.text
.trim()
.chars()
.next()
.is_some_and(|c| c.is_alphabetic())
{
let incoming_wordy = {
let t = item.text.trim();
t.split_whitespace().count() >= 3
&& t.chars().filter(|c| c.is_alphabetic()).count() >= 10
};
let line_text = last_line
.items
.iter()
.map(|i| i.text.trim())
.collect::<Vec<_>>()
.join(" ");
let line_wordy = line_text.split_whitespace().count() >= 2
&& line_text.chars().filter(|c| c.is_alphabetic()).count() >= 8;
let starts_lower = item
.text
.trim()
.chars()
.next()
.is_some_and(|c| c.is_lowercase());
let style_mismatch = last_line.items.iter().all(|i| i.is_bold) && !item.is_bold;
if line_wordy && incoming_wordy && (starts_lower || style_mismatch) {
return false;
}
}
}
true
});
if should_merge {
lines.last_mut().unwrap().items.push(item);
} else {
let y = item.y;
let page = item.page;
lines.push(TextLine {
items: vec![item],
y,
page,
adaptive_threshold,
});
}
}
for line in &mut lines {
sort_line_items(&mut line.items);
}
debug!("group_single_column: {} lines", lines.len());
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ItemType;
fn make_item(page: u32, x: f32, y: f32, text: &str) -> TextItem {
TextItem {
text: text.to_string(),
x,
y,
width: text.len() as f32 * 6.0, height: 12.0,
font_size: 12.0,
font: String::new(),
page,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
}
}
fn fill_zone(page: u32, x_start: f32, x_end: f32, y_start: f32, y_end: f32) -> Vec<TextItem> {
let mut items = Vec::new();
let item_width = 60.0; let step = 55.0; let mut y = y_start;
while y >= y_end {
let mut x = x_start;
while x + item_width <= x_end {
items.push(make_item(page, x, y, "SomeText__"));
x += step;
}
y -= 14.0;
}
items
}
#[test]
fn same_baseline_wide_gap_lowercase_continuation_splits() {
let items = vec![
make_item(1, 94.0, 242.0, "6.2. Expectations for Re-Hiring Staff"),
make_item(1, 380.0, 242.0, "they had no plans to re-hire and more"),
];
let lines = group_single_column(items, 0.10);
assert_eq!(lines.len(), 2, "independent column runs must not fuse");
}
#[test]
fn same_baseline_wide_gap_table_label_stays_joined() {
let items = vec![
make_item(1, 94.0, 242.0, "2. Embracing complexity in"),
make_item(1, 380.0, 242.0, "2.1 Systems thinking and practice"),
];
let lines = group_single_column(items, 0.10);
assert_eq!(lines.len(), 1, "numbered table cells stay on one line");
}
#[test]
fn three_zone_layout_detected() {
let mut items = Vec::new();
items.extend(fill_zone(1, 15.0, 330.0, 750.0, 50.0));
items.extend(fill_zone(1, 345.0, 660.0, 750.0, 50.0));
items.extend(fill_zone(1, 675.0, 800.0, 750.0, 50.0));
let cols = detect_columns(&items, 1, false);
assert_eq!(cols.len(), 3, "Expected 3 columns, got {}", cols.len());
let g1 = cols[0].x_max;
assert!(
(290.0..=350.0).contains(&g1),
"First gutter at {g1}, expected between left and middle zones"
);
let g2 = cols[1].x_max;
assert!(
(620.0..=680.0).contains(&g2),
"Second gutter at {g2}, expected between middle and right zones"
);
}
#[test]
fn two_column_regression_guard() {
let mut items = Vec::new();
items.extend(fill_zone(1, 30.0, 280.0, 750.0, 50.0));
items.extend(fill_zone(1, 320.0, 570.0, 750.0, 50.0));
let cols = detect_columns(&items, 1, false);
assert_eq!(cols.len(), 2, "Expected 2 columns, got {}", cols.len());
let gutter = cols[0].x_max;
assert!(
(280.0..=320.0).contains(&gutter),
"Gutter at {gutter}, expected ~300"
);
}
#[test]
fn score_prefers_balanced_gutter_over_wide_gap() {
let mut items = Vec::new();
items.extend(fill_zone(1, 15.0, 200.0, 750.0, 50.0));
items.extend(fill_zone(1, 220.0, 400.0, 750.0, 50.0));
items.extend(fill_zone(1, 420.0, 600.0, 750.0, 50.0));
for y_off in 0..12 {
items.push(make_item(
1,
700.0,
750.0 - y_off as f32 * 50.0,
"Sparse____",
));
}
let cols = detect_columns(&items, 1, false);
assert!(
cols.len() >= 3,
"Expected >=3 columns for dense zones, got {}",
cols.len()
);
}
fn fill_zone_justified(
page: u32,
x_start: f32,
x_end: f32,
overshoot: f32,
y_start: f32,
y_end: f32,
) -> Vec<TextItem> {
let mut items = Vec::new();
let mut y = y_start;
while y >= y_end {
let item_width = (x_end - x_start + overshoot) / 3.0;
for i in 0..3 {
let x = x_start + i as f32 * (x_end - x_start) / 3.0;
let text_len = (item_width / 6.0).ceil() as usize;
let text: String = "W".repeat(text_len);
items.push(TextItem {
text,
x,
y,
width: item_width,
height: 12.0,
font_size: 12.0,
font: String::new(),
page,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
});
}
y -= 14.0;
}
items
}
#[test]
fn relative_valley_detects_justified_text_columns() {
let mut items = Vec::new();
items.extend(fill_zone_justified(1, 40.0, 290.0, 7.0, 750.0, 50.0));
items.extend(fill_zone_justified(1, 300.0, 550.0, 7.0, 750.0, 50.0));
let cols = detect_columns(&items, 1, false);
assert_eq!(
cols.len(),
2,
"Expected 2 columns for justified text, got {}",
cols.len()
);
let gutter = cols[0].x_max;
assert!(
(280.0..=310.0).contains(&gutter),
"Gutter at {gutter}, expected ~295"
);
}
#[test]
fn relative_valley_rejects_single_column_margin() {
let items = fill_zone_justified(1, 40.0, 350.0, 0.0, 750.0, 50.0);
let cols = detect_columns(&items, 1, false);
assert_eq!(
cols.len(),
1,
"Expected 1 column for single-column text, got {}",
cols.len()
);
}
fn make_lines(n: usize, x: f32) -> Vec<TextLine> {
(0..n)
.map(|i| {
let y = 700.0 - i as f32 * 14.0;
let item = make_item(1, x, y, "SomeText__");
TextLine {
y,
page: 1,
adaptive_threshold: 0.10,
items: vec![item],
}
})
.collect()
}
#[test]
fn sidebar_layout_detected_as_newspaper() {
let body = make_lines(40, 50.0);
let sidebar: Vec<TextLine> = (0..12)
.map(|i| {
let y = 693.0 - i as f32 * 45.0; let item = make_item(1, 440.0, y, "SomeText__");
TextLine {
y,
page: 1,
adaptive_threshold: 0.10,
items: vec![item],
}
})
.collect();
let cols = vec![
ColumnRegion {
x_min: 0.0,
x_max: 400.0,
},
ColumnRegion {
x_min: 420.0,
x_max: 590.0,
},
];
assert!(
is_newspaper_layout(&[body, sidebar], &cols),
"Wide body + narrow sidebar should be detected as newspaper"
);
}
#[test]
fn borderless_table_not_misclassified() {
let col1 = make_lines(10, 50.0);
let col2 = make_lines(10, 350.0);
let cols = vec![
ColumnRegion {
x_min: 0.0,
x_max: 300.0,
},
ColumnRegion {
x_min: 300.0,
x_max: 550.0,
},
];
assert!(
!is_newspaper_layout(&[col1, col2], &cols),
"Equal-width equal-row columns should NOT be newspaper (borderless table)"
);
}
#[test]
fn premask_spanning_title_removed_from_columns() {
let cols = vec![
ColumnRegion {
x_min: 0.0,
x_max: 300.0,
},
ColumnRegion {
x_min: 300.0,
x_max: 600.0,
},
];
let mut items = Vec::new();
for i in 0..5 {
items.push(make_item(
1,
30.0 + i as f32 * 104.0,
750.0,
"TitleWord_________",
));
}
for i in 0..20 {
items.push(make_item(1, 30.0, 700.0 - i as f32 * 14.0, "LeftText__"));
}
for i in 0..20 {
items.push(make_item(1, 320.0, 700.0 - i as f32 * 14.0, "RightText_"));
}
let mask = identify_spanning_lines(&items, &cols);
let spanning_count = mask.iter().filter(|&&m| m).count();
let non_spanning_count = mask.iter().filter(|&&m| !m).count();
assert_eq!(spanning_count, 5, "Title items should be pre-masked");
assert_eq!(non_spanning_count, 40, "Column items should remain");
}
#[test]
fn premask_does_not_mask_column_items_at_same_y() {
let cols = vec![
ColumnRegion {
x_min: 0.0,
x_max: 300.0,
},
ColumnRegion {
x_min: 300.0,
x_max: 600.0,
},
];
let mut items = Vec::new();
for i in 0..15 {
let y = 700.0 - i as f32 * 14.0;
items.push(make_item(1, 30.0, y, "LeftText__"));
items.push(make_item(1, 320.0, y, "RightText_"));
}
let mask = identify_spanning_lines(&items, &cols);
let spanning_count = mask.iter().filter(|&&m| m).count();
assert_eq!(
spanning_count, 0,
"Column items with gap at gutter should NOT be pre-masked"
);
}
#[test]
fn bullet_marker_column_not_detected_as_column() {
let mut items = Vec::new();
for i in 0..15 {
let y = 750.0 - i as f32 * 30.0;
items.push(make_item(1, 90.0, y, "●"));
items.push(make_item(
1,
104.0,
y,
"FullContentLineTextHere________________",
));
}
for i in 0..15 {
let y = 300.0 - i as f32 * 14.0;
items.push(make_item(1, 72.0, y, "FootnoteText_____________________"));
}
let cols = detect_columns(&items, 1, false);
assert_eq!(
cols.len(),
1,
"Bullet markers aligned at left margin should not be treated as their own column"
);
}
#[test]
fn is_list_marker_column_detects_bullets() {
let items = vec![
make_item(1, 90.0, 100.0, "●"),
make_item(1, 90.0, 114.0, "●"),
make_item(1, 90.0, 128.0, "●"),
make_item(1, 90.0, 142.0, "●"),
];
let refs: Vec<&TextItem> = items.iter().collect();
let wrapped: Vec<&&TextItem> = refs.iter().collect();
assert!(is_list_marker_column(&wrapped));
}
#[test]
fn is_list_marker_column_rejects_prose() {
let items = vec![
make_item(1, 30.0, 100.0, "Regular prose line"),
make_item(1, 30.0, 114.0, "Another sentence"),
make_item(1, 30.0, 128.0, "Third line"),
make_item(1, 30.0, 142.0, "Fourth line"),
];
let refs: Vec<&TextItem> = items.iter().collect();
let wrapped: Vec<&&TextItem> = refs.iter().collect();
assert!(!is_list_marker_column(&wrapped));
}
#[test]
fn premask_narrow_line_not_masked() {
let cols = vec![
ColumnRegion {
x_min: 0.0,
x_max: 300.0,
},
ColumnRegion {
x_min: 300.0,
x_max: 600.0,
},
];
let mut items = Vec::new();
for i in 0..3 {
items.push(make_item(
1,
180.0 + i as f32 * 84.0,
750.0,
"SmallHeader___",
));
}
for i in 0..15 {
let y = 700.0 - i as f32 * 14.0;
items.push(make_item(1, 30.0, y, "LeftText__"));
items.push(make_item(1, 400.0, y, "RightText_"));
}
let mask = identify_spanning_lines(&items, &cols);
let spanning_count = mask.iter().filter(|&&m| m).count();
assert_eq!(spanning_count, 0, "Narrow header should NOT be pre-masked");
}
}