//! Column detection, line grouping, and reading-order layout. use crate::text_utils::{effective_width, sort_line_items}; use crate::types::{TextItem, TextLine}; /// 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) -> Vec { 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 let page_items: Vec<&TextItem> = items.iter().filter(|i| i.page == page).collect(); if page_items.is_empty() { return vec![]; } // 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 = 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(); if valleys.is_empty() { return vec![ColumnRegion { x_min, x_max }]; } // Compute Y range of the page let y_min = page_items.iter().map(|i| i.y).fold(f32::INFINITY, f32::min); let y_max = page_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)> = 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 let left_items: Vec<&&TextItem> = page_items .iter() .filter(|i| i.x + effective_width(i) <= gutter_center) .collect(); let right_items: Vec<&&TextItem> = page_items.iter().filter(|i| i.x >= gutter_center).collect(); if left_items.len() < MIN_ITEMS_PER_COLUMN || right_items.len() < MIN_ITEMS_PER_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_RATIO { continue; } } valid_valleys.push((start, end)); } if valid_valleys.is_empty() { return vec![ColumnRegion { x_min, x_max }]; } // Limit to at most 3 gutters (4 columns) — keep the widest if more found if valid_valleys.len() > 3 { valid_valleys.sort_by(|a, b| { let wa = (a.1 - a.0) as f32; let wb = (b.1 - b.0) as f32; wb.partial_cmp(&wa).unwrap_or(std::cmp::Ordering::Equal) }); valid_valleys.truncate(3); // Re-sort by position (left to right) 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 } /// 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]) -> 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); if min_lines < 15 { return false; } // Check Y-collision: count lines in the smallest column that have a // Y-match in any other column. High collision with many lines = newspaper. let y_tol = 3.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 } /// 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) -> (Vec, Vec) { if lines.len() < 3 { return (lines, Vec::new()); } // Lines are sorted Y descending (top-first). Compute gaps. let mut gaps: Vec = 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.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); 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 = 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) -> Vec { if items.is_empty() { return Vec::new(); } // Filter out page numbers (standalone numbers at top/bottom of page) let items: Vec = items .into_iter() .filter(|item| !is_page_number(item)) .collect(); // Get unique pages let mut pages: Vec = 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 = items.iter().filter(|i| i.page == page).cloned().collect(); // Detect columns for this page let columns = detect_columns(&page_items, page); if columns.len() <= 1 { // Single column - use simple sorting let lines = group_single_column(page_items); all_lines.extend(lines); } else { // Multi-column - separate spanning items from column items let mut spanning_items: Vec = Vec::new(); let mut column_items: Vec = Vec::new(); for item in &page_items { if spans_multiple_columns(item, &columns) { spanning_items.push(item.clone()); } else { column_items.push(item.clone()); } } // 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![Vec::new(); columns.len()]; for item in &column_items { let item_left = item.x; let item_right = item.x + effective_width(item); let mut best_col = 0; let mut best_overlap = f32::NEG_INFINITY; for (ci, col) in columns.iter().enumerate() { let overlap = (item_right.min(col.x_max) - item_left.max(col.x_min)).max(0.0); if overlap > best_overlap { best_overlap = overlap; best_col = ci; } } col_buckets[best_col].push(item.clone()); } let mut per_column_lines: Vec> = Vec::new(); for col_items in col_buckets { let lines = group_single_column(col_items); per_column_lines.push(lines); } // Process spanning items as their own group let spanning_lines = group_single_column(spanning_items); if is_newspaper_layout(&per_column_lines) { // 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::new(); let mut col_stragglers: Vec> = 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 = Vec::new(); let mut below_spanning: Vec = 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![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.partial_cmp(&a.y).unwrap_or(std::cmp::Ordering::Equal)); below_spanning .sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(std::cmp::Ordering::Equal)); 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 = 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.partial_cmp(&a.y) .unwrap_or(std::cmp::Ordering::Equal) .then( a.items .first() .map(|i| i.x) .unwrap_or(0.0) .partial_cmp(&b.items.first().map(|i| i.x).unwrap_or(0.0)) .unwrap_or(std::cmp::Ordering::Equal), ) }); // Merge lines at the same Y (within tolerance) into single lines let y_tol = 3.0; let mut merged: Vec = 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 = 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) -> Vec { 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.partial_cmp(&a.y) .unwrap_or(std::cmp::Ordering::Equal) .then(a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal)) }); sorted } else { items }; // Group items into lines let mut lines: Vec = 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; } } } } 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, }); } } // Sort items within each line by X position (direction-aware) for line in &mut lines { sort_line_items(&mut line.items); } lines }