Skip to content

Commit 03f23c8

Browse files
committed
feat: calculate page offsets to align toc margins
Normalize entry positions using baselines registered across pages instead of simple per-page minimum values. Prevent single far-right columns from creating spurious indent levels. Skip "Contents at a Glance" pages and replicated running headers.
1 parent 90a5157 commit 03f23c8

3 files changed

Lines changed: 209 additions & 30 deletions

File tree

AGENTS.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,43 @@ crates/papers-extract/scratch.txt
1919

2020
The `.temp/` directory is gitignored. Create it on demand if it doesn't exist.
2121

22+
## Verify PDF Layout by Rendering and Dumping — Do NOT Infer
23+
24+
When reasoning about a PDF's layout (indent levels, columns, depth, which
25+
glyphs/numbers a line has, why an entry is mis-parsed), **render the actual
26+
page to an image and look at it, and dump the page's chars.** Do NOT draw
27+
conclusions from x-coordinate dumps or text dumps alone, and never guess from
28+
the parser's output what the source "must" look like. A two-minute render
29+
settles questions that hours of coordinate-inference get wrong (e.g. "is this a
30+
sub-section or a sibling section?", "does this chapter have a number?", "is the
31+
gutter real?"). This is mandatory before concluding a fixture is wrong, a page
32+
is a certain layout, or a defect is "unfixable".
33+
34+
**Render a page to an image (then Read the image):**
35+
```bash
36+
# Windows (Ghostscript). Page numbers are 1-indexed.
37+
gswin64c -q -dNOPAUSE -dBATCH -dSAFER -sDEVICE=png16m -r140 \
38+
-dFirstPage=8 -dLastPage=8 -sOutputFile=.temp/render/page8.png data/<file>.pdf
39+
# (pdftoppm / mutool draw / magick are equivalents on other platforms)
40+
```
41+
42+
**Dump a page's chars (codepoint, bbox, font) — for exact x positions, broken
43+
glyphs, hanging number columns, sub/superscripts:**
44+
```bash
45+
cargo run --release --bin dump_chars -- data/<file>.pdf <page_num> # 1-indexed
46+
```
47+
48+
**Dump pdfium's reference text for the whole PDF (find a page, see word
49+
boundaries / wrap markers):**
50+
```bash
51+
cargo run --release --bin dump_text -- data/<file>.pdf .temp/<file>_text.txt
52+
```
53+
54+
Page-index gotcha: Ghostscript `-dFirstPage` and `dump_chars` are **1-indexed**;
55+
pypdf and our internal `page_idx` are **0-indexed**. An entry can also appear
56+
twice in `dump_text` (once in the TOC, once in the body) — confirm you are
57+
looking at the TOC page, not the body section of the same title.
58+
2259
## Extraction Pipeline Architecture
2360

2461
The PDF extraction pipeline has three stages. Fixes and logic should go

crates/papers-extract/src/toc.rs

Lines changed: 150 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,14 @@ fn find_toc_pages(page_chars: &[(Vec<PdfChar>, f32)]) -> Vec<u32> {
527527
if trimmed.starts_with("list of") || normalized.starts_with("listof") {
528528
continue;
529529
}
530+
// "Contents at a Glance" is a brief summary TOC that duplicates the
531+
// chapter/appendix headings of the detailed "Table of Contents".
532+
// Skip it as a start page so the detailed TOC is used instead; the
533+
// glance page then falls outside the ascending main-TOC run and is
534+
// not extracted, avoiding duplicated (and phantom wrapped) headings.
535+
if normalized.contains("ataglance") {
536+
continue;
537+
}
530538
if normalized == "contents"
531539
|| normalized == "tableofcontents"
532540
|| normalized == "detailedcontents"
@@ -780,6 +788,20 @@ fn line_ends_with_number(line: &TocRawLine) -> bool {
780788

781789
// ── Phase 2: Line extraction ──
782790

791+
/// True for an appendix-letter heading fragment: a single uppercase letter
792+
/// followed by a space and a capitalized word ("B Message Crackers, …",
793+
/// "A The Build Environment"). Used to keep such a wrapped heading on an
794+
/// offset-page top band, where its page number sits on the continuation line so
795+
/// the usual number/leader-dot/heading-number signals are absent. A running
796+
/// header ("Table of Contents") fails because its second word is lowercase.
797+
fn is_appendix_letter_heading(text: &str) -> bool {
798+
let t = text.trim();
799+
let mut chars = t.chars();
800+
chars.next().is_some_and(|c| c.is_ascii_uppercase())
801+
&& chars.next() == Some(' ')
802+
&& chars.next().is_some_and(|c| c.is_ascii_uppercase())
803+
}
804+
783805
fn extract_toc_lines(page_chars: &[(Vec<PdfChar>, f32)], toc_pages: &[u32]) -> Vec<TocRawLine> {
784806
let mut all_lines = Vec::new();
785807

@@ -827,7 +849,8 @@ fn extract_toc_lines(page_chars: &[(Vec<PdfChar>, f32)], toc_pages: &[u32]) -> V
827849
}
828850
return line_ends_with_number(line)
829851
|| headings::has_leader_dots(&text)
830-
|| starts_with_heading_pattern(text.trim());
852+
|| starts_with_heading_pattern(text.trim())
853+
|| is_appendix_letter_heading(&text);
831854
}
832855
true
833856
});
@@ -882,6 +905,23 @@ fn extract_toc_lines(page_chars: &[(Vec<PdfChar>, f32)], toc_pages: &[u32]) -> V
882905
all_lines.extend(lines);
883906
}
884907

