Skip to content

Commit 4b52384

Browse files
committed
feat: add two-column gutter detection and line splitting
1 parent 4e8bfdb commit 4b52384

1 file changed

Lines changed: 186 additions & 0 deletions

File tree

  • crates/papers-extract/src

crates/papers-extract/src/toc.rs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,6 +1115,184 @@ fn build_lines_from_chars(
11151115
/// Split each line at the gutter, producing separate left/right half-lines,
11161116
/// then reorder: all left-column lines (top to bottom), then all right-column
11171117
/// lines (top to bottom).
1118+
/// Detect a two-column gutter via the line-coverage projection profile.
1119+
///
1120+
/// For each X bin, count how many lines have a character *spanning* it
1121+
/// (Nagy's projection profile). A real column gutter is a near-empty vertical
1122+
/// band (few lines cross it — only full-width headers) flanked by two WIDE dense
1123+
/// columns (Breuel's column-separator evaluation). The "wide column on both
1124+
/// sides" requirement rejects single-column TOCs (whose only interior low-density
1125+
/// band is the narrow strip before the right-aligned page numbers); the
1126+
/// "balanced / nearest the centre" tie-break picks the true gutter over a
1127+
/// column's own internal title→page-number gap. Returns the gutter X if confident.
1128+
fn detect_two_column_gutter(lines: &[Vec<TocChar>], page_left: f32, page_right: f32) -> Option<f32> {
1129+
let n = lines.len();
1130+
let span = page_right - page_left;
1131+
if n < 8 || span < 150.0 {
1132+
return None;
1133+
}
1134+
let bw = 3.0f32;
1135+
let nb = ((span / bw).ceil() as usize).max(10);
1136+
let mut cov = vec![0u32; nb];
1137+
for line in lines {
1138+
let mut hit = vec![false; nb];
1139+
for c in line {
1140+
let lo = (((c.bbox[0] - page_left) / bw).floor().max(0.0) as usize).min(nb - 1);
1141+
let hi = (((c.bbox[2] - page_left) / bw).floor().max(0.0) as usize).min(nb - 1);
1142+
for h in hit.iter_mut().take(hi + 1).skip(lo) {
1143+
*h = true;
1144+
}
1145+
}
1146+
for (b, &h) in hit.iter().enumerate() {
1147+
if h {
1148+
cov[b] += 1;
1149+
}
1150+
}
1151+
}
1152+
let near_zero = (n as f32 * 0.12).max(1.0);
1153+
let dense = n as f32 * 0.4;
1154+
let center = nb as f32 / 2.0;
1155+
let (lo, hi) = (nb / 5, nb * 4 / 5);
1156+
// (gutter_x, two_column_row_count, distance_to_centre). We pick the valley
1157+
// that best PARTITIONS the page (Breuel): the true gutter is the one with
1158+
// the most rows carrying real text on *both* sides (left entry + right
1159+
// entry). A spurious within-column valley separates far fewer such rows.
1160+
let mut best: Option<(f32, usize, f32)> = None;
1161+
let mut b = lo;
1162+
while b < hi {
1163+
if (cov[b] as f32) > near_zero {
1164+
b += 1;
1165+
continue;
1166+
}
1167+
let run_start = b;
1168+
while b < hi && (cov[b] as f32) <= near_zero {
1169+
b += 1;
1170+
}
1171+
let mid = (run_start + b) as f32 / 2.0;
1172+
let gutter_x = page_left + mid * bw;
1173+
// Dense-column extents on each side of this near-empty band.
1174+
let left: Vec<usize> = (0..run_start).filter(|&k| cov[k] as f32 >= dense).collect();
1175+
let right: Vec<usize> = (b..nb).filter(|&k| cov[k] as f32 >= dense).collect();
1176+
if let (Some(&l0), Some(&l1), Some(&r0), Some(&r1)) =
1177+
(left.first(), left.last(), right.first(), right.last())
1178+
{
1179+
let left_w = (l1 - l0) as f32 * bw;
1180+
let right_w = (r1 - r0) as f32 * bw;
1181+
// Count rows with real words (≥3 letters) on each side. The right
1182+
// count is also the gate: a single-column TOC's only interior valley
1183+
// sits before the right-aligned page numbers / after the dot
1184+
// leaders, where the right half is just digits and '.' — almost no
1185+
// letters. The both-sides count drives selection.
1186+
let alpha = |line: &Vec<TocChar>, want_right: bool| -> usize {
1187+
line.iter()
1188+
.filter(|c| {
1189+
let on_right = (c.bbox[0] + c.bbox[2]) / 2.0 > gutter_x;
1190+
on_right == want_right && c.codepoint.is_alphabetic()
1191+
})
1192+
.count()
1193+
};
1194+
let alpha_right_rows = lines.iter().filter(|l| alpha(l, true) >= 3).count();
1195+
let both_sides_rows = lines
1196+
.iter()
1197+
.filter(|l| alpha(l, true) >= 3 && alpha(l, false) >= 3)
1198+
.count();
1199+
if left_w >= span * 0.2
1200+
&& right_w >= span * 0.2
1201+
&& alpha_right_rows >= 5
1202+
&& (alpha_right_rows as f32) >= n as f32 * 0.25
1203+
{
1204+
let dist = (mid - center).abs();
1205+
let better = match best {
1206+
None => true,
1207+
Some((_, bc, bd)) => both_sides_rows > bc || (both_sides_rows == bc && dist < bd),
1208+
};
1209+
if better {
1210+
best = Some((gutter_x, both_sides_rows, dist));
1211+
}
1212+
}
1213+
}
1214+
}
1215+
best.map(|(gx, _, _)| gx)
1216+
}
1217+
1218+
/// Split each line into a left- and right-column half at the column gutter, then
1219+
/// emit every left half (top to bottom) followed by every right half.
1220+
///
1221+
/// Rather than slicing at the fixed `gutter_x`, each row is cut at its own
1222+
/// largest internal whitespace gap that lies *near* the gutter. This is robust
1223+
/// to rows whose right entry starts left of the nominal gutter — e.g. an
1224+
/// unindented right-column chapter heading ("6 Differential Analysis") begins
1225+
/// further left than the indented subsection rows that set the gutter. Slicing
1226+
/// at the gap keeps "6 Differential Analysis" whole on the right instead of
1227+
/// leaving its "6" on the left (where it would be misread as a page number).
1228+
///
1229+
/// A row whose text flows continuously across the gutter (no real gap near it,
1230+
/// e.g. a centred "CONTENTS" header) is kept whole.
1231+
fn split_lines_at_gutter(lines: Vec<Vec<TocChar>>, gutter_x: f32) -> Vec<Vec<TocChar>> {
1232+
const WINDOW: f32 = 55.0; // how far from the gutter a cut gap may sit
1233+
const MIN_GAP: f32 = 6.0; // a real column gap, not inter-word spacing
1234+
let has_alpha = |chars: &[TocChar]| chars.iter().any(|c| c.codepoint.is_alphabetic());
1235+
let mut left: Vec<(f32, Vec<TocChar>)> = Vec::new();
1236+
let mut right: Vec<(f32, Vec<TocChar>)> = Vec::new();
1237+
for line in lines {
1238+
if line.is_empty() {
1239+
continue;
1240+
}
1241+
let y = line.iter().map(|c| (c.bbox[1] + c.bbox[3]) / 2.0).sum::<f32>() / line.len() as f32;
1242+
// Chars are pre-sorted by left edge. Find the widest gap near the gutter
1243+
// whose *right side still contains letters* — i.e. a cut that separates
1244+
// two real entries. This rejects the title→page-number gap of a
1245+
// left-only row (whose right side would be digits only) so the row stays
1246+
// whole, while still pulling an unindented right-column heading whole to
1247+
// the right even when its number sits left of the nominal gutter.
1248+
// Score = (left side ends in a digit, gap width). Preferring a cut whose
1249+
// left char is a digit keeps a left entry's trailing page number with
1250+
// that entry — cutting before it would strand the page, make the entry
1251+
// look like an open heading, and let it absorb the next row.
1252+
let mut best_score = (false, 0.0f32);
1253+
let mut split_at: Option<usize> = None;
1254+
for i in 1..line.len() {
1255+
let gap = line[i].bbox[0] - line[i - 1].bbox[2];
1256+
let mid = (line[i - 1].bbox[2] + line[i].bbox[0]) / 2.0;
1257+
if gap >= MIN_GAP && (mid - gutter_x).abs() <= WINDOW && has_alpha(&line[i..]) {
1258+
let score = (line[i - 1].codepoint.is_numeric(), gap);
1259+
if score > best_score {
1260+
best_score = score;
1261+
split_at = Some(i);
1262+
}
1263+
}
1264+
}
1265+
match split_at {
1266+
Some(i) => {
1267+
let mut chars = line;
1268+
let r = chars.split_off(i);
1269+
if !chars.is_empty() {
1270+
left.push((y, chars));
1271+
}
1272+
if !r.is_empty() {
1273+
right.push((y, r));
1274+
}
1275+
}
1276+
None => {
1277+
// No qualifying column gap: a row confined to one column or a
1278+
// full-width header. If every char sits right of the gutter it
1279+
// belongs to the right column; otherwise keep it left.
1280+
if line.iter().all(|c| (c.bbox[0] + c.bbox[2]) / 2.0 >= gutter_x) {
1281+
right.push((y, line));
1282+
} else {
1283+
left.push((y, line));
1284+
}
1285+
}
1286+
}
1287+
}
1288+
left.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1289+
right.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1290+
let mut result = Vec::with_capacity(left.len() + right.len());
1291+
result.extend(left.into_iter().map(|(_, c)| c));
1292+
result.extend(right.into_iter().map(|(_, c)| c));
1293+
result
1294+
}
1295+
11181296
fn split_two_column_lines(lines: Vec<Vec<TocChar>>, _page_height: f32) -> Vec<Vec<TocChar>> {
11191297
if lines.len() < 4 {
11201298
return lines;
@@ -1136,6 +1314,14 @@ fn split_two_column_lines(lines: Vec<Vec<TocChar>>, _page_height: f32) -> Vec<Ve
11361314
return lines; // page too narrow for two columns
11371315
}
11381316

1317+
// Primary: projection-profile gutter detection (Nagy XY-cut / Breuel
1318+
// whitespace-column style). Finds a near-empty vertical band flanked by two
1319+
// wide dense columns — robust where the per-line largest-gap heuristic fails
1320+
// (the title→page-number gaps dominate each line's biggest gap).
1321+
if let Some(gutter_x) = detect_two_column_gutter(&lines, page_left, page_right) {
1322+
return split_lines_at_gutter(lines, gutter_x);
1323+
}
1324+
11391325
// Middle 80% of the content area — gutter should be here.
11401326
// Use 10%-90% rather than 20%-80% to handle asymmetric column layouts.
11411327
let gutter_zone_left = page_left + page_span * 0.1;

0 commit comments

Comments
 (0)