@@ -959,7 +959,8 @@ fn build_lines_from_chars(
959959 // Expand TeX ligatures (0x0B-0x0F) to their component characters.
960960 let mut toc_chars: Vec < TocChar > = Vec :: new ( ) ;
961961 let mut last_was_space = false ;
962- for c in chars {
962+ for idx in 0 ..chars. len ( ) {
963+ let c = & chars[ idx] ;
963964 if c. codepoint == ' ' {
964965 last_was_space = true ;
965966 continue ;
@@ -994,11 +995,36 @@ fn build_lines_from_chars(
994995 continue ;
995996 }
996997 }
997- if c. codepoint . is_control ( ) {
998+ // Recover a wrap hyphen mis-encoded as a control / format code point.
999+ // This corpus has fonts whose broken ToUnicode maps the line-break
1000+ // hyphen glyph to U+0002 (or U+00AD); it still draws as a real hyphen
1001+ // (non-zero width). Treat it as '-' ONLY when it sits at a line break —
1002+ // the next glyph is on a different line — so a wrapped word rejoins
1003+ // ("Ital-" + "iano" -> "Italiano"). A mid-line U+0002 is a stray marker
1004+ // (e.g. "S , I , R" in a formula) and is dropped as the control char.
1005+ let codepoint = if matches ! ( c. codepoint, '\u{2}' | '\u{ad}' )
1006+ && ( c. bbox [ 2 ] - c. bbox [ 0 ] ) > 0.5
1007+ {
1008+ let cy = ( c. bbox [ 1 ] + c. bbox [ 3 ] ) / 2.0 ;
1009+ let line_h = ( c. bbox [ 3 ] - c. bbox [ 1 ] ) . abs ( ) . max ( 1.0 ) ;
1010+ let next_on_new_line = chars[ idx + 1 ..]
1011+ . iter ( )
1012+ . find ( |n| n. codepoint != ' ' )
1013+ . map ( |n| ( ( n. bbox [ 1 ] + n. bbox [ 3 ] ) / 2.0 - cy) . abs ( ) > line_h * 0.5 )
1014+ . unwrap_or ( false ) ;
1015+ if next_on_new_line {
1016+ '-'
1017+ } else {
1018+ c. codepoint
1019+ }
1020+ } else {
1021+ c. codepoint
1022+ } ;
1023+ if codepoint. is_control ( ) {
9981024 continue ;
9991025 }
10001026 toc_chars. push ( TocChar {
1001- codepoint : c . codepoint ,
1027+ codepoint,
10021028 bbox : [ c. bbox [ 0 ] , y1, c. bbox [ 2 ] , y2] ,
10031029 origin_x : c. origin_x ,
10041030 pdfium_space_before : has_space_before,
@@ -1149,7 +1175,13 @@ fn detect_two_column_gutter(lines: &[Vec<TocChar>], page_left: f32, page_right:
11491175 }
11501176 }
11511177 }
1152- let near_zero = ( n as f32 * 0.12 ) . max ( 1.0 ) ;
1178+ // A gutter is a deep dip *relative to the column peaks*, not necessarily
1179+ // near-empty: on dense pages wrapped left-titles overflow into the gutter
1180+ // band, so an absolute near-zero threshold misses it. Use 35% of the peak
1181+ // column coverage (with the old absolute 12%-of-rows value as a floor). The
1182+ // alpha-right / both-sides gates below still reject single-column pages.
1183+ let col_density = * cov. iter ( ) . max ( ) . unwrap_or ( & 1 ) as f32 ;
1184+ let near_zero = ( col_density * 0.35 ) . max ( n as f32 * 0.12 ) . max ( 1.0 ) ;
11531185 let dense = n as f32 * 0.4 ;
11541186 let center = nb as f32 / 2.0 ;
11551187 let ( lo, hi) = ( nb / 5 , nb * 4 / 5 ) ;
@@ -1192,14 +1224,40 @@ fn detect_two_column_gutter(lines: &[Vec<TocChar>], page_left: f32, page_right:
11921224 . count ( )
11931225 } ;
11941226 let alpha_right_rows = lines. iter ( ) . filter ( |l| alpha ( l, true ) >= 3 ) . count ( ) ;
1227+ // A genuine two-column row has real words on both sides AND a clear
1228+ // empty band straddling the gutter (the left entry ends, then a gap,
1229+ // then the right entry begins). A long *single-column* title that
1230+ // merely flows across the gutter has alpha on both sides but no gap,
1231+ // so it must NOT count — that false signal is what made the relaxed
1232+ // valley threshold fire on single-column pages (e.g. opt).
11951233 let both_sides_rows = lines
11961234 . iter ( )
1197- . filter ( |l| alpha ( l, true ) >= 3 && alpha ( l, false ) >= 3 )
1235+ . filter ( |l| {
1236+ if alpha ( l, true ) < 3 || alpha ( l, false ) < 3 {
1237+ return false ;
1238+ }
1239+ let left_end = l
1240+ . iter ( )
1241+ . filter ( |c| ( c. bbox [ 0 ] + c. bbox [ 2 ] ) / 2.0 < gutter_x)
1242+ . map ( |c| c. bbox [ 2 ] )
1243+ . fold ( f32:: MIN , f32:: max) ;
1244+ let right_start = l
1245+ . iter ( )
1246+ . filter ( |c| ( c. bbox [ 0 ] + c. bbox [ 2 ] ) / 2.0 >= gutter_x)
1247+ . map ( |c| c. bbox [ 0 ] )
1248+ . fold ( f32:: MAX , f32:: min) ;
1249+ right_start - left_end >= 6.0
1250+ } )
11981251 . count ( ) ;
11991252 if left_w >= span * 0.2
12001253 && right_w >= span * 0.2
12011254 && alpha_right_rows >= 5
12021255 && ( alpha_right_rows as f32 ) >= n as f32 * 0.25
1256+ // A genuine two-column TOC has many rows carrying real words on
1257+ // BOTH sides (a left entry AND a right entry). A single-column
1258+ // page with an incidental interior dip does not, so this guards
1259+ // the now-relaxed valley threshold against false splits.
1260+ && ( both_sides_rows as f32 ) >= n as f32 * 0.3
12031261 {
12041262 let dist = ( mid - center) . abs ( ) ;
12051263 let better = match best {
@@ -2150,15 +2208,19 @@ fn parse_lowercase_roman(s: &str) -> Option<u32> {
21502208/// "Some Title" + "Continued" → "Some Title Continued"
21512209fn join_title_parts ( left : & str , right : & str ) -> String {
21522210 if left. ends_with ( '-' ) {
2153- // Check if this is a real hyphenated compound word (both sides capitalized)
2154- // or a line-break split (second part is lowercase).
2155- let right_starts_lower = right. starts_with ( |c : char | c. is_lowercase ( ) ) ;
2156- if right_starts_lower {
2157- // Line-break hyphenation: remove hyphen and join directly
2158- format ! ( "{}{}" , & left[ ..left. len( ) - 1 ] , right)
2211+ // A wrapped title broke at a hyphen. Drop the wrap hyphen AND any space
2212+ // `TocRawLine::text()` inserted just before it (the right-margin advance
2213+ // gap reads as a word boundary), otherwise "Ital-" wrapped onto "iano"
2214+ // rejoins as "Ital iano" instead of "Italiano".
2215+ let base = left. trim_end_matches ( '-' ) . trim_end ( ) ;
2216+ // Lowercase continuation = mid-word line break: join directly
2217+ // ("Ital" + "iano" = "Italiano"). Uppercase continuation = a real
2218+ // compound hyphenated at the wrap: keep one hyphen, no spaces
2219+ // ("Chung" + "Kuan" = "Chung-Kuan").
2220+ if right. starts_with ( |c : char | c. is_lowercase ( ) ) {
2221+ format ! ( "{}{}" , base, right)
21592222 } else {
2160- // Compound word like "Self-Adjoint": keep the hyphen
2161- format ! ( "{} {}" , left, right)
2223+ format ! ( "{}-{}" , base, right)
21622224 }
21632225 } else if left. ends_with ( '—' ) || left. ends_with ( '–' ) {
21642226 // A title wrapped immediately after an em/en-dash ("First Law of
0 commit comments