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());
const MAX_PAGE_EXTENT: f32 = 14_400.0;
const MAX_TRIM_FRACTION: f32 = 0.10;
let finite_span = |i: &&TextItem| -> Option<(f32, f32)> {
let (left, width) = (i.x, effective_width(i));
(left.is_finite() && (left + width).is_finite()).then_some((left, width))
};
let (min_left, max_right, total) = page_items.iter().filter_map(finite_span).fold(
(f32::INFINITY, f32::NEG_INFINITY, 0usize),
|(lo, hi, n), (left, width)| (lo.min(left), hi.max(left + width), n + 1),
);
if total == 0 {
return vec![];
}
let (x_min, x_max) = if max_right - min_left <= MAX_PAGE_EXTENT {
(min_left, max_right)
} else {
let mut spans: Vec<(f32, f32)> = page_items.iter().filter_map(finite_span).collect();
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut core: Option<std::ops::Range<usize>> = None;
let mut start = 0usize;
for i in 1..=spans.len() {
if i < spans.len() && spans[i].0 - spans[i - 1].0 <= MAX_PAGE_EXTENT {
continue;
}
if core.as_ref().is_none_or(|best| i - start > best.len()) {
core = Some(start..i);
}
start = i;
}
let mut core = core.unwrap_or(0..spans.len());
let dropped = spans.len() - core.len();
if dropped as f32 > spans.len() as f32 * MAX_TRIM_FRACTION {
core = 0..spans.len();
}
let core = &spans[core];
let (lo, widest_left) = (core[0].0, core[core.len() - 1].0);
let max_run_width = (widest_left - lo) + MAX_PAGE_EXTENT;
let hi = core
.iter()
.filter(|&&(_, width)| width <= max_run_width)
.map(|&(left, width)| left + width)
.fold(widest_left, f32::max);
if lo != min_left || hi != max_right {
debug!(
"page {page}: bounds {min_left}..{max_right} exceed one page; \
dropped {dropped}/{} detached item(s), using {lo}..{hi}",
spans.len()
);
}
(lo, hi)
};
const MAX_BINS: usize = 65_536;
let page_width = x_max - x_min;
if !page_width.is_finite() || page_width < 200.0 {
return vec![ColumnRegion { x_min, x_max }];
}
if page_items.len() < 20 {
return vec![ColumnRegion { x_min, x_max }];
}
let bin_width = BIN_WIDTH.max(page_width / MAX_BINS as f32);
let wide_threshold = page_width * 0.6;
let num_bins = ((page_width / bin_width).ceil() as usize).clamp(1, MAX_BINS);
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() >= 30 {
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 !page_has_table {
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,
},
])
}
const LINE_FILL_THRESHOLD: f32 = 0.45;
#[derive(Default)]
struct ProseLineStats {
full_lines: usize,
total_lines: usize,
total_items: usize,
current_run: usize,
best_run: usize,
previous_line_y: Option<f32>,
previous_line_height: f32,
}
impl ProseLineStats {
const MAX_LEADING_FACTOR: f32 = 2.5;
fn flush_line(&mut self, line_items: &[&TextItem], col: &ColumnRegion, col_width: f32) {
if line_items.is_empty() {
return;
}
self.total_lines += 1;
self.total_items += line_items.len();
let line_y = line_items[0].y;
let line_height = line_items
.iter()
.map(|i| i.height)
.fold(0.0_f32, f32::max)
.max(1.0);
if let Some(previous_y) = self.previous_line_y {
let leading = (previous_y - line_y).abs();
if leading > self.previous_line_height.max(line_height) * Self::MAX_LEADING_FACTOR {
self.current_run = 0;
}
}
self.previous_line_y = Some(line_y);
self.previous_line_height = line_height;
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 {
self.full_lines += 1;
self.current_run += 1;
self.best_run = self.best_run.max(self.current_run);
} else {
self.current_run = 0;
}
}
}
fn columns_have_prose(columns: &[ColumnRegion], items: &[&TextItem]) -> bool {
const Y_TOL: f32 = 3.0; const MIN_PROSE_RATIO: f32 = 0.40; const MIN_PROSE_RUN: usize = 6;
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 stats = ProseLineStats::default();
let mut line_items: Vec<&TextItem> = Vec::new();
let mut line_y = f32::NAN;
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 {
stats.flush_line(&line_items, col, col_width);
line_items.clear();
line_y = item.y;
line_items.push(item);
}
}
stats.flush_line(&line_items, col, col_width);
if stats.total_lines < MIN_LINES {
return false;
}
let full_lines = stats.full_lines;
let total_lines = stats.total_lines;
let best_run = stats.best_run;
let total_items_in_lines = stats.total_items;
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} run={} avg_items={:.1}",
col.x_min, col.x_max, total_lines, full_lines, ratio, best_run, avg_items
);
if ratio < MIN_PROSE_RATIO && best_run < MIN_PROSE_RUN {
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
}
const PAGE_NUMBER_Y_TOLERANCE: f32 = 3.0;
const PAGE_NUMBER_CONTEXT_GAP_EM: f32 = 1.5;
const PAGE_NUMBER_BOTTOM_Y: f32 = 100.0;
const PAGE_NUMBER_TOP_Y: f32 = 720.0;
const SPREAD_MIN_CONTENT_WIDTH_EM: f32 = 40.0;
const SPREAD_EDGE_FRACTION: f32 = 0.25;
const ADJACENT_PAGE_MIN_CONTENT_WIDTH_EM: f32 = 26.0;
type ContextualCandidateOccurrence = (u32, f32, Vec<(usize, u32)>);
fn page_number_value(item: &TextItem) -> Option<u32> {
if !matches!(
item.item_type,
crate::types::ItemType::Text | crate::types::ItemType::FormField
) {
return None;
}
let text = item.text.trim();
if text.is_empty() || text.len() > 4 || !text.chars().all(|c| c.is_ascii_digit()) {
return None;
}
if item.y <= PAGE_NUMBER_TOP_Y && item.y >= PAGE_NUMBER_BOTTOM_Y {
return None;
}
text.parse().ok()
}
fn mark_repeated_folio_candidates(
occurrences_by_signature: HashMap<String, Vec<ContextualCandidateOccurrence>>,
document_page_count: usize,
explicit_folio: &mut [bool],
) {
let min_pages = 4usize.max((document_page_count * 30).div_ceil(100));
for occurrences in occurrences_by_signature.into_values() {
if occurrences.len() < min_pages {
continue;
}
let distinct_pages: HashSet<u32> = occurrences.iter().map(|(page, _, _)| *page).collect();
if distinct_pages.len() != occurrences.len() || distinct_pages.len() < min_pages {
continue;
}
let min_y = occurrences
.iter()
.map(|(_, y, _)| *y)
.fold(f32::INFINITY, f32::min);
let max_y = occurrences
.iter()
.map(|(_, y, _)| *y)
.fold(f32::NEG_INFINITY, f32::max);
if max_y - min_y >= PAGE_NUMBER_Y_TOLERANCE {
continue;
}
let slot_count = occurrences[0].2.len();
if slot_count == 0
|| occurrences
.iter()
.any(|(_, _, candidates)| candidates.len() != slot_count)
{
continue;
}
for slot in 0..slot_count {
let mut values: Vec<(u32, u32, usize)> = occurrences
.iter()
.map(|(page, _, candidates)| {
let (index, value) = candidates[slot];
(*page, value, index)
})
.collect();
values.sort_by_key(|(page, _, _)| *page);
let unique_values: HashSet<u32> = values.iter().map(|(_, value, _)| *value).collect();
let mostly_unique = unique_values.len() * 5 >= values.len() * 4;
let page_tracking_pairs = values
.windows(2)
.filter(|pair| {
let page_delta = pair[1].0 - pair[0].0;
let value_delta = pair[1].1.saturating_sub(pair[0].1);
value_delta == page_delta || value_delta == page_delta.saturating_mul(2)
})
.count();
let mostly_tracks_page_order = page_tracking_pairs * 5 >= (values.len() - 1) * 4;
let max_plausible_folio = (document_page_count as u32).saturating_mul(4).max(100);
let plausible_magnitude = values
.iter()
.all(|(_, value, _)| *value <= max_plausible_folio);
if mostly_unique && mostly_tracks_page_order && plausible_magnitude {
for (_, _, index) in values {
explicit_folio[index] = true;
}
}
}
}
}
fn mark_spread_folio_pairs(
items: &[TextItem],
candidate_values: &[Option<u32>],
contextual: &[bool],
explicit_folio: &mut [bool],
) {
let mut candidates_by_page: HashMap<u32, Vec<usize>> = HashMap::new();
let mut page_bounds: HashMap<u32, (f32, f32)> = HashMap::new();
for (index, value) in candidate_values.iter().enumerate() {
if value.is_some() {
candidates_by_page
.entry(items[index].page)
.or_default()
.push(index);
}
}
for item in items.iter().filter(|item| !item.text.trim().is_empty()) {
let bounds = page_bounds
.entry(item.page)
.or_insert((f32::INFINITY, f32::NEG_INFINITY));
bounds.0 = bounds.0.min(item.x);
bounds.1 = bounds.1.max(item.x + effective_width(item));
}
for (page, page_candidates) in candidates_by_page {
let Some(&(page_left, page_right)) = page_bounds.get(&page) else {
continue;
};
let page_width = page_right - page_left;
if page_width <= 0.0 {
continue;
}
let left_edge = page_left + page_width * SPREAD_EDGE_FRACTION;
let right_edge = page_right - page_width * SPREAD_EDGE_FRACTION;
let max_pair_font_size = page_width / SPREAD_MIN_CONTENT_WIDTH_EM;
let edge_side = |index: usize| {
let center = items[index].x + effective_width(&items[index]) / 2.0;
if center <= left_edge {
Some(false)
} else if center >= right_edge {
Some(true)
} else {
None
}
};
let mut known_baselines: HashMap<(u32, bool), Vec<f32>> = HashMap::new();
for &index in &page_candidates {
if (contextual[index] && !explicit_folio[index])
|| items[index].font_size >= max_pair_font_size
{
continue;
}
let Some(side) = edge_side(index) else {
continue;
};
let value = candidate_values[index].unwrap();
known_baselines
.entry((value, side))
.or_default()
.push(items[index].y);
}
for baselines in known_baselines.values_mut() {
baselines.sort_by(f32::total_cmp);
}
for index in page_candidates {
if !contextual[index]
|| explicit_folio[index]
|| items[index].font_size >= max_pair_font_size
{
continue;
}
let Some(value) = candidate_values[index] else {
continue;
};
let Some(side) = edge_side(index) else {
continue;
};
let y = items[index].y;
let paired = [value.checked_sub(1), value.checked_add(1)]
.into_iter()
.flatten()
.filter_map(|other_value| known_baselines.get(&(other_value, !side)))
.any(|baselines| {
let first = baselines
.partition_point(|baseline| *baseline <= y - PAGE_NUMBER_Y_TOLERANCE);
baselines
.get(first)
.is_some_and(|baseline| *baseline < y + PAGE_NUMBER_Y_TOLERANCE)
});
if paired {
explicit_folio[index] = true;
}
}
}
}
fn mark_adjacent_page_folio_pairs(
items: &[TextItem],
candidate_values: &[Option<u32>],
contextual: &[bool],
document_page_count: usize,
explicit_folio: &mut [bool],
) {
let max_plausible_folio = (document_page_count as u32).saturating_mul(4).max(100);
let strong_folio_evidence = explicit_folio.to_vec();
let mut page_bounds: HashMap<u32, (f32, f32)> = HashMap::new();
for item in items.iter().filter(|item| !item.text.trim().is_empty()) {
let bounds = page_bounds
.entry(item.page)
.or_insert((f32::INFINITY, f32::NEG_INFINITY));
bounds.0 = bounds.0.min(item.x);
bounds.1 = bounds.1.max(item.x + effective_width(item));
}
let edge_side = |index: usize| {
let &(page_left, page_right) = page_bounds.get(&items[index].page)?;
let page_width = page_right - page_left;
if page_width < items[index].font_size * ADJACENT_PAGE_MIN_CONTENT_WIDTH_EM {
return None;
}
let center = items[index].x + effective_width(&items[index]) / 2.0;
let left_edge = page_left + page_width * SPREAD_EDGE_FRACTION;
let right_edge = page_right - page_width * SPREAD_EDGE_FRACTION;
if center <= left_edge {
Some(false)
} else if center >= right_edge {
Some(true)
} else {
None
}
};
let mut anchors_by_page: HashMap<(u32, bool, i64), Vec<usize>> = HashMap::new();
let mut anchors_by_sequence: HashMap<(bool, i64), Vec<usize>> = HashMap::new();
for (index, value) in candidate_values.iter().enumerate() {
let Some(value) = value else {
continue;
};
if *value > max_plausible_folio || (contextual[index] && !strong_folio_evidence[index]) {
continue;
}
let Some(side) = edge_side(index) else {
continue;
};
let page = items[index].page;
let offset = i64::from(*value) - i64::from(page);
anchors_by_page
.entry((page, side, offset))
.or_default()
.push(index);
anchors_by_sequence
.entry((side, offset))
.or_default()
.push(index);
}
for anchors in anchors_by_sequence.values_mut() {
anchors.sort_by(|&left, &right| items[left].y.total_cmp(&items[right].y));
}
for (index, value) in candidate_values.iter().enumerate() {
let Some(value) = value else {
continue;
};
if !contextual[index] || explicit_folio[index] || *value > max_plausible_folio {
continue;
}
let Some(side) = edge_side(index) else {
continue;
};
let page = items[index].page;
let offset = i64::from(*value) - i64::from(page);
let Some(sequence_anchors) = anchors_by_sequence.get(&(!side, offset)) else {
continue;
};
let neighbor = [page.checked_sub(1), page.checked_add(1)]
.into_iter()
.flatten()
.filter_map(|neighbor_page| anchors_by_page.get(&(neighbor_page, !side, offset)))
.flatten()
.copied()
.find(|&neighbor_index| {
(items[index].y - items[neighbor_index].y).abs() < PAGE_NUMBER_Y_TOLERANCE
&& (items[index].font_size - items[neighbor_index].font_size).abs() < 1.0
});
let Some(neighbor_index) = neighbor else {
continue;
};
let first = sequence_anchors.partition_point(|&anchor_index| {
items[anchor_index].y <= items[index].y - PAGE_NUMBER_Y_TOLERANCE
});
let has_second_anchor = sequence_anchors[first..]
.iter()
.take_while(|&&anchor_index| {
items[anchor_index].y < items[index].y + PAGE_NUMBER_Y_TOLERANCE
})
.any(|&anchor_index| {
items[anchor_index].page != page
&& items[anchor_index].page != items[neighbor_index].page
&& (items[index].font_size - items[anchor_index].font_size).abs() < 1.0
});
if has_second_anchor {
explicit_folio[index] = true;
}
}
}
fn page_number_context_masks(
items: &[TextItem],
candidate_values: &[Option<u32>],
document_page_count: usize,
) -> (Vec<bool>, Vec<bool>) {
let mut contextual = vec![false; items.len()];
let mut explicit_folio = vec![false; items.len()];
let mut occurrences_by_signature: HashMap<String, Vec<ContextualCandidateOccurrence>> =
HashMap::new();
let mut indices_by_page: HashMap<u32, Vec<usize>> = HashMap::new();
for (index, item) in items.iter().enumerate() {
if matches!(
item.item_type,
crate::types::ItemType::Text | crate::types::ItemType::FormField
) && !item.text.trim().is_empty()
{
indices_by_page.entry(item.page).or_default().push(index);
}
}
for mut page_indices in indices_by_page.into_values() {
page_indices.sort_by(|&left, &right| {
items[right]
.y
.total_cmp(&items[left].y)
.then(items[left].x.total_cmp(&items[right].x))
});
let mut rows: Vec<Vec<usize>> = Vec::new();
for index in page_indices {
if rows.last().is_some_and(|row| {
(items[row[0]].y - items[index].y).abs() < PAGE_NUMBER_Y_TOLERANCE
}) {
rows.last_mut().unwrap().push(index);
} else {
rows.push(vec![index]);
}
}
for mut row in rows {
row.sort_by(|&left, &right| items[left].x.total_cmp(&items[right].x));
let mut start = 0;
while start < row.len() {
let mut end = start + 1;
let first = &items[row[start]];
let mut group_right = first.x + effective_width(first);
let mut group_font_size = first.font_size;
while end < row.len() {
let item = &items[row[end]];
let gap = item.x - group_right;
if gap > group_font_size.max(item.font_size) * PAGE_NUMBER_CONTEXT_GAP_EM {
break;
}
group_right = group_right.max(item.x + effective_width(item));
group_font_size = group_font_size.max(item.font_size);
end += 1;
}
let group = &row[start..end];
let has_lexical_context = group.iter().any(|&index| {
candidate_values[index].is_none()
&& items[index]
.text
.chars()
.any(|character| character.is_alphabetic())
});
let numeric_like = |text: &str| {
text.chars().any(|character| character.is_numeric())
&& !text.chars().any(|character| character.is_alphabetic())
};
let is_structured_numeric_context = |index: usize| {
if candidate_values[index].is_some() {
return false;
}
let text = items[index].text.trim();
numeric_like(text)
&& text
.chars()
.any(|character| !character.is_numeric() && !character.is_whitespace())
};
let has_structured_numeric_context = group
.iter()
.any(|&index| is_structured_numeric_context(index));
let numeric_item_count = group
.iter()
.filter(|&&index| numeric_like(items[index].text.trim()))
.count();
let has_long_integer = group.iter().any(|&index| {
candidate_values[index].is_none()
&& items[index]
.text
.trim()
.chars()
.all(|character| character.is_numeric())
});
let has_dense_numeric_context = numeric_item_count >= 3 && has_long_integer;
let has_context = has_lexical_context
|| has_structured_numeric_context
|| has_dense_numeric_context;
let has_candidate = row[start..end]
.iter()
.any(|&index| candidate_values[index].is_some());
if group.len() == 3
&& items[group[0]].text.trim() == "-"
&& candidate_values[group[1]].is_some()
&& items[group[2]].text.trim() == "-"
{
for &index in group {
explicit_folio[index] = true;
}
}
if has_context && has_candidate {
let group = &row[start..end];
let group_text = group
.iter()
.map(|&index| items[index].text.trim())
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join(" ");
let group_is_folio =
crate::text_utils::is_explicit_page_number_expression(&group_text);
let context_text = group
.iter()
.filter(|&&index| candidate_values[index].is_none())
.map(|&index| items[index].text.trim())
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join(" ");
let candidates: Vec<(usize, u32)> = group
.iter()
.filter_map(|&index| candidate_values[index].map(|value| (index, value)))
.collect();
let recurrence_candidates: Vec<(usize, u32)> = candidates
.iter()
.copied()
.filter(|(index, _)| {
group.first() == Some(index) || group.last() == Some(index)
})
.collect();
let in_deep_margin = candidates.iter().all(|(index, _)| {
items[*index].y < PAGE_NUMBER_BOTTOM_Y
|| items[*index].y > PAGE_NUMBER_TOP_Y
});
if in_deep_margin
&& !recurrence_candidates.is_empty()
&& context_text
.chars()
.filter(|character| character.is_alphanumeric())
.count()
>= 8
{
let signature = group
.iter()
.map(|&index| {
if candidate_values[index].is_some() {
"{number}".to_string()
} else {
items[index]
.text
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
})
.collect::<Vec<_>>()
.join(" ");
occurrences_by_signature
.entry(signature)
.or_default()
.push((
items[group[0]].page,
items[group[0]].y,
recurrence_candidates,
));
}
for (position, &index) in group.iter().enumerate() {
if let Some(value) = candidate_values[index] {
let adjacent_context = [position.checked_sub(1), Some(position + 1)]
.into_iter()
.flatten()
.filter_map(|position| group.get(position).copied())
.any(|adjacent| {
candidate_values[adjacent].is_none()
&& items[adjacent].text.chars().any(|character| {
!character.is_numeric() && !character.is_whitespace()
})
});
let max_plausible_folio =
(document_page_count as u32).saturating_mul(4).max(100);
let implausible_folio_with_lexical_context =
value > max_plausible_folio && has_lexical_context;
contextual[index] = adjacent_context
|| has_dense_numeric_context
|| implausible_folio_with_lexical_context;
let previous = position
.checked_sub(1)
.map(|position| items[group[position]].text.trim());
let next = group
.get(position + 1)
.map(|&index| items[index].text.trim());
let follows_page_label =
previous.is_some_and(|text| text.eq_ignore_ascii_case("page"));
let starts_of_expression =
next.is_some_and(|text| text.eq_ignore_ascii_case("of"));
let is_centered_folio = previous == Some("-") && next == Some("-");
explicit_folio[index] |= group_is_folio
&& (follows_page_label
|| starts_of_expression
|| is_centered_folio);
}
}
if group_is_folio
&& group.len() >= 4
&& items[group[0]].text.trim().eq_ignore_ascii_case("page")
&& candidate_values[group[1]].is_some()
&& items[group[2]].text.trim().eq_ignore_ascii_case("of")
&& items[group[3]]
.text
.trim()
.chars()
.all(|character| character.is_ascii_digit())
{
for &index in &group[..4] {
explicit_folio[index] = true;
}
}
}
start = end;
}
}
}
mark_repeated_folio_candidates(
occurrences_by_signature,
document_page_count,
&mut explicit_folio,
);
mark_spread_folio_pairs(items, candidate_values, &contextual, &mut explicit_folio);
mark_adjacent_page_folio_pairs(
items,
candidate_values,
&contextual,
document_page_count,
&mut explicit_folio,
);
(contextual, explicit_folio)
}
fn page_number_removal_mask(items: &[TextItem], document_page_count: usize) -> Vec<bool> {
let candidate_values: Vec<Option<u32>> = items.iter().map(page_number_value).collect();
let (contextual, explicit_folio) =
page_number_context_masks(items, &candidate_values, document_page_count);
candidate_values
.iter()
.enumerate()
.map(|(index, value)| explicit_folio[index] || (value.is_some() && !contextual[index]))
.collect()
}
pub(super) fn needs_document_page_number_context(
items: &[TextItem],
document_page_count: usize,
) -> bool {
let candidate_values: Vec<Option<u32>> = items.iter().map(page_number_value).collect();
let (contextual, explicit_folio) =
page_number_context_masks(items, &candidate_values, document_page_count);
candidate_values
.iter()
.enumerate()
.any(|(index, value)| value.is_some() && contextual[index] && !explicit_folio[index])
}
#[cfg(test)]
pub(crate) fn filter_markdown_page_numbers(
items: Vec<TextItem>,
document_page_count: u32,
) -> Vec<TextItem> {
filter_markdown_page_numbers_with_removed_pages(items, document_page_count).0
}
pub(crate) fn filter_markdown_page_numbers_with_removed_pages(
items: Vec<TextItem>,
document_page_count: u32,
) -> (Vec<TextItem>, HashSet<u32>, Vec<bool>) {
let remove = page_number_removal_mask(&items, document_page_count as usize);
let mut removed_pages = HashSet::new();
let items = items
.into_iter()
.zip(remove.iter().copied())
.filter_map(|(item, remove)| {
if remove {
removed_pages.insert(item.page);
None
} else {
Some(item)
}
})
.collect();
(items, removed_pages, remove)
}
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 short_prose_columns(per_column_lines: &[Vec<TextLine>], columns: &[ColumnRegion]) -> bool {
if per_column_lines.len() != columns.len() || columns.len() < 2 {
return false;
}
let min_lines = per_column_lines.iter().map(|c| c.len()).min().unwrap_or(0);
if !(5..15).contains(&min_lines) {
return false;
}
per_column_lines.iter().zip(columns).all(|(lines, col)| {
let col_width = (col.x_max - col.x_min).max(1.0);
let full = lines
.iter()
.filter(|line| {
let left = line.items.iter().map(|i| i.x).fold(f32::INFINITY, f32::min);
let right = line
.items
.iter()
.map(|i| i.x + effective_width(i))
.fold(f32::NEG_INFINITY, f32::max);
right - left >= col_width * 0.60
})
.count();
full * 10 >= lines.len() * 6
})
}
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.saturating_sub(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_prefiltered_items_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_impl(
items,
page_thresholds,
table_pages,
chart_regions,
&HashMap::new(),
false,
)
}
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,
)
}
pub(crate) fn group_prefiltered_items_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,
false,
)
}
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 {
let observed_page_count = items
.iter()
.map(|item| item.page as usize)
.max()
.unwrap_or(0);
let remove = page_number_removal_mask(&items, observed_page_count);
items
.into_iter()
.zip(remove)
.filter_map(|(item, remove)| (!remove).then_some(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 column_detection_items: Vec<TextItem> = page_items
.iter()
.filter(|item| page_number_value(item).is_none())
.cloned()
.collect();
let column_detection_items = column_detection_items.as_slice();
let adaptive_threshold = page_thresholds.get(&page).copied().unwrap_or(0.10);
if !chart_regions.contains_key(&page) {
let preliminary_columns =
detect_columns(column_detection_items, page, table_pages.contains(&page));
let detected_split =
(preliminary_columns.len() == 2).then(|| 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| {
if page_number_value(it).is_some() {
return false;
}
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(column_detection_items, page, table_pages.contains(&page)),
};
let banded = if chart_regions.contains_key(&page) {
None
} else {
try_banded_layout(
&page_items,
column_detection_items,
&columns,
page,
table_pages.contains(&page),
adaptive_threshold,
)
};
if let Some(lines) = banded {
all_lines.extend(lines);
} else if columns.len() <= 1 {
all_lines.extend(group_single_column(page_items, adaptive_threshold));
} else {
all_lines.extend(order_multi_column_region(
page_items,
&columns,
adaptive_threshold,
page,
));
}
}
all_lines
}
fn order_multi_column_region(
page_items: Vec<TextItem>,
columns: &[ColumnRegion],
adaptive_threshold: f32,
page: u32,
) -> Vec<TextLine> {
order_columns_with_policy(page_items, columns, adaptive_threshold, page, false)
}
fn order_validated_band(
page_items: Vec<TextItem>,
columns: &[ColumnRegion],
adaptive_threshold: f32,
page: u32,
) -> Vec<TextLine> {
order_columns_with_policy(page_items, columns, adaptive_threshold, page, true)
}
fn order_columns_with_policy(
page_items: Vec<TextItem>,
columns: &[ColumnRegion],
adaptive_threshold: f32,
page: u32,
band_validated: bool,
) -> Vec<TextLine> {
let mut all_lines = Vec::new();
{
{
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 = band_validated
|| is_newspaper_layout(&per_column_lines, columns)
|| short_prose_columns(&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 {
if band_validated {
core_columns.push(col);
col_stragglers.push(Vec::new());
continue;
}
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
}
#[derive(Debug, Clone, Copy)]
struct YBand {
y_top: f32,
y_bottom: f32,
}
impl YBand {
fn contains(&self, y: f32) -> bool {
y <= self.y_top && y > self.y_bottom
}
}
fn split_into_y_bands(detection_items: &[TextItem]) -> (Vec<YBand>, Vec<(f32, f32)>) {
const MIN_GAP: f32 = 14.0;
const LEADING_FACTOR: f32 = 1.4;
const WIDE_FRACTION: f32 = 0.6;
let text_items: Vec<&TextItem> = detection_items
.iter()
.filter(|i| crate::extractor::is_text_layout_item(i))
.collect();
let (x_min, x_max) = text_items
.iter()
.fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), i| {
(lo.min(i.x), hi.max(i.x + effective_width(i)))
});
if !(x_max - x_min).is_finite() {
return (vec![], vec![]);
}
let wide_threshold = (x_max - x_min) * WIDE_FRACTION;
let mut intervals: Vec<(f32, f32)> = text_items
.iter()
.filter(|i| effective_width(i) <= wide_threshold)
.map(|i| (i.y + i.height.max(0.0), i.y))
.filter(|(top, bottom)| top.is_finite() && bottom.is_finite())
.collect();
if intervals.len() < 10 {
return (vec![], vec![]);
}
intervals.sort_by(|a, b| b.0.total_cmp(&a.0));
let mut baselines: Vec<f32> = intervals.iter().map(|&(_, bottom)| bottom).collect();
baselines.sort_by(|a, b| b.total_cmp(a));
let mut steps: Vec<f32> = baselines
.windows(2)
.map(|w| w[0] - w[1])
.filter(|d| *d > 1.0)
.collect();
steps.sort_by(|a, b| a.total_cmp(b));
let median_leading = steps.get(steps.len() / 2).copied().unwrap_or(12.0);
let gap_threshold = (median_leading * LEADING_FACTOR).max(MIN_GAP);
let mut cuts: Vec<(f32, f32)> = Vec::new();
let mut largest_rejected = 0.0f32;
let mut run_bottom = intervals[0].1;
for &(top, bottom) in &intervals[1..] {
if run_bottom - top >= gap_threshold {
cuts.push((run_bottom, top));
run_bottom = bottom;
} else {
largest_rejected = largest_rejected.max(run_bottom - top);
run_bottom = run_bottom.min(bottom);
}
}
log::trace!(
"y-bands: {} intervals, leading {:.1}, threshold {:.1}, {} cuts, largest rejected gap {:.1}",
intervals.len(),
median_leading,
gap_threshold,
cuts.len(),
largest_rejected
);
if cuts.is_empty() {
return (vec![], vec![]);
}
let mut bands = Vec::with_capacity(cuts.len() + 1);
let mut top = f32::INFINITY;
for &(gap_top, gap_bottom) in &cuts {
bands.push(YBand {
y_top: top,
y_bottom: (gap_top + gap_bottom) / 2.0,
});
top = (gap_top + gap_bottom) / 2.0;
}
bands.push(YBand {
y_top: top,
y_bottom: f32::NEG_INFINITY,
});
(bands, cuts)
}
fn columns_match(a: &[ColumnRegion], b: &[ColumnRegion]) -> bool {
const GUTTER_TOLERANCE: f32 = 25.0;
if a.len() != b.len() || a.len() < 2 {
return false;
}
std::iter::zip(a.windows(2), b.windows(2)).all(|(wa, wb)| {
let gutter_a = (wa[0].x_max + wa[1].x_min) / 2.0;
let gutter_b = (wb[0].x_max + wb[1].x_min) / 2.0;
(gutter_a - gutter_b).abs() <= GUTTER_TOLERANCE
})
}
fn try_banded_layout(
page_items: &[TextItem],
detection_items: &[TextItem],
page_columns: &[ColumnRegion],
page: u32,
page_has_table: bool,
adaptive_threshold: f32,
) -> Option<Vec<TextLine>> {
const MIN_ITEMS: usize = 40;
if page_has_table || detection_items.len() < MIN_ITEMS {
return None;
}
if page_items.iter().any(|i| !i.y.is_finite()) {
return None;
}
let (bands, gaps) = split_into_y_bands(detection_items);
if bands.len() < 2 {
return None;
}
struct BandPlan {
band: YBand,
columns: Vec<ColumnRegion>,
anchor_columns: Vec<ColumnRegion>,
}
let mut plans: Vec<BandPlan> = Vec::new();
for band in bands {
let band_detection: Vec<TextItem> = detection_items
.iter()
.filter(|i| band.contains(i.y))
.cloned()
.collect();
let columns = detect_columns(&band_detection, page, false);
let refs: Vec<&TextItem> = band_detection.iter().collect();
let columns = if columns.len() > 1 && columns_have_prose(&columns, &refs) {
columns
} else {
vec![]
};
plans.push(BandPlan {
band,
anchor_columns: columns.clone(),
columns,
});
}
let page_count = page_columns.len().max(1);
if !plans
.iter()
.any(|p| p.columns.len() > 1 && p.columns.len() != page_count)
{
return None;
}
let mut sorted_ys: Vec<f32> = page_items
.iter()
.filter(|i| crate::extractor::is_text_layout_item(i))
.map(|i| i.y)
.collect();
sorted_ys.sort_by(|a, b| b.total_cmp(a));
let gap_has_content = |gap: (f32, f32)| -> bool {
let first_below_top = sorted_ys.partition_point(|&y| y >= gap.0);
first_below_top < sorted_ys.len() && sorted_ys[first_below_top] > gap.1
};
let mut merged: Vec<BandPlan> = Vec::new();
for (idx, plan) in plans.into_iter().enumerate() {
if idx > 0 {
if let Some(prev) = merged.last_mut() {
if columns_match(&prev.anchor_columns, &plan.columns)
&& !gap_has_content(gaps[idx - 1])
{
prev.band.y_bottom = plan.band.y_bottom;
for (pc, nc) in prev.columns.iter_mut().zip(&plan.columns) {
pc.x_min = pc.x_min.min(nc.x_min);
pc.x_max = pc.x_max.max(nc.x_max);
}
continue;
}
}
}
merged.push(plan);
}
debug!(
"page {}: banded layout: {} bands ({} multi-column)",
page,
merged.len(),
merged.iter().filter(|p| p.columns.len() > 1).count()
);
let mut band_items: Vec<Vec<TextItem>> = (0..merged.len()).map(|_| Vec::new()).collect();
for item in page_items {
let idx = merged.partition_point(|p| p.band.y_bottom >= item.y);
band_items[idx.min(merged.len() - 1)].push(item.clone());
}
let mut out = Vec::new();
for (plan, items) in merged.iter().zip(band_items) {
if items.is_empty() {
continue;
}
if plan.columns.len() > 1 {
out.extend(order_validated_band(
items,
&plan.columns,
adaptive_threshold,
page,
));
} else if page_count > 1 {
out.extend(order_multi_column_region(
items,
page_columns,
adaptive_threshold,
page,
));
} else {
out.extend(group_single_column(items, adaptive_threshold));
}
}
Some(out)
}
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(),
font_tag: String::new(),
page,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
}
}
fn prose_block(x: f32, y_top: f32, lines: usize, tag: &str) -> Vec<TextItem> {
(0..lines)
.map(|i| {
make_item(
1,
x,
y_top - i as f32 * 14.0,
&format!("{tag}{i:02} word word word word word"),
)
})
.collect()
}
#[test]
fn y_bands_split_at_full_width_gap() {
let mut items = two_column_band(700.0, 12, "TA", "TB");
items.extend(two_column_band(480.0, 12, "BA", "BB"));
let (bands, gaps) = split_into_y_bands(&items);
assert_eq!(bands.len(), 2, "one full-width gap must yield two bands");
assert_eq!(gaps.len(), 1);
assert!(bands[0].y_bottom < 546.0 && bands[0].y_bottom > 492.0);
}
#[test]
fn y_bands_ignore_wide_separator_inside_gap() {
let mut items = two_column_band(700.0, 12, "TA", "TB");
items.extend(two_column_band(480.0, 12, "BA", "BB"));
items.push(make_item(1, 50.0, 520.0, &"m".repeat(80)));
let (bands, _) = split_into_y_bands(&items);
assert_eq!(bands.len(), 2, "wide separator must not suppress the cut");
}
#[test]
fn y_bands_no_cut_in_continuous_text() {
let items = two_column_band(700.0, 30, "LL", "RR");
let (bands, _) = split_into_y_bands(&items);
assert!(bands.is_empty(), "uniform leading must produce no bands");
}
#[test]
fn columns_match_requires_count_and_gutter() {
let two = |g0: f32| {
vec![
ColumnRegion {
x_min: 0.0,
x_max: g0,
},
ColumnRegion {
x_min: g0 + 20.0,
x_max: 500.0,
},
]
};
assert!(columns_match(&two(240.0), &two(250.0)));
assert!(!columns_match(&two(240.0), &two(320.0)));
assert!(!columns_match(&two(240.0), &[]));
}
fn two_column_band(y_top: f32, lines: usize, left_tag: &str, right_tag: &str) -> Vec<TextItem> {
let mut items = Vec::new();
for i in 0..lines {
let y = y_top - i as f32 * 14.0;
items.push(make_item(
1,
50.0,
y,
&format!("{left_tag}{i:02} {}", "x".repeat(26)),
));
items.push(make_item(
1,
310.0,
y,
&format!("{right_tag}{i:02} {}", "x".repeat(26)),
));
}
items
}
fn joined_order(lines: &[TextLine]) -> String {
lines
.iter()
.flat_map(|l| l.items.iter())
.map(|i| i.text.split(' ').next().unwrap_or("").to_string())
.collect::<Vec<_>>()
.join(",")
}
#[test]
fn banded_layout_orders_mismatched_band_sequentially() {
let mut items = two_column_band(700.0, 12, "L", "R");
items.extend(prose_block(150.0, 480.0, 16, "B"));
let lines = try_banded_layout(&items, &items, &[], 1, false, 0.10)
.expect("contradicting band evidence must engage");
let order = joined_order(&lines);
let li = order.find("L00").unwrap();
let ri = order.find("R00").unwrap();
let bi = order.find("B0").unwrap();
assert!(li < ri && ri < bi, "expected L*, R*, B* order, got {order}");
assert!(
order.find("L11").unwrap() < ri,
"left column must complete before right column starts: {order}"
);
}
#[test]
fn banded_layout_merges_matching_bands_across_empty_gap() {
let mut items = two_column_band(700.0, 12, "LA", "RA");
items.extend(two_column_band(460.0, 12, "LB", "RB"));
let lines = try_banded_layout(&items, &items, &[], 1, false, 0.10)
.expect("multi-column bands on a single-column page must engage");
let order = joined_order(&lines);
assert!(
order.find("LB00").unwrap() < order.find("RA00").unwrap(),
"columns must flow through the empty gap (LB before RA): {order}"
);
}
fn gutter_band(
items: &mut Vec<TextItem>,
y_top: f32,
left_chars: usize,
right_x: f32,
tag: &str,
) {
for i in 0..12 {
let y = y_top - i as f32 * 14.0;
items.push(make_item(
1,
50.0,
y,
&format!("L{tag}{i:02} {}", "x".repeat(left_chars - 6)),
));
items.push(make_item(
1,
right_x,
y,
&format!("R{tag}{i:02} {}", "x".repeat(29)),
));
}
}
#[test]
fn banded_layout_merge_anchors_on_founding_band() {
let mut items = Vec::new();
gutter_band(&mut items, 700.0, 38, 330.0, "A"); gutter_band(&mut items, 460.0, 42, 352.0, "B"); gutter_band(&mut items, 220.0, 38, 330.0, "C"); let lines = try_banded_layout(&items, &items, &[], 1, false, 0.10)
.expect("multi-column bands on a single-column page must engage");
let order = joined_order(&lines);
assert!(
order.find("LC00").unwrap() < order.find("RA00").unwrap(),
"founder-identical band must stay in the founder's run \
(its left column reads before any right column): {order}"
);
}
#[test]
fn banded_layout_rejects_creeping_drift() {
let mut items = Vec::new();
gutter_band(&mut items, 700.0, 38, 330.0, "A"); gutter_band(&mut items, 460.0, 42, 352.0, "B"); gutter_band(&mut items, 220.0, 45, 360.0, "C"); let lines = try_banded_layout(&items, &items, &[], 1, false, 0.10)
.expect("multi-column bands on a single-column page must engage");
let order = joined_order(&lines);
assert!(
order.find("RA00").unwrap() < order.find("LC00").unwrap(),
"a band beyond tolerance of the founder must not join its run: {order}"
);
}
#[test]
fn banded_layout_merges_across_gap_holding_figure_placeholder() {
let mut items = two_column_band(700.0, 12, "LA", "RA");
items.extend(two_column_band(460.0, 12, "LB", "RB"));
let mut figure = make_item(1, 100.0, 505.0, "[img]");
figure.item_type = ItemType::Image;
items.push(figure);
let lines = try_banded_layout(&items, &items, &[], 1, false, 0.10)
.expect("multi-column bands on a single-column page must engage");
let order = joined_order(&lines);
assert!(
order.find("LB00").unwrap() < order.find("RA00").unwrap(),
"figure placeholder in the gap must not block the merge: {order}"
);
}
#[test]
fn banded_layout_keeps_bands_apart_across_separator() {
let mut items = two_column_band(700.0, 12, "LA", "RA");
items.extend(two_column_band(460.0, 12, "LB", "RB"));
items.push(make_item(1, 50.0, 505.0, &"m".repeat(80)));
let lines = try_banded_layout(&items, &items, &[], 1, false, 0.10)
.expect("multi-column bands on a single-column page must engage");
let order = joined_order(&lines);
assert!(
order.find("RA00").unwrap() < order.find("LB00").unwrap(),
"separator must keep bands sequential (RA before LB): {order}"
);
}
#[test]
fn short_prose_columns_read_newspaper() {
let cols: Vec<ColumnRegion> = (0..3)
.map(|c| ColumnRegion {
x_min: c as f32 * 200.0,
x_max: c as f32 * 200.0 + 190.0,
})
.collect();
let per_column: Vec<Vec<TextLine>> = (0..3)
.map(|c| {
(0..8)
.map(|i| {
let y = 700.0 - i as f32 * 14.0;
let item = make_item(1, c as f32 * 200.0 + 5.0, y, &"m".repeat(30));
TextLine {
y,
page: 1,
adaptive_threshold: 0.10,
items: vec![item],
}
})
.collect()
})
.collect();
assert!(short_prose_columns(&per_column, &cols));
assert!(!is_newspaper_layout(&per_column, &cols));
}
#[test]
fn short_cell_columns_stay_tabular() {
let cols = vec![
ColumnRegion {
x_min: 0.0,
x_max: 190.0,
},
ColumnRegion {
x_min: 200.0,
x_max: 390.0,
},
];
let make_col = |x: f32, text: &str| -> Vec<TextLine> {
(0..8)
.map(|i| {
let y = 700.0 - i as f32 * 14.0;
let item = make_item(1, x, y, text);
TextLine {
y,
page: 1,
adaptive_threshold: 0.10,
items: vec![item],
}
})
.collect()
};
let per_column = vec![make_col(5.0, "term"), make_col(205.0, &"m".repeat(30))];
assert!(!short_prose_columns(&per_column, &cols));
assert!(!is_newspaper_layout(&per_column, &cols));
}
#[test]
fn prose_gate_accepts_figure_diluted_column_via_run() {
let mut items: Vec<TextItem> = Vec::new();
for line in 0..9 {
items.push(make_item(
1,
10.0,
700.0 - line as f32 * 14.0,
&"m".repeat(38),
));
}
for line in 0..16 {
items.push(make_item(1, 60.0, 560.0 - line as f32 * 14.0, "cap"));
}
let column = ColumnRegion {
x_min: 0.0,
x_max: 250.0,
};
let refs: Vec<&TextItem> = items.iter().collect();
assert!(columns_have_prose(&[column], &refs));
}
#[test]
fn prose_gate_run_requires_vertical_continuity() {
let mut items: Vec<TextItem> = Vec::new();
for line in 0..6 {
items.push(make_item(
1,
10.0,
720.0 - line as f32 * 90.0,
&"m".repeat(38),
));
}
for line in 0..12 {
items.push(make_item(1, 60.0, 150.0 - line as f32 * 12.0, "box"));
}
let column = ColumnRegion {
x_min: 0.0,
x_max: 250.0,
};
let refs: Vec<&TextItem> = items.iter().collect();
assert!(!columns_have_prose(&[column], &refs));
}
#[test]
fn prose_gate_rejects_scattered_short_lines() {
let mut items: Vec<TextItem> = Vec::new();
for line in 0..24 {
let text = if line % 4 == 0 {
"m".repeat(38)
} else {
"box".to_string()
};
items.push(make_item(1, 10.0, 700.0 - line as f32 * 14.0, &text));
}
let column = ColumnRegion {
x_min: 0.0,
x_max: 250.0,
};
let refs: Vec<&TextItem> = items.iter().collect();
assert!(!columns_have_prose(&[column], &refs));
}
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 extreme_far_coordinate_does_not_allocate_unboundedly() {
let mut items = Vec::new();
for i in 0..24 {
items.push(make_item(1, i as f32 * 10.0, 700.0 - i as f32 * 5.0, "A"));
}
items.push(make_item(1, 1e12, 700.0, "Z"));
let cols = detect_columns(&items, 1, false);
assert!(!cols.is_empty());
}
#[test]
fn non_finite_coordinates_never_leak_into_region_bounds() {
for bad_x in [f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
let mut items = Vec::new();
for i in 0..24 {
items.push(make_item(1, i as f32 * 10.0, 700.0 - i as f32 * 5.0, "A"));
}
items.push(make_item(1, bad_x, 700.0, "Z"));
for col in detect_columns(&items, 1, false) {
assert!(
col.x_min.is_finite() && col.x_max.is_finite(),
"bad_x {bad_x} leaked bounds {}..{}",
col.x_min,
col.x_max
);
}
}
}
#[test]
fn all_non_finite_coordinates_yield_no_columns() {
let items: Vec<TextItem> = (0..24)
.map(|i| make_item(1, f32::NAN, 700.0 - i as f32 * 5.0, "A"))
.collect();
assert!(detect_columns(&items, 1, false).is_empty());
}
#[test]
fn one_bad_item_does_not_disable_column_detection() {
for (label, bad_x, bad_width) in [
("nan position", f32::NAN, 0.0),
("inf position", f32::INFINITY, 0.0),
("far position", 50_000.0, 0.0),
("very far position", 1e12, 0.0),
("huge width", 100.0, 1e12),
("inf width", 100.0, f32::INFINITY),
] {
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 mut bad = make_item(1, bad_x, 400.0, "Z");
bad.width = bad_width;
items.push(bad);
let cols = detect_columns(&items, 1, false);
assert_eq!(
cols.len(),
2,
"{label}: expected 2 columns, got {}",
cols.len()
);
for col in &cols {
assert!(
col.x_max - col.x_min <= MAX_PAGE_EXTENT_FOR_TEST,
"{label}: region {}..{} exceeds one page",
col.x_min,
col.x_max
);
}
}
}
const MAX_PAGE_EXTENT_FOR_TEST: f32 = 14_400.0;
#[test]
fn very_wide_page_keeps_full_histogram_coverage() {
let mut items = Vec::new();
items.extend(fill_zone(1, 0.0, 60_000.0, 750.0, 700.0));
items.extend(fill_zone(1, 70_000.0, 140_000.0, 750.0, 700.0));
items.extend(fill_zone(1, 160_000.0, 200_000.0, 750.0, 700.0));
let cols = detect_columns(&items, 1, false);
assert_eq!(
cols.len(),
3,
"Expected 3 columns across a 200k-wide page, got {}",
cols.len()
);
assert!(
(140_000.0..=160_000.0).contains(&cols[1].x_max),
"second gutter at {}, expected inside the real 140k..160k gap",
cols[1].x_max
);
}
#[test]
fn large_page_with_legitimately_long_runs_is_kept() {
let mut items = Vec::new();
for row in 0..30 {
let y = 750.0 - row as f32 * 14.0;
let mut left = make_item(1, 0.0, y, "Left run");
left.width = 20_000.0;
let mut right = make_item(1, 25_000.0, y, "Right run");
right.width = 20_000.0;
items.extend([left, right]);
}
let cols = detect_columns(&items, 1, false);
assert!(
!cols.is_empty(),
"a page of long-but-valid runs must still report a layout"
);
let right_edge = cols
.iter()
.map(|c| c.x_max)
.fold(f32::NEG_INFINITY, f32::max);
assert!(
right_edge > 44_000.0,
"long runs were treated as malformed: right edge {right_edge}, expected ~45_000"
);
}
#[test]
fn sparse_far_sidebar_on_a_large_page_is_kept() {
let mut items = Vec::new();
items.extend(fill_zone(1, 0.0, 12_000.0, 750.0, 500.0));
for i in 0..12 {
items.push(make_item(1, 24_000.0, 750.0 - i as f32 * 14.0, "Sidebar"));
}
let cols = detect_columns(&items, 1, false);
let right_edge = cols
.iter()
.map(|c| c.x_max)
.fold(f32::NEG_INFINITY, f32::max);
assert!(
right_edge > 24_000.0,
"sidebar was trimmed away: right edge {right_edge}, expected >24_000"
);
}
#[test]
fn genuinely_wide_layout_keeps_its_true_bounds() {
let mut items = Vec::new();
items.extend(fill_zone(1, 100.0, 20_000.0, 750.0, 600.0));
items.extend(fill_zone(1, 22_000.0, 40_000.0, 750.0, 600.0));
let cols = detect_columns(&items, 1, false);
let widest = cols
.iter()
.map(|c| c.x_max)
.fold(f32::NEG_INFINITY, f32::max);
assert!(
widest > 35_000.0,
"wide layout was trimmed: right edge {widest}, expected ~40_000"
);
}
#[test]
fn oversized_but_legal_page_is_not_trimmed() {
let mut items = Vec::new();
items.extend(fill_zone(1, 100.0, 4_000.0, 750.0, 400.0));
items.extend(fill_zone(1, 4_400.0, 8_000.0, 750.0, 400.0));
let cols = detect_columns(&items, 1, false);
assert_eq!(cols.len(), 2, "Expected 2 columns, got {}", cols.len());
assert!(
cols[1].x_max > 7_000.0,
"right column should keep its true extent, got {}",
cols[1].x_max
);
}
#[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(),
font_tag: 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");
}
}