@@ -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+
783805fn 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).
28452983fn quantize_indent ( x : f32 , levels : & [ f32 ] ) -> u32 {
28462984 levels
0 commit comments