fix(tables): recognize chart-bar clusters and mask their text from table detection (#159)
Bar charts drawn as filled rects read as cell rects or aligned text: the cell-rect fallback gridded their axis labels into phantom tables, and when rect paths rejected them, hint regions and the gap-histogram heuristic re-gridded the same text. On survey-report pages this scrambled reading order and swallowed section headings. Detection (is_chart_bar_cluster): the dominant equal-breadth rect family arranged in >=2 spaced positions (bars; cell rects touch), data-driven extent variation (>=1.3x), no same-offset/same-extent partners across positions (grid rows pair up, chart segments don't), and only numeric labels inside. Mirrored predicate covers horizontal bar charts. Chart clusters are skipped in detect_tables_from_rects (no table, no hint), and a new detect_chart_regions pass lets the markdown pipeline pre-claim chart items so heuristic/line/column detection and the merged-band retry all skip them — the text flows out as plain lines. opendataloader-bench: overall 0.8389 -> 0.8446, TEDS 0.699 -> 0.708, MHS 0.742 -> 0.750, NID 0.889 -> 0.894; 7 docs up (best +0.459), none down. pdf-evals changed-set composite +0.013, TEDS +0.036. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e48e34dfe9
commit
2ed152e49b
+60
-7
@@ -744,6 +744,34 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
cols.len() >= 2
|
||||
};
|
||||
|
||||
// Chart-bar regions: bar charts drawn as filled rects read as cell
|
||||
// rects or aligned text and get gridded into phantom tables. Their
|
||||
// items are excluded from every table detector below and flow through
|
||||
// as plain text instead.
|
||||
let page_rect_vec: Vec<PdfRect> =
|
||||
rects.iter().filter(|r| r.page == page).cloned().collect();
|
||||
let chart_regions = crate::tables::detect_chart_regions(&page_items, &page_rect_vec, page);
|
||||
// Pad the claim region: axis/category labels sit just outside the
|
||||
// bar rects (below the axis, left of the scale) and belong to the
|
||||
// chart as much as the bars do.
|
||||
const CHART_PAD: f32 = 20.0;
|
||||
let in_chart = |it: &TextItem| {
|
||||
chart_regions.iter().any(|&(x0, y0, x1, y1)| {
|
||||
let cx = it.x + it.width / 2.0;
|
||||
cx >= x0 - CHART_PAD
|
||||
&& cx <= x1 + CHART_PAD
|
||||
&& it.y >= y0 - CHART_PAD
|
||||
&& it.y <= y1 + CHART_PAD
|
||||
})
|
||||
};
|
||||
if !chart_regions.is_empty() {
|
||||
log::debug!(
|
||||
"page {}: {} chart region(s) masked from table detection",
|
||||
page,
|
||||
chart_regions.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Check for side-by-side layout (e.g. two tables placed left and right)
|
||||
let mut bands = split_side_by_side(&page_items);
|
||||
// A rect table crossing a proposed split boundary means the "gutter"
|
||||
@@ -837,6 +865,16 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
// Track which band-local indices are claimed by structural detection
|
||||
let mut rect_claimed: HashSet<usize> = HashSet::new();
|
||||
|
||||
// Pre-claim chart items: every detector below skips claimed
|
||||
// indices, and unclaimed-by-tables text flows out as plain lines.
|
||||
if !chart_regions.is_empty() {
|
||||
for (idx, item) in band_items.iter().enumerate() {
|
||||
if in_chart(item) {
|
||||
rect_claimed.insert(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 0. Structure-tree detection (highest priority — semantic PDF tagging)
|
||||
// Only use struct-tree tables when they capture a majority (≥50%) of
|
||||
// band items. Incomplete struct trees (partial tagging) should fall
|
||||
@@ -1092,15 +1130,20 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
}
|
||||
}
|
||||
if synth_lines.len() >= 10 {
|
||||
let page_text: Vec<TextItem> = text_items
|
||||
// Chart text stays out of the thin-rect fallback too — a
|
||||
// chart's thin grid rules would otherwise re-grid it.
|
||||
let (page_text, page_text_map): (Vec<TextItem>, Vec<usize>) = text_items
|
||||
.iter()
|
||||
.filter(|i| i.page == page)
|
||||
.cloned()
|
||||
.collect();
|
||||
.enumerate()
|
||||
.filter(|(_, i)| i.page == page && !in_chart(i))
|
||||
.map(|(idx, i)| (i.clone(), idx))
|
||||
.unzip();
|
||||
let line_tables = detect_tables_from_lines(&page_text, &synth_lines, page);
|
||||
for table in &line_tables {
|
||||
for &idx in &table.item_indices {
|
||||
table_items.insert(idx);
|
||||
if let Some(&global_idx) = page_text_map.get(idx) {
|
||||
table_items.insert(global_idx);
|
||||
}
|
||||
}
|
||||
let table_y = table.rows.first().copied().unwrap_or(0.0);
|
||||
let table_md = table_to_markdown(table);
|
||||
@@ -1124,10 +1167,20 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
band_items.len(),
|
||||
was_split
|
||||
);
|
||||
let heuristic_tables = detect_tables(band_items, base_size, page_has_columns);
|
||||
// Chart text stays out of the retry as well.
|
||||
let (chart_free, chart_free_map): (Vec<TextItem>, Vec<usize>) = band_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, it)| !in_chart(it))
|
||||
.map(|(i, it)| (it.clone(), i))
|
||||
.unzip();
|
||||
let heuristic_tables = detect_tables(&chart_free, base_size, page_has_columns);
|
||||
for table in &heuristic_tables {
|
||||
for &idx in &table.item_indices {
|
||||
if let Some(&page_idx) = band_index_map.get(idx) {
|
||||
if let Some(&page_idx) = chart_free_map
|
||||
.get(idx)
|
||||
.and_then(|&band_idx| band_index_map.get(band_idx))
|
||||
{
|
||||
if let Some(&(global_idx, _)) = group.get(page_idx) {
|
||||
table_items.insert(global_idx);
|
||||
}
|
||||
|
||||
+280
-5
@@ -226,6 +226,67 @@ pub struct RectHintRegion {
|
||||
/// Also returns hint regions: bounding boxes of cell-sized rects from clusters
|
||||
/// that failed full grid validation. These can be used to scope heuristic
|
||||
/// detection and prevent unrelated items from being merged into tables.
|
||||
/// Bounding boxes of chart-bar clusters on the page. Text inside these
|
||||
/// regions (axis labels, data values, legends) belongs to a figure and must
|
||||
/// not be gridded into a table by any detection strategy.
|
||||
pub fn detect_chart_regions(
|
||||
items: &[TextItem],
|
||||
rects: &[PdfRect],
|
||||
page: u32,
|
||||
) -> Vec<(f32, f32, f32, f32)> {
|
||||
// Match detect_tables_from_rects: image placeholders are not text and
|
||||
// would defeat the bar-content check.
|
||||
let items_owned: Vec<TextItem> = items
|
||||
.iter()
|
||||
.filter(|i| crate::extractor::is_text_layout_item(i))
|
||||
.cloned()
|
||||
.collect();
|
||||
let items = items_owned.as_slice();
|
||||
let page_rects: Vec<(f32, f32, f32, f32)> = rects
|
||||
.iter()
|
||||
.filter(|r| r.page == page)
|
||||
.map(|r| {
|
||||
let (x, w) = if r.width < 0.0 {
|
||||
(r.x + r.width, -r.width)
|
||||
} else {
|
||||
(r.x, r.width)
|
||||
};
|
||||
let (y, h) = if r.height < 0.0 {
|
||||
(r.y + r.height, -r.height)
|
||||
} else {
|
||||
(r.y, r.height)
|
||||
};
|
||||
(x, y, w, h)
|
||||
})
|
||||
// Origin-anchored page backgrounds/clipping paths are never chart
|
||||
// geometry, and letting one bridge into a bar cluster would inflate
|
||||
// the region to the whole page.
|
||||
.filter(|&(x, y, w, h)| w >= 5.0 && h >= 5.0 && !(x < 5.0 && y < 5.0))
|
||||
.collect();
|
||||
if page_rects.len() < 6 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut regions = Vec::new();
|
||||
for cluster in &cluster_rects(&page_rects, 3.0, 6) {
|
||||
let group: Vec<(f32, f32, f32, f32)> = cluster.iter().map(|&i| page_rects[i]).collect();
|
||||
if is_chart_bar_cluster(items, &group, page) {
|
||||
let bbox = group.iter().fold(
|
||||
(
|
||||
f32::INFINITY,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
),
|
||||
|(x0, y0, x1, y1), &(x, y, w, h)| {
|
||||
(x0.min(x), y0.min(y), x1.max(x + w), y1.max(y + h))
|
||||
},
|
||||
);
|
||||
regions.push(bbox);
|
||||
}
|
||||
}
|
||||
regions
|
||||
}
|
||||
|
||||
pub fn detect_tables_from_rects(
|
||||
items: &[TextItem],
|
||||
rects: &[PdfRect],
|
||||
@@ -379,9 +440,23 @@ pub fn detect_tables_from_rects(
|
||||
.collect();
|
||||
|
||||
debug!("page {}: {} clusters with >= 6 rects", page, clusters.len());
|
||||
for cluster_indices in &clusters {
|
||||
let mut chart_cluster_ids: Vec<usize> = Vec::new();
|
||||
for (cluster_id, cluster_indices) in clusters.iter().enumerate() {
|
||||
let group_rects: Vec<(f32, f32, f32, f32)> =
|
||||
cluster_indices.iter().map(|&i| page_rects[i]).collect();
|
||||
// Chart bars are neither table cells nor a hint region — gridding
|
||||
// a chart's axis labels scrambles the page. Skip the cluster
|
||||
// entirely so it can't reach any detector, the merged fallback,
|
||||
// or the hint fallback.
|
||||
if is_chart_bar_cluster(items, &group_rects, page) {
|
||||
debug!(
|
||||
"page {}: skipping chart-bar cluster ({} rects)",
|
||||
page,
|
||||
group_rects.len()
|
||||
);
|
||||
chart_cluster_ids.push(cluster_id);
|
||||
continue;
|
||||
}
|
||||
if let Some(table) = detect_table_from_rect_group(items, &group_rects, page) {
|
||||
tables.push(table);
|
||||
} else if let Some(table) = detect_row_stripe_table(items, &group_rects, page) {
|
||||
@@ -421,12 +496,19 @@ pub fn detect_tables_from_rects(
|
||||
// text-based column detection.
|
||||
let only_narrow = !tables.is_empty() && tables.iter().all(|t| t.columns.len() <= 3);
|
||||
if tables.is_empty() || only_narrow {
|
||||
let total_clustered: usize = clusters.iter().map(|c| c.len()).sum();
|
||||
if clusters.len() >= 3 && total_clustered >= 50 {
|
||||
// Chart clusters stay out of the merge as well.
|
||||
let table_clusters: Vec<&Vec<usize>> = clusters
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(id, _)| !chart_cluster_ids.contains(id))
|
||||
.map(|(_, c)| c)
|
||||
.collect();
|
||||
let total_clustered: usize = table_clusters.iter().map(|c| c.len()).sum();
|
||||
if table_clusters.len() >= 3 && total_clustered >= 50 {
|
||||
debug!(
|
||||
"page {}: trying merged-cluster fallback ({} clusters, {} rects{})",
|
||||
page,
|
||||
clusters.len(),
|
||||
table_clusters.len(),
|
||||
total_clustered,
|
||||
if only_narrow {
|
||||
", replacing narrow tables"
|
||||
@@ -434,7 +516,7 @@ pub fn detect_tables_from_rects(
|
||||
""
|
||||
}
|
||||
);
|
||||
let all_cluster_rects: Vec<(f32, f32, f32, f32)> = clusters
|
||||
let all_cluster_rects: Vec<(f32, f32, f32, f32)> = table_clusters
|
||||
.iter()
|
||||
.flat_map(|idxs| idxs.iter().map(|&i| page_rects[i]))
|
||||
.collect();
|
||||
@@ -1827,6 +1909,118 @@ fn row_stripe_is_sparse_prose_outline(cells: &[Vec<String>]) -> bool {
|
||||
/// 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(
|
||||
items: &[TextItem],
|
||||
group_rects: &[(f32, f32, f32, f32)],
|
||||
page: u32,
|
||||
) -> bool {
|
||||
let numeric_or_empty = |(rx, ry, rw, rh): (f32, f32, f32, f32)| {
|
||||
let inside: Vec<&TextItem> = items
|
||||
.iter()
|
||||
.filter(|it| {
|
||||
let cx = it.x + it.width / 2.0;
|
||||
it.page == page && cx >= rx && cx <= rx + rw && it.y >= ry && it.y <= ry + rh
|
||||
})
|
||||
.collect();
|
||||
// Any number of numeric data labels is chart-like; a single run of
|
||||
// word text inside means a table cell.
|
||||
inside.iter().all(|it| {
|
||||
let t = it.text.trim();
|
||||
let data = t
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_digit() || ",.%-".contains(*c))
|
||||
.count();
|
||||
t.is_empty() || data * 2 >= t.chars().count()
|
||||
})
|
||||
};
|
||||
|
||||
// Bars: the dominant equal-width family, arranged in >=2 spaced columns
|
||||
// (inter-column gap >= half a bar width — table cell rects touch), with
|
||||
// data-driven height variation (checkbox/cell grids are uniform).
|
||||
// Mirrored predicate catches horizontal bar charts.
|
||||
let bar_family = |pos: fn(&(f32, f32, f32, f32)) -> f32,
|
||||
breadth: fn(&(f32, f32, f32, f32)) -> f32,
|
||||
length: fn(&(f32, f32, f32, f32)) -> f32,
|
||||
along: fn(&(f32, f32, f32, f32)) -> f32| {
|
||||
group_rects.iter().any(|anchor| {
|
||||
let bw = breadth(anchor);
|
||||
if bw <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let family: Vec<&(f32, f32, f32, f32)> = group_rects
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
(breadth(r) - bw).abs() <= (bw * 0.1).max(2.0)
|
||||
&& length(r) > 0.0
|
||||
&& length(r) < bw * 20.0
|
||||
})
|
||||
.collect();
|
||||
if family.len() < 4 {
|
||||
return false;
|
||||
}
|
||||
// Distinct positions along the axis (bar columns).
|
||||
let mut positions: Vec<f32> = Vec::new();
|
||||
for r in &family {
|
||||
let p = pos(r);
|
||||
if !positions.iter().any(|&q| (q - p).abs() <= 2.0) {
|
||||
positions.push(p);
|
||||
}
|
||||
}
|
||||
if positions.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
positions.sort_by(|a, b| a.total_cmp(b));
|
||||
let min_gap = positions
|
||||
.windows(2)
|
||||
.map(|w| w[1] - w[0] - bw)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
if min_gap < bw * 0.5 {
|
||||
return false;
|
||||
}
|
||||
// Data-driven variation along the bar direction.
|
||||
let len_min = family
|
||||
.iter()
|
||||
.map(|r| length(r))
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let len_max = family
|
||||
.iter()
|
||||
.map(|r| length(r))
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
if len_max < len_min * 1.3 {
|
||||
return false;
|
||||
}
|
||||
// Grid rows disguise as bars: a table's cell rects have same-y,
|
||||
// same-height partners in other columns (uniform row heights).
|
||||
// Chart segments start where the previous datum ended, so their
|
||||
// extents rarely pair up across positions.
|
||||
let matched = family
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
family.iter().any(|s| {
|
||||
(pos(s) - pos(r)).abs() > 2.0
|
||||
&& (along(s) - along(r)).abs() <= 3.0
|
||||
&& (length(s) - length(r)).abs() <= 3.0
|
||||
})
|
||||
})
|
||||
.count();
|
||||
if matched * 5 >= family.len() * 3 {
|
||||
return false;
|
||||
}
|
||||
family.iter().filter(|r| numeric_or_empty(***r)).count() * 3 >= family.len() * 2
|
||||
})
|
||||
};
|
||||
|
||||
// vertical bars: position/breadth = x/width, length = height, along = y
|
||||
bar_family(|r| r.0, |r| r.2, |r| r.3, |r| r.1)
|
||||
// horizontal bars: position/breadth = y/height, length = width, along = x
|
||||
|| bar_family(|r| r.1, |r| r.3, |r| r.2, |r| r.0)
|
||||
}
|
||||
|
||||
fn detect_row_stripe_table_from_cell_rects(
|
||||
items: &[TextItem],
|
||||
group_rects: &[(f32, f32, f32, f32)],
|
||||
@@ -2702,6 +2896,87 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// --- is_chart_bar_cluster / detect_chart_regions ---
|
||||
|
||||
/// Stacked bar chart: frame + 3 columns of equal-width segments with
|
||||
/// data-driven heights, holding numeric labels.
|
||||
fn chart_rects() -> Vec<PdfRect> {
|
||||
let mut rects = vec![PdfRect {
|
||||
x: 126.0,
|
||||
y: 548.0,
|
||||
width: 396.0,
|
||||
height: 216.0,
|
||||
page: 1,
|
||||
}];
|
||||
let bars = [
|
||||
(208.0, 618.0, 59.0),
|
||||
(208.0, 661.0, 39.0),
|
||||
(208.0, 696.0, 37.0),
|
||||
(313.0, 618.0, 67.0),
|
||||
(313.0, 670.0, 49.0),
|
||||
(313.0, 691.0, 42.0),
|
||||
(419.0, 618.0, 73.0),
|
||||
(419.0, 684.0, 37.0),
|
||||
(419.0, 708.0, 25.0),
|
||||
];
|
||||
for (x, y, h) in bars {
|
||||
rects.push(PdfRect {
|
||||
x,
|
||||
y,
|
||||
width: 46.0,
|
||||
height: h,
|
||||
page: 1,
|
||||
});
|
||||
}
|
||||
rects
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_bars_produce_region_not_table() {
|
||||
let items: Vec<TextItem> = [
|
||||
("38", 228.0, 638.0),
|
||||
("30", 228.0, 676.0),
|
||||
("46", 333.0, 643.0),
|
||||
("17", 333.0, 679.0),
|
||||
("57", 438.0, 650.0),
|
||||
("20", 438.0, 694.0),
|
||||
]
|
||||
.iter()
|
||||
.map(|&(t, x, y)| make_item(t, x, y, 9.0))
|
||||
.collect();
|
||||
let rects = chart_rects();
|
||||
let regions = detect_chart_regions(&items, &rects, 1);
|
||||
assert_eq!(regions.len(), 1, "expected one chart region");
|
||||
let (tables, hints) = detect_tables_from_rects(&items, &rects, 1);
|
||||
assert!(tables.is_empty(), "chart bars must not become a table");
|
||||
assert!(hints.is_empty(), "chart bars must not become a hint region");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniform_cell_grid_is_not_a_chart() {
|
||||
// Touching, uniform-height cell rects (a real table) must not match:
|
||||
// no inter-column gap and no bar-length variation.
|
||||
let mut rects = Vec::new();
|
||||
for row in 0..4 {
|
||||
for col in 0..3 {
|
||||
rects.push(PdfRect {
|
||||
x: 100.0 + col as f32 * 80.0,
|
||||
y: 600.0 - row as f32 * 20.0,
|
||||
width: 80.0,
|
||||
height: 20.0,
|
||||
page: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
let items: Vec<TextItem> = (0..4)
|
||||
.flat_map(|r| {
|
||||
(0..3).map(move |c| (100.0 + c as f32 * 80.0 + 10.0, 605.0 - r as f32 * 20.0))
|
||||
})
|
||||
.map(|(x, y)| make_item("42", x, y, 9.0))
|
||||
.collect();
|
||||
assert!(detect_chart_regions(&items, &rects, 1).is_empty());
|
||||
}
|
||||
|
||||
// --- detect_stacked_box_table ---
|
||||
|
||||
/// N stacked boxes at x=100, w=300, h=22, top-to-bottom from y=600.
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ pub use detect_heuristic::detect_tables;
|
||||
pub(crate) use detect_heuristic::is_table_of_contents;
|
||||
pub use detect_lines::detect_tables_from_lines;
|
||||
pub(crate) use detect_rects::cluster_rects;
|
||||
pub use detect_rects::{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 format::table_to_markdown;
|
||||
pub use structured::{cells_to_markdown, StructuredCell};
|
||||
|
||||
Reference in New Issue
Block a user