908+
// Drop standalone "Contents" / "Table of Contents" running headers that
909+
// REPEAT across pages. A repeated pure-"Contents" line (optionally followed
910+
// by a roman page number) sits at an arbitrary, often far-right x and would
911+
// otherwise create a spurious indent level and clear the depth-resolution
912+
// stack. Gated on ≥2 occurrences so a single real "Contents (p. v)"
913+
// front-matter entry (e.g. in `edo`) is preserved. The topmost-line filter
914+
// above already handles the once-per-page header in the common case.
915+
let is_pure_contents = |l: &TocRawLine| {
916+
let norm = normalize_toc_header_text(&l.text());
917+
["contents", "tableofcontents", "detailedcontents"]
918+
.iter()
919+
.any(|h| norm.starts_with(h) && norm[h.len()..].chars().all(|c| "ivxlcdm".contains(c)))
920+
};
921+
if all_lines.iter().filter(|l| is_pure_contents(l)).count() >= 2 {
922+
all_lines.retain(|l| !is_pure_contents(l));
923+
}
924+
885925
all_lines
886926
}
887927

@@ -2551,24 +2591,19 @@ fn classify_and_learn(lines: Vec<TocSplitLine>) -> Vec<ClassifiedEntry> {
25512591
.iter()
25522592
.map(|(l, _, _)| l.x_left)
25532593
.collect();
2554-
// Normalize x_left per page: subtract each page's minimum x_left so that
2555-
// indent positions are comparable across pages with different margins.
2556-
let mut page_min_x: HashMap<u32, f32> = HashMap::new();
2557-
for (line, _, _) in &entries {
2558-
let min = page_min_x.entry(line.page_idx).or_insert(f32::MAX);
2559-
if line.x_left < *min {
2560-
*min = line.x_left;
2561-
}
2562-
}
2594+
// Normalize x_left per page by a registered baseline so indent positions are
2595+
// comparable across pages with different margins AND continuation pages that
2596+
// lack a chapter heading (see `compute_page_offsets`). Indent levels are
2597+
// count-aware so a stray far-right entry cannot create a spurious deep level.
2598+
let page_min_x: HashMap<u32, f32> = compute_page_offsets(&entries);
25632599
let x_lefts_normalized: Vec<f32> = entries
25642600
.iter()
25652601
.map(|(l, _, _)| {
25662602
let page_min = page_min_x.get(&l.page_idx).copied().unwrap_or(0.0);
25672603
l.x_left - page_min
25682604
})
25692605
.collect();
2570-
let indent_levels = compute_indent_levels(&x_lefts_normalized);
2571-
2606+
let indent_levels = compute_indent_levels_counted(&x_lefts_normalized, 3);
25722607

25732608
// Build a mapping from indent level → most common depth among classified
25742609
// entries at that indent. This lets us anchor indent levels to known depths.
@@ -2841,6 +2876,109 @@ fn compute_indent_levels(x_lefts: &[f32]) -> Vec<f32> {
28412876
levels
28422877
}
28432878

2879+
/// Like `compute_indent_levels` but drops levels backed by fewer than
2880+
/// `min_count` entries. A single far-right entry (a page-number-column artifact
2881+
/// or a two-column right-edge fragment) would otherwise create a spurious deep
2882+
/// indent level that corrupts depth inference. Never returns empty if the
2883+
/// unfiltered set was non-empty.
2884+
fn compute_indent_levels_counted(x_lefts: &[f32], min_count: usize) -> Vec<f32> {
2885+
let levels = compute_indent_levels(x_lefts);
2886+
if levels.len() <= 1 {
2887+
return levels;
2888+
}
2889+
let mut counts = vec![0usize; levels.len()];
2890+
for &x in x_lefts {
2891+
counts[quantize_indent(x, &levels) as usize] += 1;
2892+
}
2893+
let mut filtered: Vec<f32> = Vec::new();
2894+
for (i, &l) in levels.iter().enumerate() {
2895+
if counts[i] >= min_count {
2896+
filtered.push(l);
2897+
}
2898+
}
2899+
if filtered.is_empty() {
2900+
levels
2901+
} else {
2902+
filtered
2903+
}
2904+
}
2905+
2906+
/// Per-page x baseline that registers continuation pages to a canonical indent
2907+
/// grid (offset-invariant depth). A naïve per-page *minimum* fails on a
2908+
/// continuation page that carries only a chapter's sections (no chapter
2909+
/// heading): its leftmost entry is at the *section* level, so subtracting the
2910+
/// page min collapses every entry up a depth. Books also shift recto/verso pages
2911+
/// by a binding margin (Windows Internals offsets odd pages +30pt). So:
2912+
/// - Pages with a Chapter/Part heading anchor on that heading's x (the base
2913+
/// indent, which already encodes the page's physical margin).
2914+
/// - Continuation pages are aligned by the offset that best maps their indent
2915+
/// levels onto the canonical grid (inter-level spacings are stable; only the
2916+
/// absolute offset differs). Pages that do not align cleanly fall back to
2917+
/// their own minimum.
2918+
fn compute_page_offsets(entries: &[(TocSplitLine, Classification, u32)]) -> HashMap<u32, f32> {
2919+
let mut page_min: HashMap<u32, f32> = HashMap::new();
2920+
let mut pages: Vec<u32> = Vec::new();
2921+
for (l, _, _) in entries {
2922+
if !page_min.contains_key(&l.page_idx) {
2923+
pages.push(l.page_idx);
2924+
}
2925+
let m = page_min.entry(l.page_idx).or_insert(f32::MAX);
2926+
*m = m.min(l.x_left);
2927+
}
2928+
// Anchor each page on its CHAPTER level only. Parts/appendix dividers sit
2929+
// further left than chapters (one indent above), so anchoring a page on its
2930+
// part would shift that page's baseline relative to chapter-anchored pages
2931+
// and smear the indent levels. Pages without a chapter (part-only divider
2932+
// pages, appendix continuations, mid-chapter continuations) are instead
2933+
// REGISTERED below by aligning their section/subsection levels to the canon.
2934+
let mut page_offset: HashMap<u32, f32> = HashMap::new();
2935+
for (l, class, _) in entries {
2936+
if *class == Classification::Chapter {
2937+
let e = page_offset.entry(l.page_idx).or_insert(f32::MAX);
2938+
*e = e.min(l.x_left);
2939+
}
2940+
}
2941+
if page_offset.is_empty() {
2942+
return page_min; // no chapter anchor — keep per-page minimum
2943+
}
2944+
let canon_xs: Vec<f32> = entries
2945+
.iter()
2946+
.filter(|(l, _, _)| page_offset.contains_key(&l.page_idx))
2947+
.map(|(l, _, _)| l.x_left - page_offset[&l.page_idx])
2948+
.collect();
2949+
let canon = compute_indent_levels_counted(&canon_xs, 3);
2950+
for &p in &pages {
2951+
if page_offset.contains_key(&p) {
2952+
continue;
2953+
}
2954+
let fallback = page_min.get(&p).copied().unwrap_or(0.0);
2955+
let page_xs: Vec<f32> = entries
2956+
.iter()
2957+
.filter(|(l, _, _)| l.page_idx == p)
2958+
.map(|(l, _, _)| l.x_left)
2959+
.collect();
2960+
let page_levels = compute_indent_levels_counted(&page_xs, 2);
2961+
let mut best_delta = fallback;
2962+
let mut best_err = f32::MAX;
2963+
for &pl in &page_levels {
2964+
for &cl in &canon {
2965+
let delta = pl - cl;
2966+
let err: f32 = page_levels
2967+
.iter()
2968+
.map(|&x| canon.iter().map(|&c| (x - delta - c).abs()).fold(f32::MAX, f32::min))
2969+
.sum();
2970+
if err < best_err {
2971+
best_err = err;
2972+
best_delta = delta;
2973+
}
2974+
}
2975+
}
2976+
let avg_err = best_err / page_levels.len().max(1) as f32;
2977+
page_offset.insert(p, if avg_err <= 6.0 { best_delta } else { fallback });
2978+
}
2979+
page_offset
2980+
}
2981+
28442982
/// Quantize an x-position to an indent level (0-based).
28452983
fn quantize_indent(x: f32, levels: &[f32]) -> u32 {
28462984
levels

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

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,22 +35,22 @@
3535
- 3.3.3 Duplicating Object Handles (p. 60)
3636
- 4 Processes (p. 67)
3737
- 4.1 Writing Your First Windows Application (p. 68)
38-
- 4.2 A Process Instance Handle (p. 73)
39-
- 4.3 The CreateProcess Function (p. 89)
40-
- 4.3.1 pszApplicationName and pszCommandLine (p. 89)
41-
- 4.4 Terminating a Process (p. 104)
42-
- 4.4.1 The Primary Thread's Entry-Point Function Returns (p. 104)
43-
- 4.4.2 The ExitProcess Function (p. 105)
44-
- 4.4.3 The TerminateProcess Function (p. 106)
45-
- 4.4.4 When All the Threads in the Process Die (p. 107)
46-
- 4.4.5 When a Process Terminates (p. 107)
47-
- 4.5 Child Processes (p. 108)
48-
- 4.5.1 Running Detached Child Processes (p. 110)
49-
- 4.6 When Administrator Runs as a Standard User (p. 110)
50-
- 4.6.1 Elevating a Process Automatically (p. 113)
51-
- 4.6.2 Elevating a Process by Hand (p. 115)
52-
- 4.6.3 What Is the Current Privileges Context? (p. 117)
53-
- 4.6.4 Enumerating the Processes Running in the System (p. 118)
38+
- 4.1.1 A Process Instance Handle (p. 73)
39+
- 4.2 The CreateProcess Function (p. 89)
40+
- 4.2.1 pszApplicationName and pszCommandLine (p. 89)
41+
- 4.3 Terminating a Process (p. 104)
42+
- 4.3.1 The Primary Thread's Entry-Point Function Returns (p. 104)
43+
- 4.3.2 The ExitProcess Function (p. 105)
44+
- 4.3.3 The TerminateProcess Function (p. 106)
45+
- 4.3.4 When All the Threads in the Process Die (p. 107)
46+
- 4.3.5 When a Process Terminates (p. 107)
47+
- 4.4 Child Processes (p. 108)
48+
- 4.4.1 Running Detached Child Processes (p. 110)
49+
- 4.5 When Administrator Runs as a Standard User (p. 110)
50+
- 4.5.1 Elevating a Process Automatically (p. 113)
51+
- 4.5.2 Elevating a Process by Hand (p. 115)
52+
- 4.5.3 What Is the Current Privileges Context? (p. 117)
53+
- 4.5.4 Enumerating the Processes Running in the System (p. 118)
5454
- 5 Jobs (p. 125)
5555
- 5.1 Placing Restrictions on a Job's Processes (p. 129)
5656
- 5.2 Placing a Process in a Job (p. 136)
@@ -257,8 +257,11 @@
257257
- 20.1.3 Explicitly Linking to an Exported Symbol (p. 561)
258258
- 20.2 The DLL's Entry-Point Function (p. 562)
259259
- 20.2.1 The DLL_PROCESS_ATTACH Notification (p. 563)
260-
- 20.2.2 Serialized Calls to DllMain (p. 567)
261-
- 20.2.3 DllMain and the C/C++ Run-Time Library (p. 570)
260+
- 20.2.2 The DLL_PROCESS_DETACH Notification (p. 564)
261+
- 20.2.3 The DLL_THREAD_ATTACH Notification (p. 566)
262+
- 20.2.4 The DLL_THREAD_DETACH Notification (p. 567)
263+
- 20.2.5 Serialized Calls to DllMain (p. 567)
264+
- 20.2.6 DllMain and the C/C++ Run-Time Library (p. 570)
262265
- 20.3 Delay-Loading a DLL (p. 571)
263266
- 20.3.1 The DelayLoadApp Sample Application (p. 576)
264267
- 20.4 Function Forwarders (p. 583)
@@ -366,6 +369,7 @@
366369
- Forcing the Linker to Look for a (w)WinMain Entry-Point Function (p. 766)
367370
- Support XP-Theming of the User Interface with pragma (p. 766)
368371
- B Message Crackers, Child Control Macros, and API Macros (p. 773)
372+
- Message Crackers (p. 773)
369373
- Child Control Macros (p. 776)
370374
- API Macros (p. 776)
371375
- Index (p. 779)

0 commit comments

Comments
 (0)