@@ -1115,6 +1115,184 @@ fn build_lines_from_chars(
11151115/// Split each line at the gutter, producing separate left/right half-lines,
11161116/// then reorder: all left-column lines (top to bottom), then all right-column
11171117/// lines (top to bottom).
1118+ /// Detect a two-column gutter via the line-coverage projection profile.
1119+ ///
1120+ /// For each X bin, count how many lines have a character *spanning* it
1121+ /// (Nagy's projection profile). A real column gutter is a near-empty vertical
1122+ /// band (few lines cross it — only full-width headers) flanked by two WIDE dense
1123+ /// columns (Breuel's column-separator evaluation). The "wide column on both
1124+ /// sides" requirement rejects single-column TOCs (whose only interior low-density
1125+ /// band is the narrow strip before the right-aligned page numbers); the
1126+ /// "balanced / nearest the centre" tie-break picks the true gutter over a
1127+ /// column's own internal title→page-number gap. Returns the gutter X if confident.
1128+ fn detect_two_column_gutter ( lines : & [ Vec < TocChar > ] , page_left : f32 , page_right : f32 ) -> Option < f32 > {
1129+ let n = lines. len ( ) ;
1130+ let span = page_right - page_left;
1131+ if n < 8 || span < 150.0 {
1132+ return None ;
1133+ }
1134+ let bw = 3.0f32 ;
1135+ let nb = ( ( span / bw) . ceil ( ) as usize ) . max ( 10 ) ;
1136+ let mut cov = vec ! [ 0u32 ; nb] ;
1137+ for line in lines {
1138+ let mut hit = vec ! [ false ; nb] ;
1139+ for c in line {
1140+ let lo = ( ( ( c. bbox [ 0 ] - page_left) / bw) . floor ( ) . max ( 0.0 ) as usize ) . min ( nb - 1 ) ;
1141+ let hi = ( ( ( c. bbox [ 2 ] - page_left) / bw) . floor ( ) . max ( 0.0 ) as usize ) . min ( nb - 1 ) ;
1142+ for h in hit. iter_mut ( ) . take ( hi + 1 ) . skip ( lo) {
1143+ * h = true ;
1144+ }
1145+ }
1146+ for ( b, & h) in hit. iter ( ) . enumerate ( ) {
1147+ if h {
1148+ cov[ b] += 1 ;
1149+ }
1150+ }
1151+ }
1152+ let near_zero = ( n as f32 * 0.12 ) . max ( 1.0 ) ;
1153+ let dense = n as f32 * 0.4 ;
1154+ let center = nb as f32 / 2.0 ;
1155+ let ( lo, hi) = ( nb / 5 , nb * 4 / 5 ) ;
1156+ // (gutter_x, two_column_row_count, distance_to_centre). We pick the valley
1157+ // that best PARTITIONS the page (Breuel): the true gutter is the one with
1158+ // the most rows carrying real text on *both* sides (left entry + right
1159+ // entry). A spurious within-column valley separates far fewer such rows.
1160+ let mut best: Option < ( f32 , usize , f32 ) > = None ;
1161+ let mut b = lo;
1162+ while b < hi {
1163+ if ( cov[ b] as f32 ) > near_zero {
1164+ b += 1 ;
1165+ continue ;
1166+ }
1167+ let run_start = b;
1168+ while b < hi && ( cov[ b] as f32 ) <= near_zero {
1169+ b += 1 ;
1170+ }
1171+ let mid = ( run_start + b) as f32 / 2.0 ;
1172+ let gutter_x = page_left + mid * bw;
1173+ // Dense-column extents on each side of this near-empty band.
1174+ let left: Vec < usize > = ( 0 ..run_start) . filter ( |& k| cov[ k] as f32 >= dense) . collect ( ) ;
1175+ let right: Vec < usize > = ( b..nb) . filter ( |& k| cov[ k] as f32 >= dense) . collect ( ) ;
1176+ if let ( Some ( & l0) , Some ( & l1) , Some ( & r0) , Some ( & r1) ) =
1177+ ( left. first ( ) , left. last ( ) , right. first ( ) , right. last ( ) )
1178+ {
1179+ let left_w = ( l1 - l0) as f32 * bw;
1180+ let right_w = ( r1 - r0) as f32 * bw;
1181+ // Count rows with real words (≥3 letters) on each side. The right
1182+ // count is also the gate: a single-column TOC's only interior valley
1183+ // sits before the right-aligned page numbers / after the dot
1184+ // leaders, where the right half is just digits and '.' — almost no
1185+ // letters. The both-sides count drives selection.
1186+ let alpha = |line : & Vec < TocChar > , want_right : bool | -> usize {
1187+ line. iter ( )
1188+ . filter ( |c| {
1189+ let on_right = ( c. bbox [ 0 ] + c. bbox [ 2 ] ) / 2.0 > gutter_x;
1190+ on_right == want_right && c. codepoint . is_alphabetic ( )
1191+ } )
1192+ . count ( )
1193+ } ;
1194+ let alpha_right_rows = lines. iter ( ) . filter ( |l| alpha ( l, true ) >= 3 ) . count ( ) ;
1195+ let both_sides_rows = lines
1196+ . iter ( )
1197+ . filter ( |l| alpha ( l, true ) >= 3 && alpha ( l, false ) >= 3 )
1198+ . count ( ) ;
1199+ if left_w >= span * 0.2
1200+ && right_w >= span * 0.2
1201+ && alpha_right_rows >= 5
1202+ && ( alpha_right_rows as f32 ) >= n as f32 * 0.25
1203+ {
1204+ let dist = ( mid - center) . abs ( ) ;
1205+ let better = match best {
1206+ None => true ,
1207+ Some ( ( _, bc, bd) ) => both_sides_rows > bc || ( both_sides_rows == bc && dist < bd) ,
1208+ } ;
1209+ if better {
1210+ best = Some ( ( gutter_x, both_sides_rows, dist) ) ;
1211+ }
1212+ }
1213+ }
1214+ }
1215+ best. map ( |( gx, _, _) | gx)
1216+ }
1217+
1218+ /// Split each line into a left- and right-column half at the column gutter, then
1219+ /// emit every left half (top to bottom) followed by every right half.
1220+ ///
1221+ /// Rather than slicing at the fixed `gutter_x`, each row is cut at its own
1222+ /// largest internal whitespace gap that lies *near* the gutter. This is robust
1223+ /// to rows whose right entry starts left of the nominal gutter — e.g. an
1224+ /// unindented right-column chapter heading ("6 Differential Analysis") begins
1225+ /// further left than the indented subsection rows that set the gutter. Slicing
1226+ /// at the gap keeps "6 Differential Analysis" whole on the right instead of
1227+ /// leaving its "6" on the left (where it would be misread as a page number).
1228+ ///
1229+ /// A row whose text flows continuously across the gutter (no real gap near it,
1230+ /// e.g. a centred "CONTENTS" header) is kept whole.
1231+ fn split_lines_at_gutter ( lines : Vec < Vec < TocChar > > , gutter_x : f32 ) -> Vec < Vec < TocChar > > {
1232+ const WINDOW : f32 = 55.0 ; // how far from the gutter a cut gap may sit
1233+ const MIN_GAP : f32 = 6.0 ; // a real column gap, not inter-word spacing
1234+ let has_alpha = |chars : & [ TocChar ] | chars. iter ( ) . any ( |c| c. codepoint . is_alphabetic ( ) ) ;
1235+ let mut left: Vec < ( f32 , Vec < TocChar > ) > = Vec :: new ( ) ;
1236+ let mut right: Vec < ( f32 , Vec < TocChar > ) > = Vec :: new ( ) ;
1237+ for line in lines {
1238+ if line. is_empty ( ) {
1239+ continue ;
1240+ }
1241+ let y = line. iter ( ) . map ( |c| ( c. bbox [ 1 ] + c. bbox [ 3 ] ) / 2.0 ) . sum :: < f32 > ( ) / line. len ( ) as f32 ;
1242+ // Chars are pre-sorted by left edge. Find the widest gap near the gutter
1243+ // whose *right side still contains letters* — i.e. a cut that separates
1244+ // two real entries. This rejects the title→page-number gap of a
1245+ // left-only row (whose right side would be digits only) so the row stays
1246+ // whole, while still pulling an unindented right-column heading whole to
1247+ // the right even when its number sits left of the nominal gutter.
1248+ // Score = (left side ends in a digit, gap width). Preferring a cut whose
1249+ // left char is a digit keeps a left entry's trailing page number with
1250+ // that entry — cutting before it would strand the page, make the entry
1251+ // look like an open heading, and let it absorb the next row.
1252+ let mut best_score = ( false , 0.0f32 ) ;
1253+ let mut split_at: Option < usize > = None ;
1254+ for i in 1 ..line. len ( ) {
1255+ let gap = line[ i] . bbox [ 0 ] - line[ i - 1 ] . bbox [ 2 ] ;
1256+ let mid = ( line[ i - 1 ] . bbox [ 2 ] + line[ i] . bbox [ 0 ] ) / 2.0 ;
1257+ if gap >= MIN_GAP && ( mid - gutter_x) . abs ( ) <= WINDOW && has_alpha ( & line[ i..] ) {
1258+ let score = ( line[ i - 1 ] . codepoint . is_numeric ( ) , gap) ;
1259+ if score > best_score {
1260+ best_score = score;
1261+ split_at = Some ( i) ;
1262+ }
1263+ }
1264+ }
1265+ match split_at {
1266+ Some ( i) => {
1267+ let mut chars = line;
1268+ let r = chars. split_off ( i) ;
1269+ if !chars. is_empty ( ) {
1270+ left. push ( ( y, chars) ) ;
1271+ }
1272+ if !r. is_empty ( ) {
1273+ right. push ( ( y, r) ) ;
1274+ }
1275+ }
1276+ None => {
1277+ // No qualifying column gap: a row confined to one column or a
1278+ // full-width header. If every char sits right of the gutter it
1279+ // belongs to the right column; otherwise keep it left.
1280+ if line. iter ( ) . all ( |c| ( c. bbox [ 0 ] + c. bbox [ 2 ] ) / 2.0 >= gutter_x) {
1281+ right. push ( ( y, line) ) ;
1282+ } else {
1283+ left. push ( ( y, line) ) ;
1284+ }
1285+ }
1286+ }
1287+ }
1288+ left. sort_by ( |a, b| a. 0 . partial_cmp ( & b. 0 ) . unwrap_or ( std:: cmp:: Ordering :: Equal ) ) ;
1289+ right. sort_by ( |a, b| a. 0 . partial_cmp ( & b. 0 ) . unwrap_or ( std:: cmp:: Ordering :: Equal ) ) ;
1290+ let mut result = Vec :: with_capacity ( left. len ( ) + right. len ( ) ) ;
1291+ result. extend ( left. into_iter ( ) . map ( |( _, c) | c) ) ;
1292+ result. extend ( right. into_iter ( ) . map ( |( _, c) | c) ) ;
1293+ result
1294+ }
1295+
11181296fn split_two_column_lines ( lines : Vec < Vec < TocChar > > , _page_height : f32 ) -> Vec < Vec < TocChar > > {
11191297 if lines. len ( ) < 4 {
11201298 return lines;
@@ -1136,6 +1314,14 @@ fn split_two_column_lines(lines: Vec<Vec<TocChar>>, _page_height: f32) -> Vec<Ve
11361314 return lines; // page too narrow for two columns
11371315 }
11381316
1317+ // Primary: projection-profile gutter detection (Nagy XY-cut / Breuel
1318+ // whitespace-column style). Finds a near-empty vertical band flanked by two
1319+ // wide dense columns — robust where the per-line largest-gap heuristic fails
1320+ // (the title→page-number gaps dominate each line's biggest gap).
1321+ if let Some ( gutter_x) = detect_two_column_gutter ( & lines, page_left, page_right) {
1322+ return split_lines_at_gutter ( lines, gutter_x) ;
1323+ }
1324+
11391325 // Middle 80% of the content area — gutter should be here.
11401326 // Use 10%-90% rather than 20%-80% to handle asymmetric column layouts.
11411327 let gutter_zone_left = page_left + page_span * 0.1 ;
0 commit comments