@@ -309,19 +309,32 @@ fn split_chars_into_columns(
309309 }
310310
311311 // For each gap, check if this Y-band is spanning.
312- // A Y-band is spanning if it has chars whose center_x falls
313- // INSIDE the gap region. Separate column lines have no chars
314- // in the gap; spanning lines (titles, captions) have chars
315- // flowing through it.
312+ // A Y-band is spanning if text flows continuously from one column
313+ // through the gap into the other. We verify:
314+ // 1. Chars exist on both sides of the gap
315+ // 2. No large horizontal break between consecutive chars (sorted by X)
316+ // This distinguishes two separate column lines (~20-30pt break at the
317+ // column boundary) from a spanning title (~3-6pt char spacing throughout).
316318 let mut is_spanning = false ;
319+ let band_cxs: Vec < f32 > = band_chars
320+ . iter ( )
321+ . map ( |& i| ( chars[ i] . bbox [ 0 ] + chars[ i] . bbox [ 2 ] ) / 2.0 )
322+ . collect ( ) ;
317323 for gap in gaps {
318- let has_chars_in_gap = band_chars. iter ( ) . any ( |& i| {
319- let cx = ( chars[ i] . bbox [ 0 ] + chars[ i] . bbox [ 2 ] ) / 2.0 ;
320- cx >= gap. start && cx <= gap. end
321- } ) ;
322- if has_chars_in_gap {
323- is_spanning = true ;
324- break ;
324+ let has_left = band_cxs. iter ( ) . any ( |& cx| cx < gap. start ) ;
325+ let has_right = band_cxs. iter ( ) . any ( |& cx| cx > gap. end ) ;
326+ if has_left && has_right {
327+ let mut sorted_cxs = band_cxs. clone ( ) ;
328+ sorted_cxs. sort_by ( |a, b| a. partial_cmp ( b) . unwrap_or ( std:: cmp:: Ordering :: Equal ) ) ;
329+ let max_char_gap = sorted_cxs
330+ . windows ( 2 )
331+ . map ( |w| w[ 1 ] - w[ 0 ] )
332+ . fold ( 0.0f32 , f32:: max) ;
333+ let gap_width = gap. end - gap. start ;
334+ if max_char_gap < gap_width * 0.5 {
335+ is_spanning = true ;
336+ break ;
337+ }
325338 }
326339 }
327340
@@ -2242,6 +2255,70 @@ mod tests {
22422255 assert ! ( !spanning. is_empty( ) , "Title at y=100 should be spanning (chars every 10pt through gap)" ) ;
22432256 }
22442257
2258+ #[ test]
2259+ fn test_split_edge_char_not_spanning ( ) {
2260+ // Regression: right-column char at the gap boundary should NOT trigger
2261+ // spanning. The gap [295, 319] overshoots into the right column, and
2262+ // a right-column char `(` at bbox [317.7, 287, 320.3, 299] (cx=319.0)
2263+ // would previously trigger false spanning for the entire Y-band.
2264+ let gap = ColumnGap { midpoint : 307.0 , start : 295.0 , end : 319.0 } ;
2265+
2266+ let mut chars = Vec :: new ( ) ;
2267+ // Left column chars at y=290: x from 50 to 280 (every 10pt, width 6)
2268+ for i in 0 ..24 {
2269+ chars. push ( make_char ( 50.0 + i as f32 * 10.0 , 290.0 , 6.0 , 10.0 ) ) ;
2270+ }
2271+ // Right column chars at y=290: first char near gap boundary, then regular
2272+ chars. push ( ImgChar {
2273+ bbox : [ 317.7 , 287.0 , 320.3 , 299.4 ] , // cx=319.0, exactly at gap.end
2274+ font_size : 10.0 ,
2275+ is_bold : false ,
2276+ codepoint : '(' ,
2277+ } ) ;
2278+ for i in 1 ..24 {
2279+ chars. push ( make_char ( 320.0 + i as f32 * 10.0 , 290.0 , 6.0 , 10.0 ) ) ;
2280+ }
2281+
2282+ let ( spanning, columns) = split_chars_into_columns ( & chars, & [ gap] ) ;
2283+ assert ! (
2284+ spanning. is_empty( ) ,
2285+ "Edge char at cx=319 should NOT trigger spanning; got {} spanning chars" ,
2286+ spanning. len( )
2287+ ) ;
2288+ assert ! ( !columns[ 0 ] . is_empty( ) , "Left column should have chars" ) ;
2289+ assert ! ( !columns[ 1 ] . is_empty( ) , "Right column should have chars" ) ;
2290+ }
2291+
2292+ #[ test]
2293+ fn test_split_true_spanning_still_detected ( ) {
2294+ // A full-width title with chars flowing continuously through the gap
2295+ // should still be detected as spanning.
2296+ let gap = ColumnGap { midpoint : 307.0 , start : 295.0 , end : 319.0 } ;
2297+
2298+ let mut chars = Vec :: new ( ) ;
2299+ // Spanning title at y=100: chars every 8pt from x=50 to x=530
2300+ // Max char gap = 8pt - 6pt(width) = 2pt between chars. gap_width*0.5 = 12.
2301+ // 2 < 12 → spanning.
2302+ for i in 0 ..60 {
2303+ chars. push ( make_char ( 50.0 + i as f32 * 8.0 , 100.0 , 6.0 , 10.0 ) ) ;
2304+ }
2305+ // Column lines at y=200: separate columns with large gap
2306+ for i in 0 ..24 {
2307+ chars. push ( make_char ( 50.0 + i as f32 * 10.0 , 200.0 , 6.0 , 10.0 ) ) ;
2308+ }
2309+ for i in 0 ..24 {
2310+ chars. push ( make_char ( 330.0 + i as f32 * 10.0 , 200.0 , 6.0 , 10.0 ) ) ;
2311+ }
2312+
2313+ let ( spanning, columns) = split_chars_into_columns ( & chars, & [ gap] ) ;
2314+ assert ! (
2315+ !spanning. is_empty( ) ,
2316+ "Full-width title should be detected as spanning"
2317+ ) ;
2318+ assert ! ( !columns[ 0 ] . is_empty( ) , "Left column at y=200 should have chars" ) ;
2319+ assert ! ( !columns[ 1 ] . is_empty( ) , "Right column at y=200 should have chars" ) ;
2320+ }
2321+
22452322 // ── Running header normalization ──
22462323
22472324 #[ test]
0 commit comments