fix(pdf): improve side-by-side table split detection
- Lower gap threshold from 40pt to 30pt to catch narrower inter-table gaps - Constrain candidates to middle 60% of page X range - Use center-position balance check instead of left-edge counting - Add balanced-cluster dedup (50pt) to distinguish side-by-side tables from single wide tables with multiple column gaps - Improve filter_rects_to_band with 70% containment check to exclude page-wide background stripes
This commit is contained in:
+71
-22
@@ -29,10 +29,12 @@ use convert::{merge_continuation_tables, to_markdown_from_lines_with_tables_and_
|
|||||||
/// clear vertical gap separates two groups of items, or an empty vec if the
|
/// clear vertical gap separates two groups of items, or an empty vec if the
|
||||||
/// page has a single-region layout.
|
/// page has a single-region layout.
|
||||||
///
|
///
|
||||||
/// Evaluates all candidate gaps (≥40pt with ≥20 items on each side) and picks
|
/// Candidate gaps must be ≥30pt and in the middle 60% of the page's X range.
|
||||||
/// the one with the fewest bounding-box crossings. The crossing count must be
|
/// Items are counted by center position for accurate balance (each side ≥20%).
|
||||||
/// under 2% of total items — this prevents splitting paragraphs whose lines
|
/// The candidate with the fewest bounding-box crossings is chosen (must be
|
||||||
/// extend across the gap.
|
/// under 5% of total items). To reject single wide tables with multiple
|
||||||
|
/// column gaps, only pages with one balanced-candidate cluster (within 50pt)
|
||||||
|
/// are accepted.
|
||||||
pub(crate) fn split_side_by_side(items: &[TextItem]) -> Vec<(f32, f32)> {
|
pub(crate) fn split_side_by_side(items: &[TextItem]) -> Vec<(f32, f32)> {
|
||||||
if items.len() < 40 {
|
if items.len() < 40 {
|
||||||
return vec![];
|
return vec![];
|
||||||
@@ -42,15 +44,24 @@ pub(crate) fn split_side_by_side(items: &[TextItem]) -> Vec<(f32, f32)> {
|
|||||||
let mut xs: Vec<f32> = items.iter().map(|i| i.x).collect();
|
let mut xs: Vec<f32> = items.iter().map(|i| i.x).collect();
|
||||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
// Find all candidate gaps: ≥40pt with ≥20 items on each side
|
// Find all candidate gaps: ≥30pt, in the middle 60% of the X range,
|
||||||
let mut candidates: Vec<(f32, usize)> = Vec::new(); // (split_x, gap_pos)
|
// with ≥20 items on each side.
|
||||||
|
let x_min = xs[0];
|
||||||
|
let x_max = *xs.last().unwrap();
|
||||||
|
let x_range = x_max - x_min;
|
||||||
|
let center_lo = x_min + x_range * 0.2;
|
||||||
|
let center_hi = x_min + x_range * 0.8;
|
||||||
|
let mut candidates: Vec<f32> = Vec::new();
|
||||||
for i in 1..xs.len() {
|
for i in 1..xs.len() {
|
||||||
let gap = xs[i] - xs[i - 1];
|
let gap = xs[i] - xs[i - 1];
|
||||||
let left_count = i;
|
let split_x = (xs[i - 1] + xs[i]) / 2.0;
|
||||||
let right_count = xs.len() - i;
|
if gap >= 30.0
|
||||||
if gap >= 40.0 && left_count >= 20 && right_count >= 20 {
|
&& i >= 20
|
||||||
let split_x = (xs[i - 1] + xs[i]) / 2.0;
|
&& (xs.len() - i) >= 20
|
||||||
candidates.push((split_x, i));
|
&& split_x >= center_lo
|
||||||
|
&& split_x <= center_hi
|
||||||
|
{
|
||||||
|
candidates.push(split_x);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,13 +70,18 @@ pub(crate) fn split_side_by_side(items: &[TextItem]) -> Vec<(f32, f32)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pick the candidate with the fewest bounding-box crossings,
|
// Pick the candidate with the fewest bounding-box crossings,
|
||||||
// but only consider balanced splits (smaller side ≥ 30% of total).
|
// but only consider balanced splits (each side ≥ 20% of total items
|
||||||
let min_side = items.len() * 3 / 10;
|
// by center position, which is more accurate than left-edge counting).
|
||||||
|
let min_side = items.len() / 5;
|
||||||
let mut best_split = 0.0f32;
|
let mut best_split = 0.0f32;
|
||||||
let mut best_crossing = usize::MAX;
|
let mut best_crossing = usize::MAX;
|
||||||
for &(split_x, gap_pos) in &candidates {
|
for &split_x in &candidates {
|
||||||
let left_count = gap_pos;
|
// Count items by center position for accurate balance check
|
||||||
let right_count = xs.len() - gap_pos;
|
let left_count = items
|
||||||
|
.iter()
|
||||||
|
.filter(|i| i.x + i.width / 2.0 < split_x)
|
||||||
|
.count();
|
||||||
|
let right_count = items.len() - left_count;
|
||||||
if left_count.min(right_count) < min_side {
|
if left_count.min(right_count) < min_side {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -83,32 +99,65 @@ pub(crate) fn split_side_by_side(items: &[TextItem]) -> Vec<(f32, f32)> {
|
|||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crossing items must be < 2% of total (allows a few spanning headers)
|
// Crossing items must be < 5% of total (allows spanning headers/labels)
|
||||||
let max_crossing = (items.len() / 50).max(2);
|
let max_crossing = (items.len() / 20).max(2);
|
||||||
if best_crossing > max_crossing {
|
if best_crossing > max_crossing {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
|
|
||||||
let x_min = xs[0];
|
// Multiple balanced split candidates that are far apart indicate a
|
||||||
let x_max = *xs.last().unwrap();
|
// multi-column single table. Adjacent candidates (within 20pt) are
|
||||||
|
// treated as the same split point. Side-by-side tables have exactly
|
||||||
|
// one cluster of candidates near the inter-table gap.
|
||||||
|
let mut balanced_positions: Vec<f32> = candidates
|
||||||
|
.iter()
|
||||||
|
.filter(|&&sx| {
|
||||||
|
let lc = items.iter().filter(|i| i.x + i.width / 2.0 < sx).count();
|
||||||
|
let rc = items.len() - lc;
|
||||||
|
lc.min(rc) >= min_side
|
||||||
|
})
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
balanced_positions.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
balanced_positions.dedup_by(|a, b| (*a - *b).abs() < 50.0);
|
||||||
|
if balanced_positions.len() > 1 {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
vec![(x_min, best_split), (best_split, x_max)]
|
vec![(x_min, best_split), (best_split, x_max)]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filter rects to those overlapping an X band.
|
/// Filter rects to those mostly contained within an X band.
|
||||||
|
///
|
||||||
|
/// Excludes rects that extend significantly beyond the band (e.g. page-wide
|
||||||
|
/// background stripes spanning both side-by-side tables). A rect must have
|
||||||
|
/// at least 70% of its width inside the band to be included.
|
||||||
pub(crate) fn filter_rects_to_band(
|
pub(crate) fn filter_rects_to_band(
|
||||||
rects: &[PdfRect],
|
rects: &[PdfRect],
|
||||||
page: u32,
|
page: u32,
|
||||||
x_lo: f32,
|
x_lo: f32,
|
||||||
x_hi: f32,
|
x_hi: f32,
|
||||||
) -> Vec<PdfRect> {
|
) -> Vec<PdfRect> {
|
||||||
|
let band_width = x_hi - x_lo;
|
||||||
rects
|
rects
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| {
|
.filter(|r| {
|
||||||
r.page == page && {
|
r.page == page && {
|
||||||
let rx_min = if r.width >= 0.0 { r.x } else { r.x + r.width };
|
let rx_min = if r.width >= 0.0 { r.x } else { r.x + r.width };
|
||||||
let rx_max = if r.width >= 0.0 { r.x + r.width } else { r.x };
|
let rx_max = if r.width >= 0.0 { r.x + r.width } else { r.x };
|
||||||
rx_max > x_lo && rx_min < x_hi
|
let rw = rx_max - rx_min;
|
||||||
|
// Overlap region
|
||||||
|
let overlap = rx_max.min(x_hi) - rx_min.max(x_lo);
|
||||||
|
if overlap <= 0.0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Small rects (< 70% of band): require any overlap (cell borders, etc.)
|
||||||
|
// Large rects (≥ 70% of band): require ≥70% of rect inside band
|
||||||
|
if rw < band_width * 0.7 {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
overlap >= rw * 0.7
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
|
|||||||
Reference in New Issue
Block a user