Skip to content

Commit 77147b4

Browse files
committed
fix: improve column spanning detection in text extraction
Check for continuous text flow across gaps instead of any char overlap. This prevents edge characters from falsely triggering spanning lines.
1 parent 0be61fd commit 77147b4

1 file changed

Lines changed: 88 additions & 11 deletions

File tree

crates/papers-extract/src/text_only.rs

Lines changed: 88 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -309,19 +309,32 @@ fn split_chars_into_columns(
309309
}
310310

311311
// For each gap, check if this Y-band is spanning.
312-
// A Y-band is spanning if it has chars whose center_x falls
313-
// INSIDE the gap region. Separate column lines have no chars
314-
// in the gap; spanning lines (titles, captions) have chars
315-
// flowing through it.
312+
// A Y-band is spanning if text flows continuously from one column
313+
// through the gap into the other. We verify:
314+
// 1. Chars exist on both sides of the gap
315+
// 2. No large horizontal break between consecutive chars (sorted by X)
316+
// This distinguishes two separate column lines (~20-30pt break at the
317+
// column boundary) from a spanning title (~3-6pt char spacing throughout).
316318
let mut is_spanning = false;
319+
let band_cxs: Vec<f32> = band_chars
320+
.iter()
321+
.map(|&i| (chars[i].bbox[0] + chars[i].bbox[2]) / 2.0)
322+
.collect();
317323
for gap in gaps {
318-
let has_chars_in_gap = band_chars.iter().any(|&i| {
319-
let cx = (chars[i].bbox[0] + chars[i].bbox[2]) / 2.0;
320-
cx >= gap.start && cx <= gap.end
321-
});
322-
if has_chars_in_gap {
323-
is_spanning = true;
324-
break;
324+
let has_left = band_cxs.iter().any(|&cx| cx < gap.start);
325+
let has_right = band_cxs.iter().any(|&cx| cx > gap.end);
326+
if has_left && has_right {
327+
let mut sorted_cxs = band_cxs.clone();
328+
sorted_cxs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
329+
let max_char_gap = sorted_cxs
330+
.windows(2)
331+
.map(|w| w[1] - w[0])
332+
.fold(0.0f32, f32::max);
333+
let gap_width = gap.end - gap.start;
334+
if max_char_gap < gap_width * 0.5 {
335+
is_spanning = true;
336+
break;
337+
}
325338
}
326339
}
327340

@@ -2242,6 +2255,70 @@ mod tests {
22422255
assert!(!spanning.is_empty(), "Title at y=100 should be spanning (chars every 10pt through gap)");
22432256
}
22442257

2258+
#[test]
2259+
fn test_split_edge_char_not_spanning() {
2260+
// Regression: right-column char at the gap boundary should NOT trigger
2261+
// spanning. The gap [295, 319] overshoots into the right column, and
2262+
// a right-column char `(` at bbox [317.7, 287, 320.3, 299] (cx=319.0)
2263+
// would previously trigger false spanning for the entire Y-band.
2264+
let gap = ColumnGap { midpoint: 307.0, start: 295.0, end: 319.0 };
2265+
2266+
let mut chars = Vec::new();
2267+
// Left column chars at y=290: x from 50 to 280 (every 10pt, width 6)
2268+
for i in 0..24 {
2269+
chars.push(make_char(50.0 + i as f32 * 10.0, 290.0, 6.0, 10.0));
2270+
}
2271+
// Right column chars at y=290: first char near gap boundary, then regular
2272+
chars.push(ImgChar {
2273+
bbox: [317.7, 287.0, 320.3, 299.4], // cx=319.0, exactly at gap.end
2274+
font_size: 10.0,
2275+
is_bold: false,
2276+
codepoint: '(',
2277+
});
2278+
for i in 1..24 {
2279+
chars.push(make_char(320.0 + i as f32 * 10.0, 290.0, 6.0, 10.0));
2280+
}
2281+
2282+
let (spanning, columns) = split_chars_into_columns(&chars, &[gap]);
2283+
assert!(
2284+
spanning.is_empty(),
2285+
"Edge char at cx=319 should NOT trigger spanning; got {} spanning chars",
2286+
spanning.len()
2287+
);
2288+
assert!(!columns[0].is_empty(), "Left column should have chars");
2289+
assert!(!columns[1].is_empty(), "Right column should have chars");
2290+
}
2291+
2292+
#[test]
2293+
fn test_split_true_spanning_still_detected() {
2294+
// A full-width title with chars flowing continuously through the gap
2295+
// should still be detected as spanning.
2296+
let gap = ColumnGap { midpoint: 307.0, start: 295.0, end: 319.0 };
2297+
2298+
let mut chars = Vec::new();
2299+
// Spanning title at y=100: chars every 8pt from x=50 to x=530
2300+
// Max char gap = 8pt - 6pt(width) = 2pt between chars. gap_width*0.5 = 12.
2301+
// 2 < 12 → spanning.
2302+
for i in 0..60 {
2303+
chars.push(make_char(50.0 + i as f32 * 8.0, 100.0, 6.0, 10.0));
2304+
}
2305+
// Column lines at y=200: separate columns with large gap
2306+
for i in 0..24 {
2307+
chars.push(make_char(50.0 + i as f32 * 10.0, 200.0, 6.0, 10.0));
2308+
}
2309+
for i in 0..24 {
2310+
chars.push(make_char(330.0 + i as f32 * 10.0, 200.0, 6.0, 10.0));
2311+
}
2312+
2313+
let (spanning, columns) = split_chars_into_columns(&chars, &[gap]);
2314+
assert!(
2315+
!spanning.is_empty(),
2316+
"Full-width title should be detected as spanning"
2317+
);
2318+
assert!(!columns[0].is_empty(), "Left column at y=200 should have chars");
2319+
assert!(!columns[1].is_empty(), "Right column at y=200 should have chars");
2320+
}
2321+
22452322
// ── Running header normalization ──
22462323

22472324
#[test]

0 commit comments

Comments
 (0)