* feat(layout): order image-anchored regions * fix(layout): preserve region flow boundaries * fix(layout): gate image-backed column flows
2115 lines
76 KiB
Rust
2115 lines
76 KiB
Rust
//! Column detection, line grouping, and reading-order layout.
|
||
|
||
use std::collections::{HashMap, HashSet};
|
||
|
||
use crate::text_utils::{effective_width, sort_line_items};
|
||
use crate::types::{TextItem, TextLine};
|
||
use log::debug;
|
||
|
||
/// Represents a column region on a page
|
||
#[derive(Debug, Clone)]
|
||
pub(crate) struct ColumnRegion {
|
||
pub(crate) x_min: f32,
|
||
pub(crate) x_max: f32,
|
||
}
|
||
|
||
/// Detect column boundaries on a page using a horizontal projection profile.
|
||
///
|
||
/// Builds an occupancy histogram across the page width and finds empty valleys
|
||
/// (gutters) where no text exists. Validates valleys with vertical consistency
|
||
/// checks to avoid false positives.
|
||
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;
|
||
|
||
// Get items for this page. Strip Image placeholders — an image's left edge
|
||
// would otherwise count toward the column projection profile.
|
||
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());
|
||
|
||
// Find page bounds
|
||
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 }];
|
||
}
|
||
|
||
// Build occupancy histogram.
|
||
// Exclude items wider than 60% of page width — these are spanning items
|
||
// (titles, full-width paragraphs) that would fill the gutter and prevent
|
||
// detection of partial-page column layouts (e.g. two-column abstracts on
|
||
// a page that also has single-column introduction text).
|
||
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;
|
||
}
|
||
}
|
||
|
||
// Find the noise threshold: bins with count <= max_count * NOISE_FRACTION are "empty"
|
||
let max_count = *histogram.iter().max().unwrap_or(&0);
|
||
let noise_threshold = (max_count as f32 * NOISE_FRACTION) as u32;
|
||
|
||
// Find empty valleys (consecutive runs of low-count bins)
|
||
// Each valley is stored as (start_bin, end_bin)
|
||
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;
|
||
}
|
||
}
|
||
// Close any valley that extends to the end
|
||
if let Some(start) = valley_start {
|
||
valleys.push((start, num_bins));
|
||
}
|
||
|
||
// Filter valleys: must be wide enough and not at page margins
|
||
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;
|
||
}
|
||
// Valley center must not be within 5% of page edges
|
||
let center_pts = ((start + end) as f32 / 2.0) * BIN_WIDTH;
|
||
center_pts > margin_threshold && center_pts < (page_width - margin_threshold)
|
||
})
|
||
.collect();
|
||
|
||
// Fallback: if no absolute valleys found, try relative valley detection.
|
||
// Justified text can leave gutter bins non-empty because item widths extend
|
||
// to the column edge. Look for local minima that are significantly lower
|
||
// than the peaks on either side.
|
||
// Only attempt this for dense pages (>=100 items) — sparse pages with shallow
|
||
// histogram dips are likely not multi-column.
|
||
// Skip on pages with detected tables — table column gaps look like gutters
|
||
// in the histogram but the table pipeline already handles reading order.
|
||
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, // center-based assignment for relative valleys
|
||
);
|
||
if result.len() > 1 {
|
||
// Validate that both sides contain paragraph-like content.
|
||
// Tables, forms, and checklists have short scattered items
|
||
// that create false gutter signals. Only commit to relative
|
||
// valley columns when both sides look like flowing prose.
|
||
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,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// Try XY-cut fallback before giving up
|
||
if let Some(columns) = try_xy_cut_split(&page_items, x_min, x_max, page) {
|
||
return columns;
|
||
}
|
||
return vec![ColumnRegion { x_min, x_max }];
|
||
}
|
||
|
||
// Try center-based assignment first (handles asymmetric layouts / sidebars
|
||
// better than edge-based). Fall back to edge-based if center produces
|
||
// a degenerate split (one side empty).
|
||
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, // center-based assignment
|
||
);
|
||
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, // edge-based fallback
|
||
);
|
||
if result.len() > 1 {
|
||
return result;
|
||
}
|
||
|
||
// Fallback: XY-cut style gap detection. When the histogram finds no
|
||
// clear valleys (common with asymmetric/sidebar layouts), look for the
|
||
// largest horizontal gap between item edges. This is a simplified
|
||
// single-level XY-cut inspired by opendataloader's XY-Cut++ algorithm.
|
||
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 }]
|
||
}
|
||
|
||
/// Simplified single-level XY-cut: find the largest horizontal gap between
|
||
/// item right-edges and left-edges. If the gap is wide enough and both sides
|
||
/// have sufficient items with vertical overlap, split into two columns.
|
||
///
|
||
/// Inspired by opendataloader's XY-Cut++ algorithm but without full recursion.
|
||
/// Handles asymmetric layouts (sidebars) that the histogram misses because
|
||
/// the narrow column has too few items to register in the occupancy profile.
|
||
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; // minimum gap to consider a split
|
||
const MIN_ITEMS_MAJOR: usize = 10; // major column must have ≥10 items
|
||
const MIN_ITEMS_MINOR: usize = 3; // minor column (sidebar) must have ≥3
|
||
|
||
let page_width = page_x_max - page_x_min;
|
||
if page_width < 200.0 {
|
||
return None;
|
||
}
|
||
|
||
// Collect all item edges: (right_edge, left_edge) pairs sorted by right_edge
|
||
// The gap between one item's right edge and the next item's left edge
|
||
// reveals column gutters.
|
||
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));
|
||
|
||
// Find the largest gap between consecutive items (by left edge).
|
||
// Use a sweep: sort left edges, find max gap between sorted right edges
|
||
// of items to the left and left edges of items to the right.
|
||
let mut left_edges: Vec<f32> = page_items.iter().map(|i| i.x).collect();
|
||
left_edges.sort_by(|a, b| a.total_cmp(b));
|
||
|
||
// Build prefix max of right edges (for items sorted by left edge)
|
||
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;
|
||
}
|
||
|
||
// Don't split at page margins (within 10% of edges)
|
||
let margin = page_width * 0.10;
|
||
if best_split - page_x_min < margin || page_x_max - best_split < margin {
|
||
return None;
|
||
}
|
||
|
||
// Count items on each side
|
||
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;
|
||
}
|
||
|
||
// Check vertical overlap — both sides should span a meaningful Y range
|
||
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,
|
||
},
|
||
])
|
||
}
|
||
|
||
/// Check whether each proposed column contains paragraph-like content.
|
||
///
|
||
/// Groups items per column into rough lines by Y-proximity, then measures
|
||
/// what fraction of those lines span a significant portion of the column
|
||
/// width. Two-column prose (justified or ragged-right) produces lines that
|
||
/// fill most of the column width. Tables, forms, and checklists produce
|
||
/// short scattered items that don't.
|
||
///
|
||
/// Returns true only when *every* column passes a minimum prose density.
|
||
fn columns_have_prose(columns: &[ColumnRegion], items: &[&TextItem]) -> bool {
|
||
const Y_TOL: f32 = 3.0; // y-proximity to group items into the same line
|
||
const LINE_FILL_THRESHOLD: f32 = 0.45; // line must span ≥45% of column width
|
||
const MIN_PROSE_RATIO: f32 = 0.40; // ≥40% of lines must be "full"
|
||
const MIN_LINES: usize = 8; // need enough lines to judge
|
||
const MIN_COL_WIDTH: f32 = 120.0; // columns must be ≥120pt (not narrow sidebars/fragments)
|
||
const MAX_AVG_ITEMS_PER_LINE: f32 = 3.5; // prose has 1-3 items/line; tables/forms have 4+
|
||
|
||
for col in columns {
|
||
let col_width = col.x_max - col.x_min;
|
||
if col_width < MIN_COL_WIDTH {
|
||
return false;
|
||
}
|
||
|
||
// Collect items whose center falls within this column
|
||
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;
|
||
}
|
||
|
||
// Sort by Y descending (top of page = higher Y in PDF coords)
|
||
let mut sorted: Vec<&TextItem> = col_items;
|
||
sorted.sort_by(|a, b| b.y.total_cmp(&a.y));
|
||
|
||
// Group into lines by Y-proximity and measure fill + item count
|
||
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();
|
||
// Compute the span of text on this line within the column
|
||
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;
|
||
}
|
||
// Tables and forms tend to have many small items per line (one per cell),
|
||
// while prose has few items per line (one per word-run or phrase).
|
||
if avg_items > MAX_AVG_ITEMS_PER_LINE {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
true
|
||
}
|
||
|
||
/// Find relative valleys (local minima) in the histogram.
|
||
///
|
||
/// When justified text fills gutters, the absolute noise threshold fails.
|
||
/// This finds local minima where the count drops significantly below
|
||
/// the peaks on either side — indicating a gutter even when not empty.
|
||
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; // minimum 4pt gutter
|
||
const CONTRAST_THRESHOLD: f32 = 0.60; // valley must be < 60% of surrounding peaks
|
||
const PEAK_WINDOW: usize = 25; // look 50pt on each side for peaks
|
||
const MIN_PEAK_HEIGHT: f32 = 20.0; // peaks must be ≥20 (dense text columns)
|
||
|
||
if num_bins < 10 {
|
||
return vec![];
|
||
}
|
||
|
||
// Smooth histogram with a 5-bin moving average to reduce noise
|
||
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;
|
||
}
|
||
|
||
// Find local minima: positions where smoothed value is lower than
|
||
// both sides within a search window
|
||
let mut candidates: Vec<(usize, f32, f32)> = Vec::new(); // (bin, valley_val, contrast)
|
||
|
||
for i in PEAK_WINDOW..num_bins.saturating_sub(PEAK_WINDOW) {
|
||
let val = smoothed[i];
|
||
if val < 1.0 {
|
||
continue; // skip empty margins
|
||
}
|
||
|
||
// Check this is a local minimum within a small window
|
||
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;
|
||
}
|
||
|
||
// Find peak values on each side
|
||
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;
|
||
}
|
||
|
||
// Both peaks must be substantial — prevents detecting margin drop-offs
|
||
// as gutters in single-column layouts with ragged text.
|
||
let peak_balance = left_peak.min(right_peak) / left_peak.max(right_peak);
|
||
if peak_balance < 0.40 {
|
||
continue;
|
||
}
|
||
|
||
// Contrast: ratio of valley to the smaller of the two peaks
|
||
let ref_peak = left_peak.min(right_peak);
|
||
let contrast = val / ref_peak;
|
||
|
||
if contrast < CONTRAST_THRESHOLD {
|
||
// Check margin constraint
|
||
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![];
|
||
}
|
||
|
||
// Group adjacent candidates into valley ranges and pick the deepest point
|
||
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 {
|
||
// Same group
|
||
if next_contrast < best_contrast {
|
||
best_bin = next_bin;
|
||
best_contrast = next_contrast;
|
||
}
|
||
} else {
|
||
// End current group
|
||
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;
|
||
}
|
||
}
|
||
// Close last group
|
||
let half = MIN_GUTTER_BINS;
|
||
valleys.push((
|
||
best_bin.saturating_sub(half),
|
||
(best_bin + half + 1).min(num_bins),
|
||
));
|
||
|
||
// Limit to the single best valley (deepest contrast).
|
||
// Multi-column layouts with 3+ columns typically have clear gutters that
|
||
// the absolute valley detection handles. The relative fallback is designed
|
||
// for 2-column layouts where justified text fills the gutter.
|
||
if valleys.len() > 1 {
|
||
// Keep only the valley with the best (lowest) contrast in the candidates
|
||
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;
|
||
// Find the candidate closest to this valley's midpoint
|
||
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
|
||
}
|
||
|
||
/// Detect whether a side of a gutter consists predominantly of list-marker
|
||
/// glyphs (•, ●, ○, ◦, ▪, ▫, ◆, ◇). A column of bullets on the left margin
|
||
/// creates a spurious histogram valley between the bullet and the content.
|
||
/// Treating it as a real column splits each list item's text across two
|
||
/// "columns," so we reject these candidates.
|
||
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();
|
||
// Require ≥80% of items on this side to be standalone markers. A handful
|
||
// of non-marker items (stray page numbers, footnote refs) shouldn't
|
||
// defeat the check.
|
||
marker_count as f32 / items.len() as f32 >= 0.8
|
||
}
|
||
|
||
/// Validate valley candidates with vertical consistency checks and build column regions.
|
||
///
|
||
/// When `center_assign` is true, items are assigned to columns based on their
|
||
/// center point rather than their right edge. This helps when justified text
|
||
/// items extend past the gutter.
|
||
#[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> {
|
||
// Compute the Y range from column-eligible items only — the same items
|
||
// the histogram counted. Spanning items (full-width captions, titles)
|
||
// are excluded from the projection, so letting them stretch the page's
|
||
// vertical extent here would sink the overlap ratio for column regions
|
||
// that legitimately occupy only part of the page (e.g. two-column text
|
||
// below a figure).
|
||
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;
|
||
|
||
// Validate each valley with vertical consistency
|
||
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;
|
||
|
||
// Collect items on each side of the gutter.
|
||
// Center-based: use item midpoint (better for justified text).
|
||
// Edge-based: use item right edge (original behavior).
|
||
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();
|
||
|
||
// Require both sides to have items. Symmetric layout needs min_items
|
||
// on each side. Asymmetric layouts (sidebars) are accepted when the
|
||
// dominant side has ≥ min_items and the smaller side has ≥ 3 items.
|
||
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;
|
||
}
|
||
|
||
// Reject valleys where the smaller side is just a column of list
|
||
// markers (bullets aligned at the left margin). This is a common
|
||
// pattern in PDFs where ● starts each list item: histogram detection
|
||
// sees the gap between bullet and content as a gutter.
|
||
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;
|
||
}
|
||
|
||
// Check vertical overlap
|
||
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<_>>()
|
||
);
|
||
|
||
// Limit to at most 3 gutters (4 columns).
|
||
// Score = width_in_bins * min(left_count, right_count)
|
||
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);
|
||
}
|
||
|
||
// Build column regions from gutter boundaries
|
||
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
|
||
}
|
||
|
||
/// Identify items that belong to lines spanning across detected columns.
|
||
///
|
||
/// Groups items into rough lines by Y-proximity and marks items whose line's
|
||
/// combined X-span exceeds 1.3× the widest column AND has no gap located at
|
||
/// a detected gutter boundary. Returns a boolean mask parallel to `items`.
|
||
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;
|
||
|
||
// Gutter centers: boundaries between adjacent columns
|
||
let gutters: Vec<f32> = columns.windows(2).map(|c| c[0].x_max).collect();
|
||
let gutter_tol = 15.0;
|
||
let y_tol = 5.0;
|
||
|
||
// Build (original_index, y) pairs sorted by Y descending for grouping
|
||
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));
|
||
|
||
// Group by Y-proximity into rough lines (as index sets)
|
||
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;
|
||
}
|
||
|
||
// Sort group indices by X to compute span
|
||
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;
|
||
}
|
||
|
||
// Check if any inter-item gap falls at a detected gutter boundary.
|
||
// If so, this is items from different columns at the same Y, not a
|
||
// true spanning line (like a title or section header).
|
||
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;
|
||
}
|
||
// Check if any gutter falls within the gap interval (with tolerance)
|
||
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
|
||
}
|
||
|
||
/// Determines if a text item spans across multiple column regions (e.g. full-width headers/titles).
|
||
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
|
||
}
|
||
|
||
/// Check if a text item is likely a page number
|
||
fn is_page_number(item: &TextItem) -> bool {
|
||
let text = item.text.trim();
|
||
|
||
// Must be 1-4 digits only
|
||
if text.is_empty() || text.len() > 4 {
|
||
return false;
|
||
}
|
||
if !text.chars().all(|c| c.is_ascii_digit()) {
|
||
return false;
|
||
}
|
||
|
||
// Must be at top or bottom of page.
|
||
// US Letter = 792pt, A4 = 841pt. Page numbers are typically in the
|
||
// top ~5% or bottom ~12% of the page.
|
||
item.y > 720.0 || item.y < 100.0
|
||
}
|
||
|
||
/// Group text items into lines, with multi-column support
|
||
/// Detect newspaper-style columns: independent text flows that should be read
|
||
/// sequentially (all of col1, then col2) rather than Y-interleaved.
|
||
pub(crate) fn is_newspaper_layout(
|
||
per_column_lines: &[Vec<TextLine>],
|
||
columns: &[ColumnRegion],
|
||
) -> bool {
|
||
if per_column_lines.len() < 2 {
|
||
return false;
|
||
}
|
||
|
||
// Each column must independently have substantial content
|
||
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 {
|
||
// Sidebar detection: a narrow annotation column beside a wide body column.
|
||
// Guards:
|
||
// - Only 2 columns (sidebars are body+sidebar, not 3+ columns)
|
||
// - width_ratio < 0.50: sidebar is much narrower than body
|
||
// - line_balance < 0.35: sidebar has significantly fewer lines
|
||
// - max_lines >= 20: body column has substantial prose content
|
||
// - narrower column has fewer lines (not a dense reference column)
|
||
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 {
|
||
// Sparse density check: sidebar annotations are spread thinly
|
||
// across the page height while regular two-column text is dense.
|
||
// Compare average Y-gap between successive lines in each column.
|
||
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);
|
||
// Sidebar annotations have >2.5x the average gap of body text
|
||
if wide_gap > 0.0 && narrow_gap / wide_gap >= 2.5 {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Dense balanced columns (similar line counts) are newspaper regardless of Y-alignment.
|
||
// By this point table items are already removed, so two dense balanced columns
|
||
// of remaining text are independent prose flows.
|
||
let balance_ratio = min_lines as f32 / max_lines as f32;
|
||
if balance_ratio > 0.7 {
|
||
return true;
|
||
}
|
||
|
||
// For unbalanced columns, fall back to Y-collision check
|
||
let y_tol = 5.0; // was 3.0 — handles government gazette typesetting variance
|
||
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
|
||
}
|
||
|
||
/// Split column lines into a core cluster and stragglers.
|
||
/// The core is the largest group of consecutive lines separated by normal
|
||
/// line spacing. Lines in other groups (header remnants, per-word items from
|
||
/// full-width lines) are returned as stragglers.
|
||
fn split_column_stragglers(lines: Vec<TextLine>) -> (Vec<TextLine>, Vec<TextLine>) {
|
||
if lines.len() < 3 {
|
||
return (lines, Vec::new());
|
||
}
|
||
|
||
// Lines are sorted Y descending (top-first). Compute gaps.
|
||
let mut gaps: Vec<f32> = Vec::new();
|
||
for i in 0..lines.len() - 1 {
|
||
gaps.push(lines[i].y - lines[i + 1].y);
|
||
}
|
||
|
||
// Median gap = typical line spacing
|
||
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];
|
||
|
||
// A gap > 3× median (min 30pt) indicates a break between content clusters
|
||
let threshold = (median_gap * 3.0).max(30.0);
|
||
|
||
// Find all split points
|
||
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());
|
||
}
|
||
|
||
// Build segments: (start_line_idx, end_line_idx_exclusive)
|
||
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()));
|
||
|
||
// Find the largest segment (the core cluster)
|
||
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())
|
||
}
|
||
|
||
/// Group text items into lines, using pre-computed per-page adaptive thresholds
|
||
/// from Canva-style letter-spacing detection. Falls back to computing the
|
||
/// threshold from item gaps when no pre-computed value is available.
|
||
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(),
|
||
)
|
||
}
|
||
|
||
/// Like `group_into_lines_with_thresholds`, but items inside chart regions
|
||
/// are excluded from column detection: chart text scattered across the page
|
||
/// fills the gutter in the projection histogram, so two-column pages read as
|
||
/// one column and same-baseline items from both columns fuse into one line
|
||
/// (headings absorbed into the neighboring column's body text).
|
||
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> {
|
||
if items.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
|
||
// Filter out page numbers (standalone numbers at top/bottom of page)
|
||
let items: Vec<TextItem> = items
|
||
.into_iter()
|
||
.filter(|item| !is_page_number(item))
|
||
.collect();
|
||
|
||
// Get unique pages
|
||
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();
|
||
|
||
// Use pre-computed threshold from fix_letterspaced_items if available
|
||
// (computed before embedded-space removal, with full signal).
|
||
// Non-Canva pages use the default 0.10 threshold.
|
||
let adaptive_threshold = page_thresholds.get(&page).copied().unwrap_or(0.10);
|
||
|
||
// Image-backed region graphs recover local/asymmetric column flows
|
||
// that a whole-page projection cannot represent. Charts already have
|
||
// their own positioned-region ordering and therefore stay on that path.
|
||
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;
|
||
}
|
||
}
|
||
|
||
// Detect columns for this page, blind to chart text.
|
||
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;
|
||
// Tight bounds: this only blinds the histogram to
|
||
// chart-internal text; rows adjacent to the chart
|
||
// belong to the column layout.
|
||
!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 {
|
||
// Single column - use simple sorting
|
||
let lines = group_single_column(page_items, adaptive_threshold);
|
||
all_lines.extend(lines);
|
||
} else {
|
||
// Multi-column detected. Pre-mask lines that span the full page
|
||
// width (titles, section headers, footers). These multi-item lines
|
||
// would otherwise be split across column buckets, corrupting
|
||
// newspaper detection and reading order.
|
||
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
|
||
);
|
||
}
|
||
|
||
// Partition items preserving original order
|
||
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);
|
||
}
|
||
}
|
||
|
||
// Process each column's items independently, preserving column identity.
|
||
// Assign each item to the column with greatest horizontal overlap
|
||
// (instead of center-point) to avoid gutter mis-assignment.
|
||
let mut col_buckets: Vec<Vec<TextItem>> = vec![Vec::new(); columns.len()];
|
||
for item in &column_items {
|
||
let item_left = item.x;
|
||
let item_right = item.x + effective_width(item);
|
||
let mut best_col = 0;
|
||
let mut best_overlap = f32::NEG_INFINITY;
|
||
for (ci, col) in columns.iter().enumerate() {
|
||
let overlap = (item_right.min(col.x_max) - item_left.max(col.x_min)).max(0.0);
|
||
if overlap > best_overlap {
|
||
best_overlap = overlap;
|
||
best_col = ci;
|
||
}
|
||
}
|
||
col_buckets[best_col].push(item.clone());
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// Process spanning items as their own group
|
||
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 {
|
||
// Newspaper: columns are independent text flows.
|
||
// 1. Split each column into its densest cluster (core) and stragglers
|
||
// 2. Use core columns to determine the above/below threshold
|
||
// 3. Emit: above items → core columns sequentially → below items
|
||
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);
|
||
}
|
||
|
||
// col_top = min of max Y across core columns
|
||
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();
|
||
|
||
// Spanning items: above or below the column region
|
||
for line in spanning_lines {
|
||
if line.y > col_top + margin {
|
||
above.push(line);
|
||
} else {
|
||
below_spanning.push(line);
|
||
}
|
||
}
|
||
|
||
// Column stragglers above col_top go to "above";
|
||
// below col_top they stay with their column to avoid
|
||
// re-interleaving when sorted by Y.
|
||
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 {
|
||
// Tabular: Y-interleaved merge — rows at the same Y from
|
||
// different columns form a single logical line.
|
||
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);
|
||
}
|
||
|
||
// Sort by Y descending (top-first), then by X for same-Y 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)),
|
||
)
|
||
});
|
||
|
||
// Merge lines at the same Y (within tolerance) into single lines
|
||
let y_tol = 3.0;
|
||
let mut merged: Vec<TextLine> = Vec::new();
|
||
for line in all_page_lines {
|
||
if let Some(last) = merged.last_mut() {
|
||
if last.page == line.page && (last.y - line.y).abs() < y_tol {
|
||
last.items.extend(line.items);
|
||
sort_line_items(&mut last.items);
|
||
continue;
|
||
}
|
||
}
|
||
merged.push(line);
|
||
}
|
||
|
||
all_lines.extend(merged);
|
||
}
|
||
}
|
||
}
|
||
|
||
all_lines
|
||
}
|
||
|
||
/// Determine if Y-sorting should be used instead of stream order.
|
||
/// Returns true if the stream order appears chaotic (items jump around in Y position).
|
||
fn should_use_y_sorting(items: &[TextItem]) -> bool {
|
||
if items.len() < 5 {
|
||
return false; // Not enough items to judge
|
||
}
|
||
|
||
// Sample Y positions from stream order
|
||
let y_positions: Vec<f32> = items.iter().map(|i| i.y).collect();
|
||
|
||
// Count "order violations" - cases where Y increases (going up) when it should decrease
|
||
// In proper reading order, Y should generally decrease (top to bottom)
|
||
let mut large_jumps_up = 0;
|
||
let mut large_jumps_down = 0;
|
||
let jump_threshold = 50.0; // Significant Y jump
|
||
|
||
for window in y_positions.windows(2) {
|
||
let delta = window[1] - window[0];
|
||
if delta > jump_threshold {
|
||
large_jumps_up += 1; // Y increased significantly (jumped up on page)
|
||
} else if delta < -jump_threshold {
|
||
large_jumps_down += 1; // Y decreased significantly (normal reading direction)
|
||
}
|
||
}
|
||
|
||
// If there are many upward jumps relative to downward jumps, order is chaotic
|
||
// A well-ordered document should have mostly downward progression
|
||
let total_jumps = large_jumps_up + large_jumps_down;
|
||
if total_jumps < 3 {
|
||
return false; // Not enough jumps to judge
|
||
}
|
||
|
||
// If more than 40% of large jumps are upward, use Y-sorting
|
||
let chaos_ratio = large_jumps_up as f32 / total_jumps as f32;
|
||
chaos_ratio > 0.4
|
||
}
|
||
|
||
/// Group items from a single column into lines
|
||
/// Uses heuristics to decide between PDF stream order and Y-position sorting.
|
||
fn group_single_column(items: Vec<TextItem>, adaptive_threshold: f32) -> Vec<TextLine> {
|
||
if items.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
|
||
// Decide whether to use stream order or Y-sorting
|
||
let use_y_sorting = should_use_y_sorting(&items);
|
||
|
||
let items = if use_y_sorting {
|
||
// Sort by Y descending (top to bottom in PDF coords)
|
||
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
|
||
};
|
||
|
||
// Group items into lines
|
||
let mut lines: Vec<TextLine> = Vec::new();
|
||
let y_tolerance = 3.0;
|
||
|
||
for item in items {
|
||
// Only check the most recent line for merging
|
||
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;
|
||
}
|
||
// Check if this looks like a new line despite similar Y:
|
||
// If items are at the same X position (left margin) but different Y,
|
||
// they're vertically stacked lines, not the same line
|
||
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 (left margin) with Y change, it's likely a new line
|
||
if at_same_x {
|
||
return false;
|
||
}
|
||
// If new item starts significantly to the left with Y change,
|
||
// it's a new line (not just out-of-order items on same line)
|
||
if let Some(last_item) = last_line.items.last() {
|
||
if item.x < last_item.x - 10.0 {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Same baseline, but separated by a wide void, with the incoming
|
||
// run starting alphabetic: the neighboring column's body text
|
||
// sharing a y with this line, in gutters too narrow for column
|
||
// detection. Both sides must be multi-word prose — TOC page
|
||
// numbers, dot leaders, and outline-numbered table cells (which
|
||
// start with digits) stay joined.
|
||
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())
|
||
{
|
||
// The incoming run must be substantial prose; the line
|
||
// side may be short (a wrapped heading's last words).
|
||
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;
|
||
// Lowercase starts are mid-sentence continuations and
|
||
// split on prose signals alone. Uppercase starts also
|
||
// need a bold-style mismatch between the runs — a bold
|
||
// heading beside regular body text — otherwise same-style
|
||
// label rows (feature tiles, legends) would shatter.
|
||
let starts_lower = item
|
||
.text
|
||
.trim()
|
||
.chars()
|
||
.next()
|
||
.is_some_and(|c| c.is_lowercase());
|
||
// The whole line must be bold (a heading), not merely
|
||
// its last run — mixed bold-label/value rows stay joined.
|
||
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 {
|
||
// Add to the most recent line
|
||
lines.last_mut().unwrap().items.push(item);
|
||
} else {
|
||
// Create new line
|
||
let y = item.y;
|
||
let page = item.page;
|
||
lines.push(TextLine {
|
||
items: vec![item],
|
||
y,
|
||
page,
|
||
adaptive_threshold,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Sort items within each line by X position (direction-aware)
|
||
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;
|
||
|
||
/// Helper: create a TextItem at given position with given width text.
|
||
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, // ~6pt per char
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// Generate dense items in a horizontal zone across many Y positions.
|
||
/// Items are placed with overlapping coverage so no intra-zone valleys appear.
|
||
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; // "SomeText__" = 10 chars * 6pt
|
||
let step = 55.0; // overlap slightly to avoid intra-zone histogram gaps
|
||
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() {
|
||
// Heading in the left column, mid-sentence body text from the right
|
||
// column at the same y, separated by a wide void: two lines.
|
||
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() {
|
||
// Outline-numbered cell content to the right: table-ish, keep 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() {
|
||
// Left months (x=15..330), right months (x=345..660), sidebar (x=675..800)
|
||
// Each zone is >100pt wide so min_col_width won't reject any.
|
||
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());
|
||
|
||
// Gutter 1 should be in the gap between left and middle zones
|
||
let g1 = cols[0].x_max;
|
||
assert!(
|
||
(290.0..=350.0).contains(&g1),
|
||
"First gutter at {g1}, expected between left and middle zones"
|
||
);
|
||
|
||
// Gutter 2 should be in the gap between middle and right 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() {
|
||
// Standard 2-column layout with clear gutter at center
|
||
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() {
|
||
// 5 valid valleys: 2 are wide but split sparse content, 2 are narrower
|
||
// but separate dense zones. The dense-zone gutters should win.
|
||
let mut items = Vec::new();
|
||
// Dense left zone
|
||
items.extend(fill_zone(1, 15.0, 200.0, 750.0, 50.0));
|
||
// Dense middle zone
|
||
items.extend(fill_zone(1, 220.0, 400.0, 750.0, 50.0));
|
||
// Dense right zone
|
||
items.extend(fill_zone(1, 420.0, 600.0, 750.0, 50.0));
|
||
// Sparse far-right zone (few items)
|
||
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);
|
||
// Should detect the gutters between the 3 dense zones, not the wide gap
|
||
// before the sparse zone
|
||
assert!(
|
||
cols.len() >= 3,
|
||
"Expected >=3 columns for dense zones, got {}",
|
||
cols.len()
|
||
);
|
||
}
|
||
|
||
/// Helper: create items that fill a zone but with widths that extend past
|
||
/// the zone boundary (simulating justified text). Items start within the zone
|
||
/// but their reported width extends `overshoot` points past the zone end.
|
||
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 {
|
||
// Each line: 3-4 items that together span x_start to x_end+overshoot
|
||
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() {
|
||
// Two columns of justified text where item widths overshoot the gutter
|
||
// by a few points, preventing absolute valley detection from finding
|
||
// an empty gutter.
|
||
let mut items = Vec::new();
|
||
// Left column: x=40..290, items extend to ~297 (7pt overshoot)
|
||
items.extend(fill_zone_justified(1, 40.0, 290.0, 7.0, 750.0, 50.0));
|
||
// Right column: x=300..550, items extend to ~557
|
||
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() {
|
||
// Single column of text — the right margin drop-off should NOT be
|
||
// detected as a column gutter.
|
||
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()
|
||
);
|
||
}
|
||
|
||
/// Helper: build a Vec<TextLine> with `n` lines at given X, starting at Y=700.
|
||
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() {
|
||
// Wide body column (x 0..400) with 40 lines,
|
||
// narrow sidebar (x 420..590, width 170) with 12 lines.
|
||
// width_ratio = 170/400 = 0.425, line_balance = 12/40 = 0.30 → sidebar → newspaper
|
||
// Sidebar lines have ~3x gap of body lines (sparse annotations).
|
||
let body = make_lines(40, 50.0);
|
||
let sidebar: Vec<TextLine> = (0..12)
|
||
.map(|i| {
|
||
let y = 693.0 - i as f32 * 45.0; // sparse annotations: ~3x body gap
|
||
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() {
|
||
// Two columns of similar width and equal line counts → borderless table, not newspaper.
|
||
// width_ratio = 250/300 = 0.83 (> 0.50), so sidebar guard fails → false.
|
||
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() {
|
||
// Title spans x=30..550 as 5 adjacent items (no gap near gutter at x=300)
|
||
// Two columns: left (x=0..300), right (x=300..600)
|
||
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();
|
||
|
||
// Spanning title: 5 items at Y=750, each ~100pt wide, gaps ~4pt
|
||
// No item gap falls near the gutter at x=300
|
||
for i in 0..5 {
|
||
items.push(make_item(
|
||
1,
|
||
30.0 + i as f32 * 104.0,
|
||
750.0,
|
||
"TitleWord_________",
|
||
));
|
||
}
|
||
|
||
// Left column body: 20 lines
|
||
for i in 0..20 {
|
||
items.push(make_item(1, 30.0, 700.0 - i as f32 * 14.0, "LeftText__"));
|
||
}
|
||
|
||
// Right column body: 20 lines
|
||
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() {
|
||
// Two items at same Y with gap at gutter → 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();
|
||
|
||
// Items in two columns at same Y — gap center ~305 is near gutter at 300
|
||
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() {
|
||
// Pattern: every line is `● <content>`, with ● at x=90 and content
|
||
// starting at x=104. Histogram detection sees a gutter between them
|
||
// and would split the page into a "bullet column" and "content column",
|
||
// scrambling every list item.
|
||
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________________",
|
||
));
|
||
}
|
||
// Pad with content to satisfy min item count for column detection.
|
||
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() {
|
||
// Items that form a line spanning only ~40% of column width → 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();
|
||
|
||
// Narrow header at top (spans ~240pt, max col width = 300, threshold = 390)
|
||
for i in 0..3 {
|
||
items.push(make_item(
|
||
1,
|
||
180.0 + i as f32 * 84.0,
|
||
750.0,
|
||
"SmallHeader___",
|
||
));
|
||
}
|
||
|
||
// Two columns below
|
||
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");
|
||
}
|
||
}
|