feat: detect side-by-side tables via X-gap pre-splitting
Split pages with two independent table regions (e.g. D'Addario tension chart) into separate bands before running rect/line/heuristic detection. The split function finds X-position gaps ≥40pt, validates that <2% of items cross the split point, and requires ≥30% balance on each side. Also adds heuristic table detection fallback in compute_layout_complexity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d5e03b8915
commit
0dba1e66ed
+60
-7
@@ -430,21 +430,74 @@ fn compute_layout_complexity(
|
||||
rects: &[types::PdfRect],
|
||||
lines: &[types::PdfLine],
|
||||
) -> LayoutComplexity {
|
||||
use markdown::analysis::calculate_font_stats_from_items;
|
||||
|
||||
// --- Collect unique pages ---
|
||||
let mut seen_pages: Vec<u32> = items.iter().map(|i| i.page).collect();
|
||||
seen_pages.sort();
|
||||
seen_pages.dedup();
|
||||
|
||||
// --- Tables: use rect-based then line-based detectors per page ---
|
||||
let font_stats = calculate_font_stats_from_items(items);
|
||||
let base_size = font_stats.most_common_size;
|
||||
|
||||
// --- Tables: use rect-based → line-based → heuristic detectors per page,
|
||||
// with side-by-side band splitting ---
|
||||
let mut pages_with_tables: Vec<u32> = Vec::new();
|
||||
for &page in &seen_pages {
|
||||
let (tables, _) = tables::detect_tables_from_rects(items, rects, page);
|
||||
if !tables.is_empty() {
|
||||
pages_with_tables.push(page);
|
||||
continue;
|
||||
let page_items: Vec<&types::TextItem> = items.iter().filter(|i| i.page == page).collect();
|
||||
|
||||
// Check for side-by-side layout
|
||||
let owned_items: Vec<types::TextItem> = page_items.iter().map(|i| (*i).clone()).collect();
|
||||
let bands = markdown::split_side_by_side(&owned_items);
|
||||
|
||||
let band_ranges: Vec<(f32, f32)> = if bands.is_empty() {
|
||||
// Single region — use sentinel range that includes everything
|
||||
vec![(f32::MIN, f32::MAX)]
|
||||
} else {
|
||||
bands
|
||||
};
|
||||
|
||||
let mut found_table = false;
|
||||
for &(x_lo, x_hi) in &band_ranges {
|
||||
let margin = 2.0;
|
||||
let band_items: Vec<types::TextItem> = owned_items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
x_lo == f32::MIN || (item.x >= x_lo - margin && item.x < x_hi + margin)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let band_rects: Vec<types::PdfRect> = if x_lo == f32::MIN {
|
||||
rects.iter().filter(|r| r.page == page).cloned().collect()
|
||||
} else {
|
||||
markdown::filter_rects_to_band(rects, page, x_lo, x_hi)
|
||||
};
|
||||
|
||||
let band_lines: Vec<types::PdfLine> = if x_lo == f32::MIN {
|
||||
lines.iter().filter(|l| l.page == page).cloned().collect()
|
||||
} else {
|
||||
markdown::filter_lines_to_band(lines, page, x_lo, x_hi)
|
||||
};
|
||||
|
||||
let (rect_tables, _) = tables::detect_tables_from_rects(&band_items, &band_rects, page);
|
||||
if !rect_tables.is_empty() {
|
||||
found_table = true;
|
||||
break;
|
||||
}
|
||||
let line_tables = tables::detect_tables_from_lines(&band_items, &band_lines, page);
|
||||
if !line_tables.is_empty() {
|
||||
found_table = true;
|
||||
break;
|
||||
}
|
||||
// Heuristic fallback for borderless tables
|
||||
let heuristic_tables = tables::detect_tables(&band_items, base_size, false);
|
||||
if !heuristic_tables.is_empty() {
|
||||
found_table = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let line_tables = tables::detect_tables_from_lines(items, lines, page);
|
||||
if !line_tables.is_empty() {
|
||||
if found_table {
|
||||
pages_with_tables.push(page);
|
||||
}
|
||||
}
|
||||
|
||||
+235
-74
@@ -6,7 +6,7 @@
|
||||
//! - Code blocks (monospace fonts, indentation)
|
||||
//! - Paragraphs
|
||||
|
||||
mod analysis;
|
||||
pub(crate) mod analysis;
|
||||
mod classify;
|
||||
mod convert;
|
||||
mod postprocess;
|
||||
@@ -17,12 +17,127 @@ pub use convert::to_markdown_from_lines;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::extractor::group_into_lines;
|
||||
use crate::types::TextItem;
|
||||
use crate::types::{PdfLine, PdfRect, TextItem};
|
||||
|
||||
use analysis::calculate_font_stats_from_items;
|
||||
use classify::{format_list_item, is_code_like, is_list_item};
|
||||
use convert::{merge_continuation_tables, to_markdown_from_lines_with_tables_and_images};
|
||||
|
||||
/// Detect side-by-side table layout by finding a significant X-position gap.
|
||||
///
|
||||
/// Returns X-band boundaries `[(x_min, split_x), (split_x, x_max)]` when a
|
||||
/// clear vertical gap separates two groups of items, or an empty vec if the
|
||||
/// page has a single-region layout.
|
||||
///
|
||||
/// Evaluates all candidate gaps (≥40pt with ≥20 items on each side) and picks
|
||||
/// the one with the fewest bounding-box crossings. The crossing count must be
|
||||
/// under 2% of total items — this prevents splitting paragraphs whose lines
|
||||
/// extend across the gap.
|
||||
pub(crate) fn split_side_by_side(items: &[TextItem]) -> Vec<(f32, f32)> {
|
||||
if items.len() < 40 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Sort items by left edge
|
||||
let mut xs: Vec<f32> = items.iter().map(|i| i.x).collect();
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
// Find all candidate gaps: ≥40pt with ≥20 items on each side
|
||||
let mut candidates: Vec<(f32, usize)> = Vec::new(); // (split_x, gap_pos)
|
||||
for i in 1..xs.len() {
|
||||
let gap = xs[i] - xs[i - 1];
|
||||
let left_count = i;
|
||||
let right_count = xs.len() - i;
|
||||
if gap >= 40.0 && left_count >= 20 && right_count >= 20 {
|
||||
let split_x = (xs[i - 1] + xs[i]) / 2.0;
|
||||
candidates.push((split_x, i));
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Pick the candidate with the fewest bounding-box crossings,
|
||||
// but only consider balanced splits (smaller side ≥ 30% of total).
|
||||
let min_side = items.len() * 3 / 10;
|
||||
let mut best_split = 0.0f32;
|
||||
let mut best_crossing = usize::MAX;
|
||||
for &(split_x, gap_pos) in &candidates {
|
||||
let left_count = gap_pos;
|
||||
let right_count = xs.len() - gap_pos;
|
||||
if left_count.min(right_count) < min_side {
|
||||
continue;
|
||||
}
|
||||
let crossing = items
|
||||
.iter()
|
||||
.filter(|item| item.x < split_x && (item.x + item.width) > split_x)
|
||||
.count();
|
||||
if crossing < best_crossing {
|
||||
best_crossing = crossing;
|
||||
best_split = split_x;
|
||||
}
|
||||
}
|
||||
|
||||
if best_crossing == usize::MAX {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Crossing items must be < 2% of total (allows a few spanning headers)
|
||||
let max_crossing = (items.len() / 50).max(2);
|
||||
if best_crossing > max_crossing {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let x_min = xs[0];
|
||||
let x_max = *xs.last().unwrap();
|
||||
|
||||
vec![(x_min, best_split), (best_split, x_max)]
|
||||
}
|
||||
|
||||
/// Filter rects to those overlapping an X band.
|
||||
pub(crate) fn filter_rects_to_band(
|
||||
rects: &[PdfRect],
|
||||
page: u32,
|
||||
x_lo: f32,
|
||||
x_hi: f32,
|
||||
) -> Vec<PdfRect> {
|
||||
rects
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.page == page && {
|
||||
let rx_min = if r.width >= 0.0 { r.x } else { r.x + r.width };
|
||||
let rx_max = if r.width >= 0.0 { r.x + r.width } else { r.x };
|
||||
rx_max > x_lo && rx_min < x_hi
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A band of items/indices/rects/lines for side-by-side table detection.
|
||||
type BandSpec = (Vec<TextItem>, Vec<usize>, Vec<PdfRect>, Vec<PdfLine>);
|
||||
|
||||
/// Filter PDF lines to those overlapping an X band.
|
||||
pub(crate) fn filter_lines_to_band(
|
||||
lines: &[PdfLine],
|
||||
page: u32,
|
||||
x_lo: f32,
|
||||
x_hi: f32,
|
||||
) -> Vec<PdfLine> {
|
||||
lines
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
l.page == page && {
|
||||
let lx_min = l.x1.min(l.x2);
|
||||
let lx_max = l.x1.max(l.x2);
|
||||
lx_max > x_lo && lx_min < x_hi
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Options for markdown conversion
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MarkdownOptions {
|
||||
@@ -230,34 +345,60 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
let group = page_groups.get(&page).unwrap();
|
||||
let page_items: Vec<TextItem> = group.iter().map(|(_, item)| (*item).clone()).collect();
|
||||
|
||||
// Track which local indices are claimed by structural table detection
|
||||
let mut rect_claimed: HashSet<usize> = HashSet::new();
|
||||
// Check for side-by-side layout (e.g. two tables placed left and right)
|
||||
let bands = split_side_by_side(&page_items);
|
||||
|
||||
// 1. Rect-based detection first (well-tested, high precision)
|
||||
let (rect_tables, hint_regions) = detect_tables_from_rects(&page_items, rects, page);
|
||||
for table in &rect_tables {
|
||||
for &idx in &table.item_indices {
|
||||
rect_claimed.insert(idx);
|
||||
if let Some(&(global_idx, _)) = group.get(idx) {
|
||||
table_items.insert(global_idx);
|
||||
}
|
||||
// Build list of (band_items, band_index_map, band_rects, band_lines).
|
||||
// band_index_map[local_band_idx] → page_items index.
|
||||
let band_specs: Vec<BandSpec> = if bands.is_empty() {
|
||||
// Single-region page — use all items/rects/lines as-is
|
||||
let identity: Vec<usize> = (0..page_items.len()).collect();
|
||||
vec![(
|
||||
page_items.clone(),
|
||||
identity,
|
||||
rects.iter().filter(|r| r.page == page).cloned().collect(),
|
||||
pdf_lines
|
||||
.iter()
|
||||
.filter(|l| l.page == page)
|
||||
.cloned()
|
||||
.collect(),
|
||||
)]
|
||||
} else {
|
||||
bands
|
||||
.iter()
|
||||
.map(|&(x_lo, x_hi)| {
|
||||
let margin = 2.0; // small margin to avoid clipping edge items
|
||||
let (items_in_band, idx_map): (Vec<TextItem>, Vec<usize>) = page_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, item)| item.x >= x_lo - margin && item.x < x_hi + margin)
|
||||
.map(|(idx, item)| (item.clone(), idx))
|
||||
.unzip();
|
||||
let band_rects = filter_rects_to_band(rects, page, x_lo, x_hi);
|
||||
let band_lines = filter_lines_to_band(pdf_lines, page, x_lo, x_hi);
|
||||
(items_in_band, idx_map, band_rects, band_lines)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
for (band_items, band_index_map, band_rects, band_lines) in &band_specs {
|
||||
if band_items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let table_y = table.rows.first().copied().unwrap_or(0.0);
|
||||
let table_md = table_to_markdown(table);
|
||||
page_tables
|
||||
.entry(page)
|
||||
.or_default()
|
||||
.push((table_y, table_md));
|
||||
}
|
||||
|
||||
// 2. Line-based detection on unclaimed items (when rects didn't find tables)
|
||||
if rect_claimed.is_empty() {
|
||||
let line_tables = detect_tables_from_lines(&page_items, pdf_lines, page);
|
||||
for table in &line_tables {
|
||||
// Track which band-local indices are claimed by structural detection
|
||||
let mut rect_claimed: HashSet<usize> = HashSet::new();
|
||||
|
||||
// 1. Rect-based detection first (well-tested, high precision)
|
||||
let (rect_tables, hint_regions) =
|
||||
detect_tables_from_rects(band_items, band_rects, page);
|
||||
for table in &rect_tables {
|
||||
for &idx in &table.item_indices {
|
||||
rect_claimed.insert(idx);
|
||||
if let Some(&(global_idx, _)) = group.get(idx) {
|
||||
table_items.insert(global_idx);
|
||||
if let Some(&page_idx) = band_index_map.get(idx) {
|
||||
if let Some(&(global_idx, _)) = group.get(page_idx) {
|
||||
table_items.insert(global_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
let table_y = table.rows.first().copied().unwrap_or(0.0);
|
||||
@@ -267,74 +408,94 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
.or_default()
|
||||
.push((table_y, table_md));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Heuristic fallback on unclaimed items
|
||||
let mut run_heuristic =
|
||||
|subset_items: &[TextItem], index_map: &[usize], min_items: usize| {
|
||||
if subset_items.len() < min_items {
|
||||
return;
|
||||
}
|
||||
let tables = detect_tables(subset_items, base_size, false);
|
||||
for table in tables {
|
||||
// 2. Line-based detection on unclaimed items (when rects didn't find tables)
|
||||
if rect_claimed.is_empty() {
|
||||
let line_tables = detect_tables_from_lines(band_items, band_lines, page);
|
||||
for table in &line_tables {
|
||||
for &idx in &table.item_indices {
|
||||
if let Some(&page_idx) = index_map.get(idx) {
|
||||
rect_claimed.insert(idx);
|
||||
if let Some(&page_idx) = band_index_map.get(idx) {
|
||||
if let Some(&(global_idx, _)) = group.get(page_idx) {
|
||||
table_items.insert(global_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
let table_y = table.rows.first().copied().unwrap_or(0.0);
|
||||
let table_md = table_to_markdown(&table);
|
||||
let table_md = table_to_markdown(table);
|
||||
page_tables
|
||||
.entry(page)
|
||||
.or_default()
|
||||
.push((table_y, table_md));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Run heuristic detection on unclaimed items
|
||||
if rect_claimed.is_empty() && hint_regions.is_empty() {
|
||||
// No rect tables or hints — run heuristic on all items
|
||||
let identity_map: Vec<usize> = (0..page_items.len()).collect();
|
||||
run_heuristic(&page_items, &identity_map, 6);
|
||||
} else if rect_claimed.is_empty() && !hint_regions.is_empty() {
|
||||
// No rect tables but hint regions exist — run heuristic separately
|
||||
// on items inside each hint region and on items outside all hints.
|
||||
// This prevents graph labels from being merged into nearby tables.
|
||||
let padding = 15.0; // include header lines slightly above rects
|
||||
for hint in &hint_regions {
|
||||
let (inside_items, inside_map): (Vec<TextItem>, Vec<usize>) = page_items
|
||||
// 3. Heuristic fallback on unclaimed items
|
||||
let mut run_heuristic =
|
||||
|subset_items: &[TextItem], index_map: &[usize], min_items: usize| {
|
||||
if subset_items.len() < min_items {
|
||||
return;
|
||||
}
|
||||
let tables = detect_tables(subset_items, base_size, false);
|
||||
for table in tables {
|
||||
for &idx in &table.item_indices {
|
||||
if let Some(&band_idx) = index_map.get(idx) {
|
||||
if let Some(&page_idx) = band_index_map.get(band_idx) {
|
||||
if let Some(&(global_idx, _)) = group.get(page_idx) {
|
||||
table_items.insert(global_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let table_y = table.rows.first().copied().unwrap_or(0.0);
|
||||
let table_md = table_to_markdown(&table);
|
||||
page_tables
|
||||
.entry(page)
|
||||
.or_default()
|
||||
.push((table_y, table_md));
|
||||
}
|
||||
};
|
||||
|
||||
// Run heuristic detection on unclaimed items
|
||||
if rect_claimed.is_empty() && hint_regions.is_empty() {
|
||||
// No rect tables or hints — run heuristic on all band items
|
||||
let identity_map: Vec<usize> = (0..band_items.len()).collect();
|
||||
run_heuristic(band_items, &identity_map, 6);
|
||||
} else if rect_claimed.is_empty() && !hint_regions.is_empty() {
|
||||
// No rect tables but hint regions exist — run heuristic separately
|
||||
// on items inside each hint region and on items outside all hints.
|
||||
let padding = 15.0;
|
||||
for hint in &hint_regions {
|
||||
let (inside_items, inside_map): (Vec<TextItem>, Vec<usize>) = band_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, item)| {
|
||||
item.y >= hint.y_bottom - padding && item.y <= hint.y_top + padding
|
||||
})
|
||||
.map(|(idx, item)| (item.clone(), idx))
|
||||
.unzip();
|
||||
run_heuristic(&inside_items, &inside_map, 6);
|
||||
for &band_idx in &inside_map {
|
||||
rect_claimed.insert(band_idx);
|
||||
}
|
||||
}
|
||||
let (outside_items, outside_map): (Vec<TextItem>, Vec<usize>) = band_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, item)| {
|
||||
item.y >= hint.y_bottom - padding && item.y <= hint.y_top + padding
|
||||
})
|
||||
.filter(|(idx, _)| !rect_claimed.contains(idx))
|
||||
.map(|(idx, item)| (item.clone(), idx))
|
||||
.unzip();
|
||||
run_heuristic(&inside_items, &inside_map, 6);
|
||||
// Mark hint-region items as claimed so they aren't re-processed
|
||||
for &page_idx in &inside_map {
|
||||
rect_claimed.insert(page_idx);
|
||||
}
|
||||
run_heuristic(&outside_items, &outside_map, 6);
|
||||
} else {
|
||||
// Rect tables found — run heuristic on unclaimed items
|
||||
let (unclaimed_items, unclaimed_map): (Vec<TextItem>, Vec<usize>) = band_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| !rect_claimed.contains(idx))
|
||||
.map(|(idx, item)| (item.clone(), idx))
|
||||
.unzip();
|
||||
run_heuristic(&unclaimed_items, &unclaimed_map, 6);
|
||||
}
|
||||
// Run heuristic on remaining items outside all hint regions
|
||||
let (outside_items, outside_map): (Vec<TextItem>, Vec<usize>) = page_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| !rect_claimed.contains(idx))
|
||||
.map(|(idx, item)| (item.clone(), idx))
|
||||
.unzip();
|
||||
run_heuristic(&outside_items, &outside_map, 6);
|
||||
} else {
|
||||
// Rect tables found — run heuristic on unclaimed items
|
||||
let (unclaimed_items, unclaimed_map): (Vec<TextItem>, Vec<usize>) = page_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| !rect_claimed.contains(idx))
|
||||
.map(|(idx, item)| (item.clone(), idx))
|
||||
.unzip();
|
||||
run_heuristic(&unclaimed_items, &unclaimed_map, 6);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user