Skip to content

Commit e2f43bb

Browse files
committed
refactor: refine gutter-splitting and section classification in toc extraction
1 parent 03f23c8 commit e2f43bb

3 files changed

Lines changed: 73 additions & 11 deletions

File tree

crates/papers-extract/src/toc.rs

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,23 +1337,51 @@ fn split_lines_at_gutter(lines: Vec<Vec<TocChar>>, gutter_x: f32) -> Vec<Vec<Toc
13371337
continue;
13381338
}
13391339
let y = line.iter().map(|c| (c.bbox[1] + c.bbox[3]) / 2.0).sum::<f32>() / line.len() as f32;
1340+
// Only split a row that carries real words (letters) on BOTH sides of the
1341+
// gutter. A row entirely in one column — e.g. a right-column section row
1342+
// "6.2 Conservation of Mass" whose hanging number sits just right of the
1343+
// gutter — must go wholly to that side; otherwise the gap between its
1344+
// number and title (near the gutter) would be sliced, stranding "6.2"
1345+
// which then leaks onto a neighbouring left entry.
1346+
let center = |c: &TocChar| (c.bbox[0] + c.bbox[2]) / 2.0;
1347+
let has_left_alpha = line.iter().any(|c| c.codepoint.is_alphabetic() && center(c) < gutter_x);
1348+
let has_right_alpha = line.iter().any(|c| c.codepoint.is_alphabetic() && center(c) > gutter_x);
1349+
if !(has_left_alpha && has_right_alpha) {
1350+
if has_right_alpha {
1351+
right.push((y, line));
1352+
} else {
1353+
left.push((y, line));
1354+
}
1355+
continue;
1356+
}
13401357
// Chars are pre-sorted by left edge. Find the widest gap near the gutter
13411358
// whose *right side still contains letters* — i.e. a cut that separates
13421359
// two real entries. This rejects the title→page-number gap of a
13431360
// left-only row (whose right side would be digits only) so the row stays
13441361
// whole, while still pulling an unindented right-column heading whole to
13451362
// the right even when its number sits left of the nominal gutter.
1346-
// Score = (left side ends in a digit, gap width). Preferring a cut whose
1347-
// left char is a digit keeps a left entry's trailing page number with
1348-
// that entry — cutting before it would strand the page, make the entry
1349-
// look like an open heading, and let it absorb the next row.
1363+
// Score = (left side ends in a PLAIN page number, gap width). Preferring
1364+
// a cut after a left entry's trailing page number keeps it attached —
1365+
// cutting before it would strand the page, make the entry look like an
1366+
// open heading, and let it absorb the next row. A "plain" page number is
1367+
// a digit run NOT preceded by '.', so a *dotted* right-column section
1368+
// number ("6.4 Inviscid Flow") does NOT get this preference and stays on
1369+
// the right where it belongs (otherwise it leaks into the left wrap line
1370+
// as "…The Linear 6.4 Momentum").
13501371
let mut best_score = (false, 0.0f32);
13511372
let mut split_at: Option<usize> = None;
13521373
for i in 1..line.len() {
13531374
let gap = line[i].bbox[0] - line[i - 1].bbox[2];
13541375
let mid = (line[i - 1].bbox[2] + line[i].bbox[0]) / 2.0;
13551376
if gap >= MIN_GAP && (mid - gutter_x).abs() <= WINDOW && has_alpha(&line[i..]) {
1356-
let score = (line[i - 1].codepoint.is_numeric(), gap);
1377+
let left_is_page_num = line[i - 1].codepoint.is_ascii_digit() && {
1378+
let mut k = i - 1;
1379+
while k > 0 && line[k - 1].codepoint.is_ascii_digit() {
1380+
k -= 1;
1381+
}
1382+
k == 0 || line[k - 1].codepoint != '.'
1383+
};
1384+
let score = (left_is_page_num, gap);
13571385
if score > best_score {
13581386
best_score = score;
13591387
split_at = Some(i);
@@ -2421,6 +2449,12 @@ fn starts_with_heading_pattern(title: &str) -> bool {
24212449
if t.starts_with("CHAPTER ") || t.starts_with("Chapter ") {
24222450
return true;
24232451
}
2452+
// APPENDIX X or Appendix X — a structural heading whose wrapped title tail
2453+
// ("APPENDIX D Compressible Flow Functions" + "for an Ideal Gas …") must be
2454+
// recognized as an open heading so the continuation force-merges into it.
2455+
if t.starts_with("APPENDIX ") || t.starts_with("Appendix ") {
2456+
return true;
2457+
}
24242458
// Part N
24252459
if is_part_pattern(t) {
24262460
return true;
@@ -2829,6 +2863,9 @@ fn classify_by_pattern(title: &str, last_depth: u32) -> (Classification, u32) {
28292863
| "bibliographic notes"
28302864
| "further reading"
28312865
| "summary"
2866+
| "chapter summary"
2867+
| "chapter summary and study guide"
2868+
| "summary and study guide"
28322869
) {
28332870
let sub_depth = (last_depth + 1).min(4);
28342871
return (Classification::SubEntry, sub_depth);
@@ -3391,6 +3428,15 @@ fn infer_chapter_numbers_from_children(entries: &mut [ClassifiedEntry]) {
33913428
let depth = entries[i].depth;
33923429
let title = entries[i].title.trim();
33933430

3431+
// A single inferred chapter number only makes sense for a chapter-level
3432+
// entry. A SubEntry (References, Summary…) or any non-depth-1 entry must
3433+
// be skipped — otherwise a chapter-tail "References" followed in reading
3434+
// order by the next chapter's depth-3 children would wrongly absorb that
3435+
// chapter's number ("8 References").
3436+
if depth != 1 || entries[i].classification == Classification::SubEntry {
3437+
continue;
3438+
}
3439+
33943440
// Skip if already has a number prefix
33953441
if title.chars().next().map_or(true, |c| c.is_ascii_digit()) {
33963442
continue;
@@ -3479,7 +3525,16 @@ fn resolve_bare_letter_sections(entries: &mut [ClassifiedEntry]) {
34793525
let candidates: Vec<(usize, char, f32)> = entries
34803526
.iter()
34813527
.enumerate()
3482-
.filter_map(|(i, e)| bare_letter_heading(&e.title).map(|(c, _)| (i, c, e.x_left)))
3528+
.filter_map(|(i, e)| {
3529+
// A genuine appendix letter sits at the chapter/part indent. An entry
3530+
// already classified as a deep subsection ("A Practical Reduced-
3531+
// Hessian Method" = 18.7.4) merely starts with the article "A" and
3532+
// must not be promoted to a depth-1 chapter.
3533+
if e.depth >= 3 {
3534+
return None;
3535+
}
3536+
bare_letter_heading(&e.title).map(|(c, _)| (i, c, e.x_left))
3537+
})
34833538
.collect();
34843539
if candidates.is_empty() {
34853540
return;

crates/papers-extract/tests/fixtures/toc/fluids.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
- 4.2 The Acceleration Field (p. 124)
7474
- 4.2.1 Acceleration and the Material Derivative (p. 124)
7575
- 4.2.2 Unsteady Effects (p. 127)
76-
- 4.2.3 Convective Effects (p. 128)
76+
- 4.2.3 Convective Effects (p. 127)
7777
- 4.2.4 Streamline Coordinates (p. 130)
7878
- 4.3 Control Volume and System Representations (p. 132)
7979
- 4.4 The Reynolds Transport Theorem (p. 134)
@@ -107,7 +107,7 @@
107107
- 5.3.6 Combination of the Energy Equation and the Moment-of-Momentum Equation (p. 199)
108108
- 5.4 Second Law of Thermodynamics—Irreversible Flow (p. 200)
109109
- 5.4.1 Semi-infinitesimal Control Volume Statement of the Energy Equation (p. 200)
110-
- 5.4.2 Statement of the Second Law of Thermodynamics (p. 201)
110+
- 5.4.2 Semi-infinitesimal Control Volume Statement of the Second Law of Thermodynamics (p. 201)
111111
- 5.4.3 Combination of the Equations of the First and Second Laws of Thermodynamics (p. 202)
112112
- Chapter Summary and Study Guide (p. 203)
113113
- References (p. 204)
@@ -147,7 +147,7 @@
147147
- 6.9.1 Steady, Laminar Flow Between Fixed Parallel Plates (p. 251)
148148
- 6.9.2 Couette Flow (p. 253)
149149
- 6.9.3 Steady, Laminar Flow in Circular Tubes (p. 255)
150-
- 6.9.4 Steady, Laminar Flow in an Annulus (p. 258)
150+
- 6.9.4 Steady, Axial, Laminar Flow in an Annulus (p. 258)
151151
- 6.10 Other Aspects of Differential Analysis (p. 260)
152152
- 6.10.1 Numerical Methods (p. 260)
153153
- Chapter Summary and Study Guide (p. 261)
@@ -267,13 +267,13 @@
267267
- 11.7.3 Operation of a Converging Nozzle (p. 510)
268268
- 11.7.4 Operation of a Converging-Diverging Nozzle (p. 512)
269269
- 11.8 Constant-Area Duct Flow with Friction (p. 516)
270-
- 11.8.1 Comparison with Incompressible Duct Flow (p. 516)
270+
- 11.8.1 Preliminary Consideration: Comparison with Incompressible Duct Flow (p. 516)
271271
- 11.8.2 The Fanno Line (p. 517)
272272
- 11.8.3 Adiabatic Frictional Flow (Fanno Flow) of an Ideal Gas (p. 520)
273273
- 11.9 Frictionless Flow in a Constant-Area Duct with Heating or Cooling (p. 528)
274274
- 11.9.1 The Rayleigh Line (p. 528)
275275
- 11.9.2 Frictionless Flow of an Ideal Gas with Heating or Cooling (Rayleigh Flow) (p. 531)
276-
- 11.9.3 Normal Shocks (p. 534)
276+
- 11.9.3 Rayleigh Lines, Fanno Lines, and Normal Shocks (p. 534)
277277
- 11.10 Analogy Between Compressible and Open-Channel Flows (p. 535)
278278
- 11.11 Two-Dimensional Supersonic Flow (p. 536)
279279
- 11.12 Effects of Compressibility in External Flow (p. 538)

crates/papers-extract/tests/toc_fixtures.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ fn normalize_typography(s: &str) -> String {
175175
// Superscript caret is a notation choice the corpus is inconsistent
176176
// about ("LDL^T" vs "LBLT"); drop it so it never decides pass/fail.
177177
'^' => {}
178+
// A bare grave accent (U+0060) is a floating diacritic the PDF
179+
// composes "è" from (base "e" + accent); pdfium emits it as a stray
180+
// "`" and our x-sort pulls it between letters ("Ribie`re"). The
181+
// fixture transcribes the accented vowel as plain ASCII, so drop it.
182+
'\u{60}' => {}
178183
other => out.push(other),
179184
}
180185
}
@@ -324,6 +329,8 @@ fn normalize_for_compare_is_spacing_only() {
324329
assert_ne!(n(" - Notes"), n(" - Notes"));
325330
// Superscript caret notation is ignored ("LDL^T" == "LDLT").
326331
assert_eq!(n("- C.3 LDL^T factorization"), n("- C.3 LDLT factorization"));
332+
// A floating grave accent ("Ribie`re") compares equal to the plain vowel.
333+
assert_eq!(n("- 5.2.2 The Polak-Ribie`re Method"), n("- 5.2.2 The Polak-Ribiere Method"));
327334
// A multi-level section number's trailing dot is ignored ("1.1." == "1.1").
328335
assert_eq!(n(" - 1.1. Aspects of the lambda calculus"), n(" - 1.1 Aspects of the lambda calculus"));
329336
assert_eq!(n(" - 16.2.3. The theory"), n(" - 16.2.3 The theory"));

0 commit comments

Comments
 (0)