@@ -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 ;
0 commit comments