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
This commit is contained in:
+116
-5
@@ -491,7 +491,14 @@ pub fn extract_pages_markdown_mem(
|
|||||||
|
|
||||||
// Tables need the original numeric cells; columns use folio-cleaned
|
// Tables need the original numeric cells; columns use folio-cleaned
|
||||||
// evidence so removed page numbers cannot create false layout metadata.
|
// 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).
|
// Compute font stats from full document (cross-page consistency).
|
||||||
let font_stats = markdown::analysis::calculate_font_stats_from_items(&filtered_items);
|
let font_stats = markdown::analysis::calculate_font_stats_from_items(&filtered_items);
|
||||||
@@ -565,6 +572,7 @@ pub fn extract_pages_markdown_mem(
|
|||||||
page_count,
|
page_count,
|
||||||
prefiltered_page_number_pages: Some(&removed_page_number_pages),
|
prefiltered_page_number_pages: Some(&removed_page_number_pages),
|
||||||
prefiltered_page_number_mask: Some(&page_number_removal_mask),
|
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);
|
let text_quality = analyze_text_quality(&items);
|
||||||
merge_ocr_reasons(&mut ocr_reasons_by_page, text_quality.reasons_by_page);
|
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 {
|
let md = if options.mode == ProcessMode::Analyze {
|
||||||
None
|
None
|
||||||
@@ -3833,6 +3848,7 @@ fn process_document(
|
|||||||
page_count,
|
page_count,
|
||||||
prefiltered_page_number_pages: Some(&removed_pages),
|
prefiltered_page_number_pages: Some(&removed_pages),
|
||||||
prefiltered_page_number_mask: Some(removal_mask.as_slice()),
|
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.
|
/// Analyse extracted items and rects for layout complexity.
|
||||||
|
#[cfg(test)]
|
||||||
fn compute_layout_complexity(
|
fn compute_layout_complexity(
|
||||||
items: &[types::TextItem],
|
items: &[types::TextItem],
|
||||||
column_items: &[types::TextItem],
|
column_items: &[types::TextItem],
|
||||||
rects: &[types::PdfRect],
|
rects: &[types::PdfRect],
|
||||||
lines: &[types::PdfLine],
|
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 {
|
) -> LayoutComplexity {
|
||||||
use markdown::analysis::calculate_font_stats_from_items;
|
use markdown::analysis::calculate_font_stats_from_items;
|
||||||
|
|
||||||
@@ -5659,6 +5693,10 @@ fn compute_layout_complexity(
|
|||||||
let owned_items: Vec<types::TextItem> = page_items.iter().map(|i| (*i).clone()).collect();
|
let owned_items: Vec<types::TextItem> = page_items.iter().map(|i| (*i).clone()).collect();
|
||||||
let page_content_width = tables::content_width(&owned_items);
|
let page_content_width = tables::content_width(&owned_items);
|
||||||
let bands = markdown::split_side_by_side(&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() {
|
let band_ranges: Vec<(f32, f32)> = if bands.is_empty() {
|
||||||
// Single region — use sentinel range that includes everything
|
// Single region — use sentinel range that includes everything
|
||||||
@@ -5673,7 +5711,8 @@ fn compute_layout_complexity(
|
|||||||
let band_items: Vec<types::TextItem> = owned_items
|
let band_items: Vec<types::TextItem> = owned_items
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|item| {
|
.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()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
@@ -5725,8 +5764,20 @@ fn compute_layout_complexity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut pages_with_columns: Vec<u32> = Vec::new();
|
let mut pages_with_columns: Vec<u32> = Vec::new();
|
||||||
for page in seen_pages {
|
for &page in &seen_pages {
|
||||||
let cols = extractor::detect_columns(column_items, page, pages_with_tables.contains(&page));
|
let chart_regions = page_chart_regions
|
||||||
|
.get(&page)
|
||||||
|
.map(Vec::as_slice)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let page_column_items: Vec<types::TextItem> = 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 {
|
if cols.len() >= 2 {
|
||||||
pages_with_columns.push(page);
|
pages_with_columns.push(page);
|
||||||
}
|
}
|
||||||
@@ -5955,6 +6006,66 @@ mod tests {
|
|||||||
assert!(filtered.pages_with_columns.is_empty());
|
assert!(filtered.pages_with_columns.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dense_chart_panel_is_not_reported_as_a_table() {
|
||||||
|
let mut items: Vec<TextItem> = (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<PdfLine> = (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]
|
#[test]
|
||||||
fn page_selection_keeps_document_wide_folio_layout_decisions() {
|
fn page_selection_keeps_document_wide_folio_layout_decisions() {
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
|
|||||||
+85
-12
@@ -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))
|
|| (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)| {
|
regions.iter().any(|&(x0, y0, x1, y1)| {
|
||||||
let cx = item.x + item.width / 2.0;
|
let cx = item.x + item.width / 2.0;
|
||||||
let within_padded_x = cx >= x0 - CHART_REGION_PAD && cx <= x1 + CHART_REGION_PAD;
|
let within_padded_x = cx >= x0 - CHART_REGION_PAD && cx <= x1 + CHART_REGION_PAD;
|
||||||
@@ -92,6 +92,72 @@ fn items_outside_chart_regions(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn merge_chart_regions(
|
||||||
|
regions: impl IntoIterator<Item = (f32, f32, f32, f32)>,
|
||||||
|
) -> 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<u32, Vec<(f32, f32, f32, f32)>>;
|
||||||
|
|
||||||
|
/// 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<u32, Vec<TextItem>> = 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.
|
/// 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
|
/// 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
|
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
|
/// True when adjacent physical rows form an unterminated, lowercase prose
|
||||||
/// continuation in the same projected column.
|
/// continuation in the same projected column.
|
||||||
fn is_cross_row_prose_continuation(previous: &str, current: &str) -> bool {
|
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,
|
page_count: document_page_count,
|
||||||
prefiltered_page_number_pages: None,
|
prefiltered_page_number_pages: None,
|
||||||
prefiltered_page_number_mask: 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
|
/// Table detection consumes the original items; the mask is applied only
|
||||||
/// after table claims have been established.
|
/// after table claims have been established.
|
||||||
pub(crate) prefiltered_page_number_mask: Option<&'a [bool]>,
|
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.
|
/// 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,
|
page_count: document_page_count,
|
||||||
prefiltered_page_number_pages,
|
prefiltered_page_number_pages,
|
||||||
prefiltered_page_number_mask,
|
prefiltered_page_number_mask,
|
||||||
|
precomputed_chart_regions,
|
||||||
} = context;
|
} = context;
|
||||||
|
|
||||||
if items.is_empty() {
|
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
|
// Chart regions per page: their text must not steer column detection
|
||||||
// during line grouping (it fills the gutter and fuses two-column lines).
|
// during line grouping (it fills the gutter and fuses two-column lines).
|
||||||
let mut page_chart_map: HashMap<u32, Vec<(f32, f32, f32, f32)>> = HashMap::new();
|
let page_chart_map = precomputed_chart_regions
|
||||||
for &page in page_groups.keys() {
|
.cloned()
|
||||||
let page_items_ref: Vec<TextItem> = page_groups[&page]
|
.unwrap_or_else(|| chart_regions_by_page(&text_items, rects, pdf_lines));
|
||||||
.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 mut pages: Vec<u32> = page_groups.keys().copied().collect();
|
let mut pages: Vec<u32> = page_groups.keys().copied().collect();
|
||||||
pages.sort();
|
pages.sort();
|
||||||
@@ -2051,6 +2123,7 @@ mod tests {
|
|||||||
page_count: 1,
|
page_count: 1,
|
||||||
prefiltered_page_number_pages: Some(&removed_pages),
|
prefiltered_page_number_pages: Some(&removed_pages),
|
||||||
prefiltered_page_number_mask: Some(&removal_mask),
|
prefiltered_page_number_mask: Some(&removal_mask),
|
||||||
|
precomputed_chart_regions: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+450
-2
@@ -4,10 +4,10 @@
|
|||||||
//! gridlines. Many IRS forms and government PDFs use these instead of
|
//! gridlines. Many IRS forms and government PDFs use these instead of
|
||||||
//! `re` (rectangle) operators.
|
//! `re` (rectangle) operators.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use crate::tables::Table;
|
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};
|
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_JOIN_GAP: f32 = 6.0;
|
||||||
const RULE_SPAN_TOLERANCE: f32 = 8.0;
|
const RULE_SPAN_TOLERANCE: f32 = 8.0;
|
||||||
const TEXT_ROW_TOLERANCE: f32 = 2.5;
|
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 HorizontalRule = (f32, f32, f32); // (y, x_min, x_max)
|
||||||
type VerticalRule = (f32, f32, f32); // (x, y_min, y_max)
|
type VerticalRule = (f32, f32, f32); // (x, y_min, y_max)
|
||||||
type AnchoredRow<'a> = (f32, Vec<(usize, &'a TextItem)>);
|
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)]
|
#[derive(Debug)]
|
||||||
struct TextAnchorTable {
|
struct TextAnchorTable {
|
||||||
table: Table,
|
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)
|
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<VerticalRule>> = 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::<f32>()
|
||||||
|
/ anchor_family.len() as f32;
|
||||||
|
let anchor_top = anchor_family.iter().map(|vertical| vertical.2).sum::<f32>()
|
||||||
|
/ 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::<Vec<_>>(), 3.0);
|
||||||
|
if xs.len() < DENSE_CHART_MIN_VERTICAL_EDGES {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let grid_bottom =
|
||||||
|
family.iter().map(|vertical| vertical.1).sum::<f32>() / family.len() as f32;
|
||||||
|
let grid_top = family.iter().map(|vertical| vertical.2).sum::<f32>() / 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<f32>> = 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<f32> = 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.
|
/// Detect only tables whose cell grid is backed by explicit vector geometry.
|
||||||
///
|
///
|
||||||
/// Region-level TSR callers need physical cell boundaries for crop bboxes, so
|
/// 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<PdfLine> = (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<PdfLine> = (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<PdfLine> = (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<f32> = (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<PdfLine> = 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<f32> = (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<PdfLine> = 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<PdfLine> = (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]
|
#[test]
|
||||||
fn test_basic_grid_detection() {
|
fn test_basic_grid_detection() {
|
||||||
// 3x2 grid with horizontal lines at y=500, 480, 460 and vertical at x=100, 200, 300
|
// 3x2 grid with horizontal lines at y=500, 480, 460 and vertical at x=100, 200, 300
|
||||||
|
|||||||
+454
-10
@@ -1996,17 +1996,249 @@ fn without_dominant_page_backgrounds(rects: &[(f32, f32, f32, f32)]) -> Vec<(f32
|
|||||||
.collect()
|
.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
|
/// Ruled tables with wrapped labels naturally have variable row heights, and
|
||||||
/// columns. Handles tables with cell backgrounds that don't form a clean
|
/// numeric-heavy cells can otherwise resemble horizontal or vertical bars.
|
||||||
/// X-edge grid (variable column widths, decorative fills).
|
/// Require several rows to repeat a shared edge schema before overriding the
|
||||||
/// Chart-bar signature: ≥3 rects sharing an aligned bottom edge (the axis),
|
/// chart hypothesis so sparse plots and independent bars remain unaffected.
|
||||||
/// with similar widths (bars) but strongly varying heights (data-driven),
|
fn is_repeated_cell_grid(group_rects: &[(f32, f32, f32, f32)]) -> bool {
|
||||||
/// holding at most a single numeric data label each. Bar charts drawn as
|
type RowGroup = (f32, f32, Vec<(f32, f32)>);
|
||||||
/// filled rects otherwise read as cell rects and grid their axis labels
|
|
||||||
/// into a phantom table. The mirrored check catches horizontal bar charts.
|
const ROW_EDGE_TOLERANCE: f32 = 3.0;
|
||||||
fn is_chart_bar_cluster(
|
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<RowGroup> = 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<f32> = 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<f32> = 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<SegmentedBarGeometry> {
|
||||||
|
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<BarRow> = 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<f32> = 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<f32> = 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],
|
items: &[TextItem],
|
||||||
group_rects: &[(f32, f32, f32, f32)],
|
group_rects: &[(f32, f32, f32, f32)],
|
||||||
page: u32,
|
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)
|
|| 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(
|
fn detect_row_stripe_table_from_cell_rects(
|
||||||
items: &[TextItem],
|
items: &[TextItem],
|
||||||
group_rects: &[(f32, f32, f32, f32)],
|
group_rects: &[(f32, f32, f32, f32)],
|
||||||
@@ -3083,6 +3339,194 @@ mod tests {
|
|||||||
assert!(detect_chart_regions(&items, &rects, 1).is_empty());
|
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<TextItem> = (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<TextItem> = (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<TextItem> = (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<TextItem> = (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<PdfRect> = 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<TextItem> = (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<PdfRect> = 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<PdfRect> = 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 ---
|
// --- detect_stacked_box_table ---
|
||||||
|
|
||||||
/// N stacked boxes at x=100, w=300, h=22, top-to-bottom from y=600.
|
/// N stacked boxes at x=100, w=300, h=22, top-to-bottom from y=600.
|
||||||
|
|||||||
+3
-1
@@ -16,7 +16,9 @@ pub(crate) use detect_heuristic::{
|
|||||||
content_width, detect_tables_with_page_width, is_table_of_contents,
|
content_width, detect_tables_with_page_width, is_table_of_contents,
|
||||||
};
|
};
|
||||||
pub use detect_lines::detect_tables_from_lines;
|
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(crate) use detect_rects::cluster_rects;
|
||||||
pub use detect_rects::{detect_chart_regions, detect_tables_from_rects, RectHintRegion};
|
pub use detect_rects::{detect_chart_regions, detect_tables_from_rects, RectHintRegion};
|
||||||
pub use detect_struct::detect_tables_from_struct_tree;
|
pub use detect_struct::detect_tables_from_struct_tree;
|
||||||
|
|||||||
Reference in New Issue
Block a user