diff --git a/src/extractor/layout.rs b/src/extractor/layout.rs index 028530f..4c16dc2 100644 --- a/src/extractor/layout.rs +++ b/src/extractor/layout.rs @@ -35,6 +35,7 @@ pub(crate) fn detect_columns( 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); @@ -166,6 +167,23 @@ pub(crate) fn detect_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; + } return validate_and_build_columns( &valleys, &page_items, @@ -175,7 +193,7 @@ pub(crate) fn detect_columns( MIN_ITEMS_PER_COLUMN, MIN_VERTICAL_SPAN_RATIO, page, - false, // edge-based assignment for absolute valleys + false, // edge-based fallback ); } @@ -505,7 +523,15 @@ fn validate_and_build_columns( }) .collect(); - if left_items.len() < min_items || right_items.len() < min_items { + // 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 { continue; } diff --git a/src/markdown/mod.rs b/src/markdown/mod.rs index 1a7fd17..4e8c318 100644 --- a/src/markdown/mod.rs +++ b/src/markdown/mod.rs @@ -601,6 +601,15 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines( let group = page_groups.get(&page).unwrap(); let page_items: Vec = group.iter().map(|(_, item)| (*item).clone()).collect(); + // Detect columns early — on multi-column pages, the merged-band retry + // should skip body-font heuristic table detection (which mistakes column + // text for tables). Individual band heuristic detection is left enabled + // because bands are scoped to single columns. + let page_has_columns = { + let cols = crate::extractor::detect_columns(&page_items, page, false); + cols.len() >= 2 + }; + // Check for side-by-side layout (e.g. two tables placed left and right) let mut bands = split_side_by_side(&page_items); // Fallback: use rect hint regions to detect side-by-side layout @@ -915,7 +924,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines( band_items.len(), was_split ); - let heuristic_tables = detect_tables(band_items, base_size, false); + let heuristic_tables = detect_tables(band_items, base_size, page_has_columns); for table in &heuristic_tables { for &idx in &table.item_indices { if let Some(&page_idx) = band_index_map.get(idx) {