From ede48099c0b2118eb9040c831f33d53cfb9e65b7 Mon Sep 17 00:00:00 2001 From: Abimael Martell <1450169+abimaelmartell@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:22:13 -0700 Subject: [PATCH] fix(layout): preserve ruled tables and chart prose order (#262) * fix(layout): preserve ruled tables and chart prose order * fix(layout): harden chart region detection * fix(layout): tighten chart geometry guards * fix(layout): bound chart inference * fix(layout): tighten chart claim bounds * fix(layout): tighten chart evidence * fix(layout): preserve edge-adjacent chart labels * fix(layout): require external chart label overlap --- src/lib.rs | 121 +++++++++- src/markdown/mod.rs | 97 +++++++- src/tables/detect_lines.rs | 452 +++++++++++++++++++++++++++++++++++- src/tables/detect_rects.rs | 464 ++++++++++++++++++++++++++++++++++++- src/tables/mod.rs | 4 +- 5 files changed, 1108 insertions(+), 30 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index e65785b..9df9066 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -491,7 +491,14 @@ pub fn extract_pages_markdown_mem( // Tables need the original numeric cells; columns use folio-cleaned // evidence so removed page numbers cannot create false layout metadata. - let complexity = compute_layout_complexity(&all_items, &filtered_items, &all_rects, &all_lines); + let chart_regions = markdown::chart_regions_by_page(&all_items, &all_rects, &all_lines); + let complexity = compute_layout_complexity_with_chart_regions( + &all_items, + &filtered_items, + &all_rects, + &all_lines, + &chart_regions, + ); // Compute font stats from full document (cross-page consistency). let font_stats = markdown::analysis::calculate_font_stats_from_items(&filtered_items); @@ -565,6 +572,7 @@ pub fn extract_pages_markdown_mem( page_count, prefiltered_page_number_pages: Some(&removed_page_number_pages), prefiltered_page_number_mask: Some(&page_number_removal_mask), + precomputed_chart_regions: Some(&chart_regions), }, ) }; @@ -3816,7 +3824,14 @@ fn process_document( let text_quality = analyze_text_quality(&items); merge_ocr_reasons(&mut ocr_reasons_by_page, text_quality.reasons_by_page); - let layout = compute_layout_complexity(&items, &layout_items, &rects, &lines); + let chart_regions = markdown::chart_regions_by_page(&items, &rects, &lines); + let layout = compute_layout_complexity_with_chart_regions( + &items, + &layout_items, + &rects, + &lines, + &chart_regions, + ); let md = if options.mode == ProcessMode::Analyze { None @@ -3833,6 +3848,7 @@ fn process_document( page_count, prefiltered_page_number_pages: Some(&removed_pages), prefiltered_page_number_mask: Some(removal_mask.as_slice()), + precomputed_chart_regions: Some(&chart_regions), }, )) }; @@ -5633,11 +5649,29 @@ fn select_items_with_document_folio_context( } /// Analyse extracted items and rects for layout complexity. +#[cfg(test)] fn compute_layout_complexity( items: &[types::TextItem], column_items: &[types::TextItem], rects: &[types::PdfRect], lines: &[types::PdfLine], +) -> LayoutComplexity { + let page_chart_regions = markdown::chart_regions_by_page(items, rects, lines); + compute_layout_complexity_with_chart_regions( + items, + column_items, + rects, + lines, + &page_chart_regions, + ) +} + +fn compute_layout_complexity_with_chart_regions( + items: &[types::TextItem], + column_items: &[types::TextItem], + rects: &[types::PdfRect], + lines: &[types::PdfLine], + page_chart_regions: &markdown::PageChartRegions, ) -> LayoutComplexity { use markdown::analysis::calculate_font_stats_from_items; @@ -5659,6 +5693,10 @@ fn compute_layout_complexity( let owned_items: Vec = page_items.iter().map(|i| (*i).clone()).collect(); let page_content_width = tables::content_width(&owned_items); let bands = markdown::split_side_by_side(&owned_items); + let chart_regions = page_chart_regions + .get(&page) + .map(Vec::as_slice) + .unwrap_or_default(); let band_ranges: Vec<(f32, f32)> = if bands.is_empty() { // Single region — use sentinel range that includes everything @@ -5673,7 +5711,8 @@ fn compute_layout_complexity( let band_items: Vec = owned_items .iter() .filter(|item| { - x_lo == f32::MIN || (item.x >= x_lo - margin && item.x < x_hi + margin) + (x_lo == f32::MIN || (item.x >= x_lo - margin && item.x < x_hi + margin)) + && !markdown::item_is_in_chart_region(item, chart_regions) }) .cloned() .collect(); @@ -5725,8 +5764,20 @@ fn compute_layout_complexity( } let mut pages_with_columns: Vec = Vec::new(); - for page in seen_pages { - let cols = extractor::detect_columns(column_items, page, pages_with_tables.contains(&page)); + for &page in &seen_pages { + let chart_regions = page_chart_regions + .get(&page) + .map(Vec::as_slice) + .unwrap_or_default(); + let page_column_items: Vec = column_items + .iter() + .filter(|item| { + item.page == page && !markdown::item_is_in_chart_region(item, chart_regions) + }) + .cloned() + .collect(); + let cols = + extractor::detect_columns(&page_column_items, page, pages_with_tables.contains(&page)); if cols.len() >= 2 { pages_with_columns.push(page); } @@ -5955,6 +6006,66 @@ mod tests { assert!(filtered.pages_with_columns.is_empty()); } + #[test] + fn dense_chart_panel_is_not_reported_as_a_table() { + let mut items: Vec = (0..8) + .flat_map(|row| { + (0..6).map(move |column| { + test_item( + &format!("{}", row * 10 + column), + 105.0 + column as f32 * 35.0, + 525.0 - row as f32 * 15.0, + 24.0, + 10.0, + ) + }) + }) + .collect(); + for row in 0..6 { + items.push(test_item( + "Left column prose continues here", + 80.0, + 320.0 - row as f32 * 15.0, + 160.0, + 10.0, + )); + items.push(test_item( + "Right column prose continues here", + 300.0, + 320.0 - row as f32 * 15.0, + 160.0, + 10.0, + )); + } + let mut lines: Vec = (0..30) + .map(|column| PdfLine { + x1: 100.0 + column as f32 * 8.0, + y1: 400.0, + x2: 100.0 + column as f32 * 8.0, + y2: 550.0, + page: 1, + }) + .collect(); + lines.extend((0..6).map(|row| PdfLine { + x1: 100.0, + y1: 400.0 + row as f32 * 30.0, + x2: 332.0, + y2: 400.0 + row as f32 * 30.0, + page: 1, + })); + let rects = vec![PdfRect { + x: 80.0, + y: 350.0, + width: 280.0, + height: 240.0, + page: 1, + }]; + + let complexity = compute_layout_complexity(&items, &items, &rects, &lines); + + assert!(complexity.pages_with_tables.is_empty()); + } + #[test] fn page_selection_keeps_document_wide_folio_layout_decisions() { let mut items = Vec::new(); diff --git a/src/markdown/mod.rs b/src/markdown/mod.rs index a789361..4300f51 100644 --- a/src/markdown/mod.rs +++ b/src/markdown/mod.rs @@ -69,7 +69,7 @@ fn is_chart_adjacent_label(item: &TextItem, region: (f32, f32, f32, f32)) -> boo || (mostly_inside_chart_width && close_to_chart_edge && category_sized)) } -fn item_is_in_chart_region(item: &TextItem, regions: &[(f32, f32, f32, f32)]) -> bool { +pub(crate) fn item_is_in_chart_region(item: &TextItem, regions: &[(f32, f32, f32, f32)]) -> bool { regions.iter().any(|&(x0, y0, x1, y1)| { let cx = item.x + item.width / 2.0; let within_padded_x = cx >= x0 - CHART_REGION_PAD && cx <= x1 + CHART_REGION_PAD; @@ -92,6 +92,72 @@ fn items_outside_chart_regions( .collect() } +pub(crate) fn merge_chart_regions( + regions: impl IntoIterator, +) -> Vec<(f32, f32, f32, f32)> { + const MERGE_TOLERANCE: f32 = 3.0; + + let mut merged: Vec<(f32, f32, f32, f32)> = Vec::new(); + for (x0, y0, x1, y1) in regions { + let mut current = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + let mut index = 0; + while index < merged.len() { + let candidate = merged[index]; + let overlaps = current.2 + MERGE_TOLERANCE >= candidate.0 + && candidate.2 + MERGE_TOLERANCE >= current.0 + && current.3 + MERGE_TOLERANCE >= candidate.1 + && candidate.3 + MERGE_TOLERANCE >= current.1; + if overlaps { + current = ( + current.0.min(candidate.0), + current.1.min(candidate.1), + current.2.max(candidate.2), + current.3.max(candidate.3), + ); + merged.swap_remove(index); + } else { + index += 1; + } + } + merged.push(current); + } + merged +} + +pub(crate) type PageChartRegions = HashMap>; + +/// Compute the chart masks used by both layout analysis and Markdown output. +/// +/// Keeping the rect-backed and dense-line heuristics behind one entry point +/// ensures metadata and extraction cannot drift when either detector changes. +pub(crate) fn chart_regions_by_page( + items: &[TextItem], + rects: &[PdfRect], + lines: &[PdfLine], +) -> PageChartRegions { + let mut page_items: HashMap> = HashMap::new(); + for item in items.iter().filter(|item| { + matches!( + &item.item_type, + crate::types::ItemType::Text | crate::types::ItemType::FormField + ) + }) { + page_items.entry(item.page).or_default().push(item.clone()); + } + + page_items + .into_iter() + .filter_map(|(page, items)| { + let rect_regions = crate::tables::detect_chart_regions(&items, rects, page); + let line_regions = crate::tables::detect_dense_line_chart_regions(lines, rects, page) + .into_iter() + .filter(|®ion| chart_region_separates_prose_columns(&items, region)); + let regions = merge_chart_regions(rect_regions.into_iter().chain(line_regions)); + (!regions.is_empty()).then_some((page, regions)) + }) + .collect() +} + /// 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 @@ -329,6 +395,15 @@ fn chart_spans_prose_split(region: (f32, f32, f32, f32), split_x: f32) -> bool { split_x - left >= MIN_CHART_WIDTH_PER_SIDE && right - split_x >= MIN_CHART_WIDTH_PER_SIDE } +pub(crate) fn chart_region_separates_prose_columns( + items: &[TextItem], + region: (f32, f32, f32, f32), +) -> bool { + let outside = items_outside_chart_regions(items, &[region]); + chart_page_prose_column_split(&outside) + .is_some_and(|split_x| chart_spans_prose_split(region, split_x)) +} + /// True when adjacent physical rows form an unterminated, lowercase prose /// continuation in the same projected column. fn is_cross_row_prose_continuation(previous: &str, current: &str) -> bool { @@ -1004,6 +1079,7 @@ pub fn to_markdown_from_items_with_rects_and_page_count( page_count: document_page_count, prefiltered_page_number_pages: None, prefiltered_page_number_mask: None, + precomputed_chart_regions: None, }, ) } @@ -1021,6 +1097,9 @@ pub(crate) struct MarkdownDocumentContext<'a> { /// Table detection consumes the original items; the mask is applied only /// after table claims have been established. pub(crate) prefiltered_page_number_mask: Option<&'a [bool]>, + /// Optional chart masks shared with layout analysis so the geometry is + /// detected once and interpreted identically by both pipelines. + pub(crate) precomputed_chart_regions: Option<&'a PageChartRegions>, } /// Convert positioned text items to markdown, using rectangles and line segments for table detection. @@ -1047,6 +1126,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines( page_count: document_page_count, prefiltered_page_number_pages, prefiltered_page_number_mask, + precomputed_chart_regions, } = context; if items.is_empty() { @@ -1119,17 +1199,9 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines( // Chart regions per page: their text must not steer column detection // during line grouping (it fills the gutter and fuses two-column lines). - let mut page_chart_map: HashMap> = HashMap::new(); - for &page in page_groups.keys() { - let page_items_ref: Vec = page_groups[&page] - .iter() - .map(|(_, item)| (*item).clone()) - .collect(); - let regions = crate::tables::detect_chart_regions(&page_items_ref, rects, page); - if !regions.is_empty() { - page_chart_map.insert(page, regions); - } - } + let page_chart_map = precomputed_chart_regions + .cloned() + .unwrap_or_else(|| chart_regions_by_page(&text_items, rects, pdf_lines)); let mut pages: Vec = page_groups.keys().copied().collect(); pages.sort(); @@ -2051,6 +2123,7 @@ mod tests { page_count: 1, prefiltered_page_number_pages: Some(&removed_pages), prefiltered_page_number_mask: Some(&removal_mask), + precomputed_chart_regions: None, }, ); diff --git a/src/tables/detect_lines.rs b/src/tables/detect_lines.rs index f36fbd1..492d47c 100644 --- a/src/tables/detect_lines.rs +++ b/src/tables/detect_lines.rs @@ -4,10 +4,10 @@ //! gridlines. Many IRS forms and government PDFs use these instead of //! `re` (rectangle) operators. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use crate::tables::Table; -use crate::types::{PdfLine, TextItem}; +use crate::types::{PdfLine, PdfRect, TextItem}; use super::detect_rects::{assign_items_to_grid, snap_edges}; @@ -15,11 +15,33 @@ const RULE_Y_TOLERANCE: f32 = 2.0; const RULE_JOIN_GAP: f32 = 6.0; const RULE_SPAN_TOLERANCE: f32 = 8.0; const TEXT_ROW_TOLERANCE: f32 = 2.5; +const DENSE_CHART_MIN_VERTICAL_EDGES: usize = 27; +const DENSE_CHART_LABEL_PAD: f32 = 20.0; +const DENSE_CHART_MAX_SHARED_PANEL_GRIDS: usize = 4; type HorizontalRule = (f32, f32, f32); // (y, x_min, x_max) type VerticalRule = (f32, f32, f32); // (x, y_min, y_max) type AnchoredRow<'a> = (f32, Vec<(usize, &'a TextItem)>); +fn dense_chart_grids_are_co_located( + left: (f32, f32, f32, f32), + right: (f32, f32, f32, f32), +) -> bool { + let left_width = left.2 - left.0; + let right_width = right.2 - right.0; + let left_height = left.3 - left.1; + let right_height = right.3 - right.1; + let horizontal_overlap = (left.2.min(right.2) - left.0.max(right.0)).max(0.0); + let vertical_overlap = (left.3.min(right.3) - left.1.max(right.1)).max(0.0); + let horizontal_gap = (left.0.max(right.0) - left.2.min(right.2)).max(0.0); + let vertical_gap = (left.1.max(right.1) - left.3.min(right.3)).max(0.0); + + (vertical_overlap >= left_height.min(right_height) * 0.5 + && horizontal_gap <= left_width.min(right_width) * 0.5) + || (horizontal_overlap >= left_width.min(right_width) * 0.5 + && vertical_gap <= left_height.min(right_height) * 0.5) +} + #[derive(Debug)] struct TextAnchorTable { table: Table, @@ -1187,6 +1209,279 @@ pub fn detect_tables_from_lines(items: &[TextItem], lines: &[PdfLine], page: u32 detect_tables_from_lines_inner(items, lines, page, true, true) } +/// Bounding boxes of chart panels backed by a very dense vector grid. +/// +/// Tables support at most 25 columns, so a panel with at least 27 distinct, +/// long vertical coordinates plus repeated horizontal rules is treated as +/// chart geometry. When the grid is enclosed by a painted panel rectangle, +/// the region expands to that rectangle so axis labels, legends, and source +/// notes remain part of the figure instead of forming a heuristic table. +pub(crate) fn detect_dense_line_chart_regions( + lines: &[PdfLine], + rects: &[PdfRect], + page: u32, +) -> Vec<(f32, f32, f32, f32)> { + const ANGLE_TOLERANCE: f32 = 0.035; + const MIN_GRID_LINE_LENGTH: f32 = 40.0; + const EXTENT_TOLERANCE: f32 = 6.0; + + let mut verticals = Vec::new(); + let mut horizontals = Vec::new(); + for line in lines.iter().filter(|line| line.page == page) { + let dx = (line.x2 - line.x1).abs(); + let dy = (line.y2 - line.y1).abs(); + let length = dx.hypot(dy); + if length < MIN_GRID_LINE_LENGTH { + continue; + } + if dy > 0.01 && dx / dy <= ANGLE_TOLERANCE { + verticals.push(( + (line.x1 + line.x2) / 2.0, + line.y1.min(line.y2), + line.y1.max(line.y2), + )); + } else if dx > 0.01 && dy / dx <= ANGLE_TOLERANCE { + horizontals.push(( + (line.y1 + line.y2) / 2.0, + line.x1.min(line.x2), + line.x1.max(line.x2), + )); + } + } + if verticals.len() < DENSE_CHART_MIN_VERTICAL_EDGES || horizontals.len() < 3 { + return Vec::new(); + } + + // Group similar vertical extents once. Neighboring buckets are consulted + // below so coordinates that straddle a bucket boundary still form one + // family, while each line participates in only a constant number of + // candidates instead of being re-scanned for every vertical anchor. + let extent_key = |value: f32| (value / EXTENT_TOLERANCE).round() as i32; + let mut extent_buckets: HashMap<(i32, i32), Vec> = HashMap::new(); + for vertical in verticals { + extent_buckets + .entry((extent_key(vertical.1), extent_key(vertical.2))) + .or_default() + .push(vertical); + } + + let mut grid_regions = Vec::new(); + let extent_keys: Vec<(i32, i32)> = extent_buckets.keys().copied().collect(); + for key in extent_keys { + let anchor_family = &extent_buckets[&key]; + let anchor_bottom = anchor_family.iter().map(|vertical| vertical.1).sum::() + / anchor_family.len() as f32; + let anchor_top = anchor_family.iter().map(|vertical| vertical.2).sum::() + / anchor_family.len() as f32; + + let mut family = Vec::new(); + for bottom_offset in -1..=1 { + for top_offset in -1..=1 { + if let Some(bucket) = + extent_buckets.get(&(key.0 + bottom_offset, key.1 + top_offset)) + { + family.extend(bucket.iter().copied().filter(|vertical| { + (vertical.1 - anchor_bottom).abs() <= EXTENT_TOLERANCE + && (vertical.2 - anchor_top).abs() <= EXTENT_TOLERANCE + })); + } + } + } + + let xs = snap_edges(&family.iter().map(|&(x, _, _)| x).collect::>(), 3.0); + if xs.len() < DENSE_CHART_MIN_VERTICAL_EDGES { + continue; + } + + let grid_bottom = + family.iter().map(|vertical| vertical.1).sum::() / family.len() as f32; + let grid_top = family.iter().map(|vertical| vertical.2).sum::() / family.len() as f32; + if grid_top - grid_bottom < 60.0 { + continue; + } + + // A horizontal rule must support the same contiguous dense run of + // vertical coordinates. Splitting at sparse X gaps prevents a shared + // rule from joining a chart to a neighboring ruled table. Keying by + // the covered X-index range also lets multiple chart panels sharing + // the same Y extents produce independent regions. + let mut supported_spans: HashMap<(usize, usize), Vec> = HashMap::new(); + for &(y, line_left, line_right) in &horizontals { + if y < grid_bottom - EXTENT_TOLERANCE || y > grid_top + EXTENT_TOLERANCE { + continue; + } + let start = xs.partition_point(|&x| x < line_left - EXTENT_TOLERANCE); + let end = xs.partition_point(|&x| x <= line_right + EXTENT_TOLERANCE); + if end - start < DENSE_CHART_MIN_VERTICAL_EDGES { + continue; + } + + let mut gaps: Vec = xs[start..end] + .windows(2) + .map(|pair| pair[1] - pair[0]) + .collect(); + gaps.sort_by(f32::total_cmp); + let dense_gap = gaps[gaps.len() / 4]; + let run_break = (dense_gap * 3.0).max(12.0); + let locally_dense_gap_limit = (dense_gap * 1.5).max(6.0); + let locally_dense_gaps = gaps + .iter() + .filter(|&&gap| gap <= locally_dense_gap_limit) + .count(); + + let mut run_start = start; + let mut retained_dense_run = false; + for index in start..end - 1 { + if xs[index + 1] - xs[index] <= run_break { + continue; + } + let run_end = index + 1; + if run_end - run_start >= DENSE_CHART_MIN_VERTICAL_EDGES + && xs[run_end - 1] - xs[run_start] >= 120.0 + { + supported_spans + .entry((run_start, run_end)) + .or_default() + .push(y); + retained_dense_run = true; + } + run_start = run_end; + } + if end - run_start >= DENSE_CHART_MIN_VERTICAL_EDGES + && xs[end - 1] - xs[run_start] >= 120.0 + { + supported_spans.entry((run_start, end)).or_default().push(y); + retained_dense_run = true; + } + + // One or two wider category gaps may split an otherwise dense + // chart into sub-threshold runs. Keep the full family only when + // its total width remains close to the expected dense spacing; + // a neighboring sparse table makes this ratio much larger. + let span_width = xs[end - 1] - xs[start]; + let expected_dense_width = dense_gap * (end - start - 1) as f32; + if !retained_dense_run + && span_width >= 120.0 + && span_width <= expected_dense_width * 1.35 + && gaps.len().saturating_sub(locally_dense_gaps) <= 2 + { + supported_spans.entry((start, end)).or_default().push(y); + } + } + + for ((start, end), ys) in supported_spans { + if snap_edges(&ys, 3.0).len() >= 3 { + grid_regions.push((xs[start], grid_bottom, xs[end - 1], grid_top)); + } + } + } + + // Prefer the smallest qualifying region when a broad rule happens to + // cover a denser nested panel, and retain every non-overlapping panel. + grid_regions.sort_by(|left, right| { + let left_area = (left.2 - left.0) * (left.3 - left.1); + let right_area = (right.2 - right.0) * (right.3 - right.1); + left_area.total_cmp(&right_area) + }); + let mut selected_regions: Vec<(f32, f32, f32, f32)> = Vec::new(); + for region in grid_regions { + let area = (region.2 - region.0) * (region.3 - region.1); + let duplicates_existing = selected_regions.iter().any(|existing| { + let overlap_width = (region.2.min(existing.2) - region.0.max(existing.0)).max(0.0); + let overlap_height = (region.3.min(existing.3) - region.1.max(existing.1)).max(0.0); + let overlap_area = overlap_width * overlap_height; + let existing_area = (existing.2 - existing.0) * (existing.3 - existing.1); + overlap_area >= area.min(existing_area) * 0.8 + }); + if !duplicates_existing { + selected_regions.push(region); + } + } + + let all_grid_regions = selected_regions.clone(); + let mut regions: Vec<_> = selected_regions + .into_iter() + .map(|grid_region| { + let (grid_left, grid_bottom, grid_right, grid_top) = grid_region; + let enclosing_panel = rects + .iter() + .filter(|rect| rect.page == page) + .filter_map(|rect| { + let (left, width) = if rect.width < 0.0 { + (rect.x + rect.width, -rect.width) + } else { + (rect.x, rect.width) + }; + let (bottom, height) = if rect.height < 0.0 { + (rect.y + rect.height, -rect.height) + } else { + (rect.y, rect.height) + }; + let right = left + width; + let top = bottom + height; + let enclosed_grids: Vec<_> = all_grid_regions + .iter() + .filter(|&&(other_left, other_bottom, other_right, other_top)| { + left <= other_left + EXTENT_TOLERANCE + && right >= other_right - EXTENT_TOLERANCE + && bottom <= other_bottom + EXTENT_TOLERANCE + && top >= other_top - EXTENT_TOLERANCE + }) + .copied() + .collect(); + if enclosed_grids.len() > DENSE_CHART_MAX_SHARED_PANEL_GRIDS + || enclosed_grids.iter().any(|&other| { + other != grid_region + && !dense_chart_grids_are_co_located(grid_region, other) + }) + { + return None; + } + let enclosed_grid_bounds = + enclosed_grids.into_iter().reduce(|bounds, other| { + ( + bounds.0.min(other.0), + bounds.1.min(other.1), + bounds.2.max(other.2), + bounds.3.max(other.3), + ) + })?; + let enclosed_width = enclosed_grid_bounds.2 - enclosed_grid_bounds.0; + let enclosed_height = enclosed_grid_bounds.3 - enclosed_grid_bounds.1; + (left <= grid_left + EXTENT_TOLERANCE + && right >= grid_right - EXTENT_TOLERANCE + && bottom <= grid_bottom + EXTENT_TOLERANCE + && top >= grid_top - EXTENT_TOLERANCE + && width <= enclosed_width * 2.0 + && height <= enclosed_height * 4.0 + && !(left < 5.0 && bottom < 5.0)) + .then_some(((left, bottom, right, top), width * height)) + }) + .min_by(|left, right| left.1.total_cmp(&right.1)) + .map(|(region, _)| region); + + enclosing_panel.unwrap_or(( + grid_left - DENSE_CHART_LABEL_PAD, + grid_bottom - DENSE_CHART_LABEL_PAD, + grid_right + DENSE_CHART_LABEL_PAD, + grid_top + DENSE_CHART_LABEL_PAD, + )) + }) + .collect(); + regions.sort_by(|left, right| { + left.0 + .total_cmp(&right.0) + .then_with(|| left.1.total_cmp(&right.1)) + }); + regions.dedup_by(|left, right| { + (left.0 - right.0).abs() <= EXTENT_TOLERANCE + && (left.1 - right.1).abs() <= EXTENT_TOLERANCE + && (left.2 - right.2).abs() <= EXTENT_TOLERANCE + && (left.3 - right.3).abs() <= EXTENT_TOLERANCE + }); + regions +} + /// Detect only tables whose cell grid is backed by explicit vector geometry. /// /// Region-level TSR callers need physical cell boundaries for crop bboxes, so @@ -1621,6 +1916,159 @@ mod tests { } } + #[test] + fn dense_vector_grid_expands_to_enclosing_chart_panel() { + let mut lines: Vec = (0..30) + .map(|column| make_vline(100.0 + column as f32 * 8.0, 400.0, 550.0, 1)) + .collect(); + lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 100.0, 332.0, 1))); + let rects = vec![PdfRect { + x: 80.0, + y: 350.0, + width: 280.0, + height: 240.0, + page: 1, + }]; + + assert_eq!( + detect_dense_line_chart_regions(&lines, &rects, 1), + vec![(80.0, 350.0, 360.0, 590.0)] + ); + } + + #[test] + fn frameless_dense_vector_grid_includes_label_padding() { + let mut lines: Vec = (0..30) + .map(|column| make_vline(100.0 + column as f32 * 8.0, 400.0, 550.0, 1)) + .collect(); + lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 100.0, 332.0, 1))); + + assert_eq!( + detect_dense_line_chart_regions(&lines, &[], 1), + vec![(80.0, 380.0, 352.0, 570.0)] + ); + } + + #[test] + fn multiple_dense_vector_panels_are_retained() { + let mut lines = Vec::new(); + for panel_left in [60.0, 380.0] { + lines.extend( + (0..30).map(|column| make_vline(panel_left + column as f32 * 8.0, 400.0, 550.0, 1)), + ); + lines.extend((0..6).map(|row| { + make_hline(400.0 + row as f32 * 30.0, panel_left, panel_left + 232.0, 1) + })); + } + + assert_eq!( + detect_dense_line_chart_regions(&lines, &[], 1), + vec![(40.0, 380.0, 312.0, 570.0), (360.0, 380.0, 632.0, 570.0),] + ); + } + + #[test] + fn multiple_dense_vector_panels_use_shared_enclosing_panel() { + let mut lines = Vec::new(); + for panel_left in [60.0, 380.0] { + lines.extend( + (0..30).map(|column| make_vline(panel_left + column as f32 * 8.0, 400.0, 550.0, 1)), + ); + lines.extend((0..6).map(|row| { + make_hline(400.0 + row as f32 * 30.0, panel_left, panel_left + 232.0, 1) + })); + } + let rects = vec![PdfRect { + x: 40.0, + y: 350.0, + width: 592.0, + height: 240.0, + page: 1, + }]; + + assert_eq!( + detect_dense_line_chart_regions(&lines, &rects, 1), + vec![(40.0, 350.0, 632.0, 590.0)] + ); + } + + #[test] + fn shared_rules_do_not_join_dense_chart_to_adjacent_table() { + let mut lines: Vec = (0..30) + .map(|column| make_vline(60.0 + column as f32 * 8.0, 400.0, 550.0, 1)) + .collect(); + + lines.extend( + [330.0, 390.0, 450.0, 510.0, 570.0, 630.0] + .into_iter() + .map(|x| make_vline(x, 400.0, 550.0, 1)), + ); + lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 60.0, 630.0, 1))); + + assert_eq!( + detect_dense_line_chart_regions(&lines, &[], 1), + vec![(40.0, 380.0, 312.0, 570.0)] + ); + } + + #[test] + fn uneven_dense_spacing_keeps_the_complete_chart_region() { + let mut xs: Vec = (0..15).map(|column| 60.0 + column as f32 * 8.0).collect(); + xs.extend((0..15).map(|column| 212.0 + column as f32 * 8.0)); + let mut lines: Vec = xs.iter().map(|&x| make_vline(x, 400.0, 550.0, 1)).collect(); + lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 60.0, 324.0, 1))); + + assert_eq!( + detect_dense_line_chart_regions(&lines, &[], 1), + vec![(40.0, 380.0, 344.0, 570.0)] + ); + } + + #[test] + fn subthreshold_dense_run_does_not_absorb_adjacent_sparse_grid() { + let mut xs: Vec = (0..21).map(|column| 60.0 + column as f32 * 8.0).collect(); + xs.extend((0..6).map(|column| 248.0 + column as f32 * 18.0)); + let mut lines: Vec = xs.iter().map(|&x| make_vline(x, 400.0, 550.0, 1)).collect(); + lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 60.0, 338.0, 1))); + + assert!(detect_dense_line_chart_regions(&lines, &[], 1).is_empty()); + } + + #[test] + fn broad_frame_does_not_merge_distant_dense_grids() { + let mut lines = Vec::new(); + for panel_left in [60.0, 700.0] { + lines.extend( + (0..30).map(|column| make_vline(panel_left + column as f32 * 8.0, 400.0, 550.0, 1)), + ); + lines.extend((0..6).map(|row| { + make_hline(400.0 + row as f32 * 30.0, panel_left, panel_left + 232.0, 1) + })); + } + let rects = vec![PdfRect { + x: 40.0, + y: 350.0, + width: 912.0, + height: 240.0, + page: 1, + }]; + + assert_eq!( + detect_dense_line_chart_regions(&lines, &rects, 1), + vec![(40.0, 380.0, 312.0, 570.0), (680.0, 380.0, 952.0, 570.0)] + ); + } + + #[test] + fn supported_width_vector_table_is_not_a_dense_chart() { + let mut lines: Vec = (0..26) + .map(|column| make_vline(100.0 + column as f32 * 10.0, 400.0, 550.0, 1)) + .collect(); + lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 100.0, 350.0, 1))); + + assert!(detect_dense_line_chart_regions(&lines, &[], 1).is_empty()); + } + #[test] fn test_basic_grid_detection() { // 3x2 grid with horizontal lines at y=500, 480, 460 and vertical at x=100, 200, 300 diff --git a/src/tables/detect_rects.rs b/src/tables/detect_rects.rs index 96606d5..18c31f4 100644 --- a/src/tables/detect_rects.rs +++ b/src/tables/detect_rects.rs @@ -1996,17 +1996,249 @@ fn without_dominant_page_backgrounds(rects: &[(f32, f32, f32, f32)]) -> Vec<(f32 .collect() } -/// Detect a table from cell-background rects that failed grid detection. +/// Repeated rows of touching cell rectangles are stronger table evidence +/// than the bar-length variation used by the chart detector. /// -/// Uses rect Y-edges for row boundaries and text X-position clustering for -/// columns. Handles tables with cell backgrounds that don't form a clean -/// X-edge grid (variable column widths, decorative fills). -/// Chart-bar signature: ≥3 rects sharing an aligned bottom edge (the axis), -/// with similar widths (bars) but strongly varying heights (data-driven), -/// holding at most a single numeric data label each. Bar charts drawn as -/// filled rects otherwise read as cell rects and grid their axis labels -/// into a phantom table. The mirrored check catches horizontal bar charts. -fn is_chart_bar_cluster( +/// Ruled tables with wrapped labels naturally have variable row heights, and +/// numeric-heavy cells can otherwise resemble horizontal or vertical bars. +/// Require several rows to repeat a shared edge schema before overriding the +/// chart hypothesis so sparse plots and independent bars remain unaffected. +fn is_repeated_cell_grid(group_rects: &[(f32, f32, f32, f32)]) -> bool { + type RowGroup = (f32, f32, Vec<(f32, f32)>); + + const ROW_EDGE_TOLERANCE: f32 = 3.0; + const MIN_GRID_ROWS: usize = 4; + const MIN_CELLS_PER_ROW: usize = 3; + + if group_rects.len() < MIN_GRID_ROWS * MIN_CELLS_PER_ROW { + return false; + } + + let mut row_groups: Vec = Vec::new(); + for &(x, y, width, height) in group_rects { + if width < 5.0 || height < 5.0 { + continue; + } + let top = y + height; + if let Some((_, _, cells)) = row_groups.iter_mut().find(|(bottom, row_top, _)| { + (y - *bottom).abs() <= ROW_EDGE_TOLERANCE + && (top - *row_top).abs() <= ROW_EDGE_TOLERANCE + }) { + cells.push((x, x + width)); + } else { + row_groups.push((y, top, vec![(x, x + width)])); + } + } + + let mut row_schemas = Vec::new(); + for (_, _, mut cells) in row_groups { + if cells.len() < MIN_CELLS_PER_ROW { + continue; + } + let mut widths: Vec = cells.iter().map(|&(left, right)| right - left).collect(); + widths.sort_by(f32::total_cmp); + let median_width = widths[widths.len() / 2]; + cells.retain(|&(left, right)| right - left <= median_width * 2.5); + cells.sort_by(|left, right| { + left.0 + .total_cmp(&right.0) + .then_with(|| left.1.total_cmp(&right.1)) + }); + cells.dedup_by(|left, right| { + (left.0 - right.0).abs() <= ROW_EDGE_TOLERANCE + && (left.1 - right.1).abs() <= ROW_EDGE_TOLERANCE + }); + if cells.len() < MIN_CELLS_PER_ROW + || cells + .windows(2) + .any(|pair| pair[1].0 > pair[0].1 + ROW_EDGE_TOLERANCE) + { + continue; + } + let edges: Vec = cells + .iter() + .flat_map(|&(left, right)| [left, right]) + .collect(); + let schema = snap_edges(&edges, ROW_EDGE_TOLERANCE); + if schema.len() > MIN_CELLS_PER_ROW { + row_schemas.push(schema); + } + } + if row_schemas.len() < MIN_GRID_ROWS { + return false; + } + + let reference = row_schemas + .iter() + .max_by_key(|schema| schema.len()) + .expect("grid rows are non-empty"); + row_schemas + .iter() + .filter(|schema| { + let comparable_edges = reference.len().min(schema.len()); + let matched_edges = schema + .iter() + .filter(|edge| { + reference + .iter() + .any(|reference_edge| (*edge - *reference_edge).abs() <= ROW_EDGE_TOLERANCE) + }) + .count(); + matched_edges > MIN_CELLS_PER_ROW && matched_edges * 4 >= comparable_edges * 3 + }) + .count() + >= MIN_GRID_ROWS +} + +fn repeated_cell_grid_overrides_bar_hypothesis(group_rects: &[(f32, f32, f32, f32)]) -> bool { + is_repeated_cell_grid(group_rects) + && without_dominant_page_backgrounds(group_rects).len() == group_rects.len() +} + +/// Detect horizontal segmented stacks from aligned rows of touching rects. +/// +/// Category rows must have visible gutters and data-varying internal segment +/// boundaries, unlike the stable boundaries of a ruled table. +struct SegmentedBarGeometry { + bounds: (f32, f32, f32, f32), + row_bands: Vec<(f32, f32)>, +} + +fn segmented_stacked_bar_geometry( + group_rects: &[(f32, f32, f32, f32)], +) -> Option { + type BarRow = (f32, f32, Vec<(f32, f32)>); + + const EDGE_TOLERANCE: f32 = 3.0; + const MIN_ROWS: usize = 4; + const MIN_SEGMENTS: usize = 3; + + let mut rows: Vec = Vec::new(); + for &(x, y, width, height) in group_rects { + if width < 5.0 || height < 5.0 { + continue; + } + let top = y + height; + if let Some((_, _, segments)) = rows.iter_mut().find(|(bottom, row_top, _)| { + (y - *bottom).abs() <= EDGE_TOLERANCE && (top - *row_top).abs() <= EDGE_TOLERANCE + }) { + segments.push((x, x + width)); + } else { + rows.push((y, top, vec![(x, x + width)])); + } + } + + rows.retain_mut(|(_, _, segments)| { + segments.sort_by(|left, right| left.0.total_cmp(&right.0)); + segments.len() >= MIN_SEGMENTS + && segments + .windows(2) + .all(|pair| (pair[1].0 - pair[0].1).abs() <= EDGE_TOLERANCE) + }); + if rows.len() < MIN_ROWS { + return None; + } + rows.sort_by(|left, right| left.0.total_cmp(&right.0)); + + // Table rows normally share borders. Horizontal stacked bars instead + // leave a visible gutter between category rows. + if rows.windows(2).any(|pair| { + let shorter_height = (pair[0].1 - pair[0].0).min(pair[1].1 - pair[1].0); + pair[1].0 - pair[0].1 < (shorter_height * 0.25).max(2.0) + }) { + return None; + } + + // At least two rows must move an internal segment boundary. Stable + // boundaries across every row are stronger evidence for a ruled table. + let reference_edges: Vec = rows[0] + .2 + .iter() + .take(rows[0].2.len() - 1) + .map(|segment| segment.1) + .collect(); + let drifting_rows = rows + .iter() + .skip(1) + .filter(|(_, _, segments)| { + let edges: Vec = segments + .iter() + .take(segments.len() - 1) + .map(|segment| segment.1) + .collect(); + edges.len() == reference_edges.len() + && edges + .iter() + .zip(&reference_edges) + .any(|(edge, reference)| (edge - reference).abs() > EDGE_TOLERANCE) + }) + .count(); + if drifting_rows < 2 { + return None; + } + + let left = rows + .iter() + .flat_map(|row| &row.2) + .map(|segment| segment.0) + .reduce(f32::min)?; + let right = rows + .iter() + .flat_map(|row| &row.2) + .map(|segment| segment.1) + .reduce(f32::max)?; + let bottom = rows.iter().map(|row| row.0).reduce(f32::min)?; + let top = rows.iter().map(|row| row.1).reduce(f32::max)?; + let row_bands = rows.iter().map(|row| (row.0, row.1)).collect(); + Some(SegmentedBarGeometry { + bounds: (left, bottom, right, top), + row_bands, + }) +} + +/// Category labels beside multiple bar rows are independent chart evidence: +/// numeric table text stays inside its cells, regardless of whether the table +/// has an outer border or extra padding. +fn has_external_segmented_bar_labels( + items: &[TextItem], + page: u32, + geometry: &SegmentedBarGeometry, +) -> bool { + const LABEL_EDGE_TOLERANCE: f32 = 3.0; + const LABEL_CLAIM_PAD: f32 = 20.0; + + let (content_left, _, content_right, _) = geometry.bounds; + let labeled_rows = geometry + .row_bands + .iter() + .filter(|&&(row_bottom, row_top)| { + items.iter().any(|item| { + if item.page != page || item.text.trim().is_empty() { + return false; + } + let item_left = item.x.min(item.x + item.width); + let item_right = item.x.max(item.x + item.width); + let item_center_x = (item_left + item_right) / 2.0; + let item_center_y = item.y + item.height / 2.0; + let beside_stack = (item_center_x <= content_left + LABEL_EDGE_TOLERANCE + && item_center_x >= content_left - LABEL_CLAIM_PAD + && item_left < content_left) + || (item_center_x >= content_right - LABEL_EDGE_TOLERANCE + && item_center_x <= content_right + LABEL_CLAIM_PAD + && item_right > content_right); + beside_stack + && item_center_y >= row_bottom - LABEL_EDGE_TOLERANCE + && item_center_y <= row_top + LABEL_EDGE_TOLERANCE + }) + }) + .count(); + + labeled_rows >= 2 && labeled_rows * 2 >= geometry.row_bands.len() +} + +/// Recognize filled vertical or horizontal bars whose geometry and labels are +/// data-driven rather than uniform table cells. +fn has_chart_bar_signature( items: &[TextItem], group_rects: &[(f32, f32, f32, f32)], page: u32, @@ -2113,6 +2345,30 @@ fn is_chart_bar_cluster( || bar_family(|r| r.1, |r| r.3, |r| r.2, |r| r.0) } +fn is_chart_bar_cluster( + items: &[TextItem], + group_rects: &[(f32, f32, f32, f32)], + page: u32, +) -> bool { + let has_bar_signature = has_chart_bar_signature(items, group_rects, page); + + // A segmented horizontal chart can share most of its edges across rows. + // Row-aligned category labels outside the stack distinguish it from a + // numeric table without depending on whether either shape has a frame. + if has_bar_signature { + if let Some(geometry) = segmented_stacked_bar_geometry(group_rects) { + if has_external_segmented_bar_labels(items, page, &geometry) { + return true; + } + } + } + if repeated_cell_grid_overrides_bar_hypothesis(group_rects) { + return false; + } + + has_bar_signature +} + fn detect_row_stripe_table_from_cell_rects( items: &[TextItem], group_rects: &[(f32, f32, f32, f32)], @@ -3083,6 +3339,194 @@ mod tests { assert!(detect_chart_regions(&items, &rects, 1).is_empty()); } + #[test] + fn variable_height_ruled_grid_overrides_bar_hypothesis() { + let edge_sets = [ + [80.0, 140.0, 200.0, 260.0, 320.0, 380.0, 440.0, 500.0, 560.0], + [80.0, 140.0, 210.0, 260.0, 320.0, 380.0, 450.0, 500.0, 560.0], + ]; + let heights = [20.0, 34.0, 26.0, 42.0, 20.0, 34.0]; + let edge_variants = [0, 0, 0, 0, 1, 1]; + let mut rects = Vec::new(); + let mut y = 650.0; + for (row, height) in heights.into_iter().enumerate() { + let edges = edge_sets[edge_variants[row]]; + rects.extend( + edges + .windows(2) + .map(|edge| (edge[0], y, edge[1] - edge[0], height)), + ); + y -= height; + } + + assert!(is_repeated_cell_grid(&rects)); + assert!(has_chart_bar_signature(&[], &rects, 1)); + assert!(repeated_cell_grid_overrides_bar_hypothesis(&rects)); + assert!(segmented_stacked_bar_geometry(&rects).is_none()); + assert!(!is_chart_bar_cluster(&[], &rects, 1)); + + let mut with_page_fills = + vec![(0.0, 0.0, 600.0, 800.0); DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS]; + with_page_fills.extend(rects); + assert!(!repeated_cell_grid_overrides_bar_hypothesis( + &with_page_fills + )); + } + + #[test] + fn touching_segments_with_spaced_rows_remain_a_chart() { + let row_edges = [ + [100.0, 140.0, 180.0, 220.0, 260.0], + [100.0, 140.0, 180.0, 228.0, 260.0], + [100.0, 140.0, 180.0, 214.0, 260.0], + [100.0, 140.0, 180.0, 232.0, 260.0], + ]; + let mut raw_rects = vec![(90.0, 530.0, 190.0, 100.0)]; + for (row, edges) in row_edges.into_iter().enumerate() { + let y = 540.0 + row as f32 * 20.0; + raw_rects.extend( + edges + .windows(2) + .map(|edge| (edge[0], y, edge[1] - edge[0], 12.0)), + ); + } + let items: Vec = (0..4) + .map(|row| make_item("Category", 62.0, 541.0 + row as f32 * 20.0, 9.0)) + .collect(); + + assert!(is_repeated_cell_grid(&raw_rects)); + assert!(has_chart_bar_signature(&items, &raw_rects, 1)); + let geometry = segmented_stacked_bar_geometry(&raw_rects).expect("segmented stack"); + assert!(has_external_segmented_bar_labels(&items, 1, &geometry)); + assert!(is_chart_bar_cluster(&items, &raw_rects, 1)); + + let numeric_items: Vec = (0..4) + .map(|row| make_item("2024", 80.0, 541.0 + row as f32 * 18.0, 9.0)) + .collect(); + assert!(has_external_segmented_bar_labels( + &numeric_items, + 1, + &geometry + )); + assert!(is_chart_bar_cluster(&numeric_items, &raw_rects, 1)); + + let edge_adjacent_items: Vec = (0..4) + .map(|row| make_item("2024", 92.0, 541.0 + row as f32 * 18.0, 9.0)) + .collect(); + assert!(has_external_segmented_bar_labels( + &edge_adjacent_items, + 1, + &geometry + )); + assert!(is_chart_bar_cluster(&edge_adjacent_items, &raw_rects, 1)); + + let far_items: Vec = (0..4) + .map(|row| make_item("Category", 20.0, 541.0 + row as f32 * 18.0, 9.0)) + .collect(); + assert!(!has_external_segmented_bar_labels(&far_items, 1, &geometry)); + assert!(!is_chart_bar_cluster(&far_items, &raw_rects, 1)); + + let rects: Vec = raw_rects + .into_iter() + .map(|(x, y, width, height)| PdfRect { + x, + y, + width, + height, + page: 1, + }) + .collect(); + assert_eq!(detect_chart_regions(&items, &rects, 1).len(), 1); + let (tables, hints) = detect_tables_from_rects(&items, &rects, 1); + assert!(tables.is_empty()); + assert!(hints.is_empty()); + } + + #[test] + fn padded_numeric_grid_frame_remains_a_table() { + let row_edges = [ + [100.0, 140.0, 180.0, 220.0, 260.0], + [100.0, 140.0, 180.0, 228.0, 260.0], + [100.0, 140.0, 180.0, 214.0, 260.0], + [100.0, 140.0, 180.0, 232.0, 260.0], + ]; + let mut raw_rects = vec![(96.0, 536.0, 168.0, 80.0)]; + let mut items = Vec::new(); + for (row, edges) in row_edges.into_iter().enumerate() { + let y = 540.0 + row as f32 * 20.0; + for edge in edges.windows(2) { + raw_rects.push((edge[0], y, edge[1] - edge[0], 12.0)); + items.push(make_item("42", edge[0] + 8.0, y + 1.0, 9.0)); + } + } + + assert!(is_repeated_cell_grid(&raw_rects)); + assert!(has_chart_bar_signature(&items, &raw_rects, 1)); + let geometry = segmented_stacked_bar_geometry(&raw_rects).expect("segmented rows"); + assert!(!has_external_segmented_bar_labels(&items, 1, &geometry)); + assert!(!is_chart_bar_cluster(&items, &raw_rects, 1)); + + let flush_items: Vec = (0..4) + .map(|row| make_item("1", 100.0, 541.0 + row as f32 * 20.0, 9.0)) + .collect(); + assert!(!has_external_segmented_bar_labels( + &flush_items, + 1, + &geometry + )); + assert!(!is_chart_bar_cluster(&flush_items, &raw_rects, 1)); + + let rects: Vec = raw_rects + .into_iter() + .map(|(x, y, width, height)| PdfRect { + x, + y, + width, + height, + page: 1, + }) + .collect(); + assert!(detect_chart_regions(&items, &rects, 1).is_empty()); + assert!(!detect_tables_from_rects(&items, &rects, 1).0.is_empty()); + } + + #[test] + fn frameless_segmented_chart_with_category_labels_remains_a_chart() { + let row_edges = [ + [100.0, 140.0, 180.0, 220.0, 260.0], + [100.0, 140.0, 180.0, 228.0, 260.0], + [100.0, 140.0, 180.0, 214.0, 260.0], + [100.0, 140.0, 180.0, 232.0, 260.0], + ]; + let mut raw_rects = Vec::new(); + let mut items = Vec::new(); + for (row, edges) in row_edges.into_iter().enumerate() { + let y = 540.0 + row as f32 * 18.0; + raw_rects.extend( + edges + .windows(2) + .map(|edge| (edge[0], y, edge[1] - edge[0], 12.0)), + ); + items.push(make_item("Category", 62.0, y + 1.0, 9.0)); + } + + let geometry = segmented_stacked_bar_geometry(&raw_rects).expect("segmented stack"); + assert!(has_external_segmented_bar_labels(&items, 1, &geometry)); + assert!(is_chart_bar_cluster(&items, &raw_rects, 1)); + + let rects: Vec = raw_rects + .into_iter() + .map(|(x, y, width, height)| PdfRect { + x, + y, + width, + height, + page: 1, + }) + .collect(); + assert_eq!(detect_chart_regions(&items, &rects, 1).len(), 1); + } + // --- detect_stacked_box_table --- /// N stacked boxes at x=100, w=300, h=22, top-to-bottom from y=600. diff --git a/src/tables/mod.rs b/src/tables/mod.rs index deff328..d856cb8 100644 --- a/src/tables/mod.rs +++ b/src/tables/mod.rs @@ -16,7 +16,9 @@ pub(crate) use detect_heuristic::{ content_width, detect_tables_with_page_width, is_table_of_contents, }; pub use detect_lines::detect_tables_from_lines; -pub(crate) use detect_lines::detect_vector_grid_tables_from_lines; +pub(crate) use detect_lines::{ + detect_dense_line_chart_regions, detect_vector_grid_tables_from_lines, +}; pub(crate) use detect_rects::cluster_rects; pub use detect_rects::{detect_chart_regions, detect_tables_from_rects, RectHintRegion}; pub use detect_struct::detect_tables_from_struct_tree;