@@ -352,6 +352,133 @@ fn effective_merge_width(item: &TextItem) -> f32 {
352352 }
353353}
354354
355+ fn is_standalone_bullet_text ( text : & str ) -> bool {
356+ matches ! ( text. trim( ) , "•" | "○" | "●" | "◦" )
357+ }
358+
359+ fn first_text_char ( text : & str ) -> Option < char > {
360+ text. trim_start ( ) . chars ( ) . next ( )
361+ }
362+
363+ fn is_short_alpha_fragment ( text : & str ) -> bool {
364+ let trimmed = text. trim ( ) ;
365+ let char_count = trimmed. chars ( ) . count ( ) ;
366+ ( 1 ..=4 ) . contains ( & char_count) && trimmed. chars ( ) . all ( char:: is_alphabetic)
367+ }
368+
369+ fn has_phrase_continuation_shape ( text : & str ) -> bool {
370+ let trimmed = text. trim_start ( ) ;
371+ trimmed
372+ . chars ( )
373+ . take ( 24 )
374+ . any ( |ch| ch. is_whitespace ( ) || matches ! ( ch, '-' ) )
375+ }
376+
377+ fn should_preserve_overlapping_stream_order ( group : & [ & TextItem ] ) -> bool {
378+ if group. len ( ) < 3 {
379+ return false ;
380+ }
381+
382+ let Some ( first) = group. iter ( ) . find ( |item| !item. text . trim ( ) . is_empty ( ) ) else {
383+ return false ;
384+ } ;
385+ if group. iter ( ) . all ( |item| item. mcid . is_none ( ) ) {
386+ return false ;
387+ }
388+
389+ let mut nonempty_count = 0 ;
390+ let mut saw_backtrack = false ;
391+ let mut nonspace_chars = 0 ;
392+ let mut math_symbol_chars = 0 ;
393+ let mut max_font_size = first. font_size ;
394+
395+ for item in group {
396+ if !item. text . trim ( ) . is_empty ( ) {
397+ nonempty_count += 1 ;
398+ }
399+ if ( item. font_size - first. font_size ) . abs ( ) > first. font_size * 0.25 {
400+ return false ;
401+ }
402+ max_font_size = max_font_size. max ( item. font_size ) ;
403+ for ch in item. text . chars ( ) . filter ( |ch| !ch. is_whitespace ( ) ) {
404+ nonspace_chars += 1 ;
405+ if matches ! (
406+ ch,
407+ '*' | 'ˆ' | '^' | '=' | '+' | '_' | '[' | ']' | '{' | '}' | '|' | '<' | '>'
408+ ) {
409+ math_symbol_chars += 1 ;
410+ }
411+ }
412+ }
413+
414+ if nonempty_count < 2 {
415+ return false ;
416+ }
417+ if nonspace_chars > 0 && math_symbol_chars * 4 > nonspace_chars {
418+ return false ;
419+ }
420+
421+ let mut sorted_by_x = group. to_vec ( ) ;
422+ sorted_by_x. sort_by ( |a, b| a. x . total_cmp ( & b. x ) ) ;
423+ let cluster_start = sorted_by_x[ 0 ] . x ;
424+ let mut cluster_end = cluster_start + effective_merge_width ( sorted_by_x[ 0 ] ) ;
425+ for item in sorted_by_x. iter ( ) . skip ( 1 ) {
426+ let gap = item. x - cluster_end;
427+ if gap > max_font_size * 2.5 {
428+ return false ;
429+ }
430+ cluster_end = cluster_end. max ( item. x + effective_merge_width ( item) ) ;
431+ }
432+ if cluster_end - cluster_start > max_font_size * 36.0 {
433+ return false ;
434+ }
435+
436+ for index in 0 ..group. len ( ) - 1 {
437+ let previous = group[ index] ;
438+ let next = group[ index + 1 ] ;
439+ let font_size = previous. font_size . max ( next. font_size ) ;
440+ let backtrack_threshold = font_size * 0.25 ;
441+ let previous_start = previous. x ;
442+ let next_start = next. x ;
443+ let next_end = next. x + effective_merge_width ( next) ;
444+ if next_start < previous_start - backtrack_threshold
445+ && next_end > previous_start + backtrack_threshold
446+ {
447+ let has_near_prefix = group[ ..=index] . iter ( ) . rev ( ) . take ( 4 ) . any ( |item| {
448+ is_short_alpha_fragment ( & item. text )
449+ && item. x >= next_start - font_size * 0.5
450+ && item. x <= next_start + font_size * 4.0
451+ } ) ;
452+ let starts_lowercase = first_text_char ( & next. text ) . is_some_and ( char:: is_lowercase) ;
453+ let phrase_continuation = has_phrase_continuation_shape ( & next. text ) ;
454+ let has_near_bullet = group[ ..=index]
455+ . iter ( )
456+ . position ( |item| {
457+ is_standalone_bullet_text ( & item. text ) && next_start <= item. x + font_size * 3.0
458+ } )
459+ . is_some_and ( |bullet_index| {
460+ if bullet_index >= index {
461+ return false ;
462+ }
463+ group[ bullet_index + 1 ..=index]
464+ . iter ( )
465+ . rev ( )
466+ . find ( |item| !item. text . trim ( ) . is_empty ( ) )
467+ . is_some_and ( |item| {
468+ item. text . trim ( ) . chars ( ) . count ( ) <= 8
469+ && has_phrase_continuation_shape ( & next. text )
470+ } )
471+ } ) ;
472+ if ( has_near_prefix && starts_lowercase && phrase_continuation) || has_near_bullet {
473+ saw_backtrack = true ;
474+ break ;
475+ }
476+ }
477+ }
478+
479+ saw_backtrack
480+ }
481+
355482pub ( crate ) fn merge_text_items ( items : Vec < TextItem > ) -> Vec < TextItem > {
356483 if items. is_empty ( ) {
357484 return items;
@@ -372,28 +499,32 @@ pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
372499 }
373500 }
374501
375- // Sort each group by X position (direction-aware)
376- for ( _, _, group) in & mut line_groups {
502+ let mut ordered_line_groups: Vec < ( u32 , f32 , Vec < & TextItem > , bool ) > = Vec :: new ( ) ;
503+
504+ // Sort each group by X position (direction-aware), except for lines whose
505+ // content stream intentionally backtracks to overlay ActualText fragments.
506+ for ( page, y, mut group) in line_groups {
377507 let rtl = is_rtl_text ( group. iter ( ) . map ( |i| & i. text ) ) ;
508+ let preserve_stream_order = !rtl && should_preserve_overlapping_stream_order ( & group) ;
378509 if rtl {
379510 group. sort_by ( |a, b| b. x . total_cmp ( & a. x ) ) ;
380- } else {
511+ } else if !preserve_stream_order {
381512 group. sort_by ( |a, b| a. x . total_cmp ( & b. x ) ) ;
382513 }
514+ ordered_line_groups. push ( ( page, y, group, preserve_stream_order) ) ;
383515 }
384516
385517 // Sort groups by page then Y descending (top of page first)
386- line_groups . sort_by ( |a, b| a. 0 . cmp ( & b. 0 ) . then_with ( || b. 1 . total_cmp ( & a. 1 ) ) ) ;
518+ ordered_line_groups . sort_by ( |a, b| a. 0 . cmp ( & b. 0 ) . then_with ( || b. 1 . total_cmp ( & a. 1 ) ) ) ;
387519
388520 let mut merged = Vec :: new ( ) ;
389521
390- for ( _, _, group) in & line_groups {
522+ for ( _, _, group, preserve_stream_order ) in & ordered_line_groups {
391523 let mut i = 0 ;
392524 while i < group. len ( ) {
393525 let first = group[ i] ;
394526 let mut text = first. text . clone ( ) ;
395527 let mut end_x = first. x + effective_merge_width ( first) ;
396- let x_gap_max = first. font_size * 0.5 ;
397528
398529 let mut j = i + 1 ;
399530 while j < group. len ( ) {
@@ -403,10 +534,15 @@ pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
403534 break ;
404535 }
405536 let gap = next. x - end_x;
537+ let x_gap_max = if * preserve_stream_order && is_standalone_bullet_text ( & text) {
538+ first. font_size * 1.2
539+ } else {
540+ first. font_size * 0.5
541+ } ;
406542 if gap > x_gap_max {
407543 break ;
408544 }
409- if gap < -first. font_size * 0.5 {
545+ if gap < -first. font_size * 0.5 && !preserve_stream_order {
410546 break ;
411547 }
412548 // Insert space at word boundaries.
@@ -428,11 +564,19 @@ pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
428564 first. font_size * 0.08
429565 }
430566 } ;
431- if gap > threshold {
567+ let needs_bullet_space = * preserve_stream_order
568+ && is_standalone_bullet_text ( & text)
569+ && !next. text . trim ( ) . is_empty ( ) ;
570+ if needs_bullet_space || gap > threshold {
432571 text. push ( ' ' ) ;
433572 }
434573 text. push_str ( & next. text ) ;
435- end_x = next. x + effective_merge_width ( next) ;
574+ let next_end = next. x + effective_merge_width ( next) ;
575+ end_x = if * preserve_stream_order {
576+ end_x. max ( next_end)
577+ } else {
578+ next_end
579+ } ;
436580 j += 1 ;
437581 }
438582
@@ -577,6 +721,11 @@ mod tests {
577721 }
578722 }
579723
724+ fn with_mcid ( mut item : TextItem ) -> TextItem {
725+ item. mcid = Some ( 1 ) ;
726+ item
727+ }
728+
580729 #[ test]
581730 fn trace_text_preview_truncates_on_char_boundary ( ) {
582731 let text = format ! ( "{}{}tail" , "a" . repeat( 79 ) , '\u{FFFD}' ) ;
@@ -625,6 +774,83 @@ mod tests {
625774 assert_eq ! ( merged[ 0 ] . text, "hello world" ) ;
626775 }
627776
777+ #[ test]
778+ fn merge_items_preserves_stream_order_for_backtracking_heading ( ) {
779+ // Some tagged PDFs emit first-letter ActualText fragments, then reset
780+ // the text matrix and draw the rest of the word from the line start.
781+ let items = vec ! [
782+ with_mcid( make_merge_item( "F" , 79.4 , 4.5 ) ) ,
783+ with_mcid( make_merge_item( "r" , 83.9 , 3.3 ) ) ,
784+ with_mcid( make_merge_item( "om tables to data-" , 79.4 , 89.7 ) ) ,
785+ with_mcid( make_merge_item( "" , 168.9 , 33.9 ) ) ,
786+ with_mcid( make_merge_item( "analytics-" , 168.9 , 75.5 ) ) ,
787+ with_mcid( make_merge_item( "ready content" , 210.5 , 60.8 ) ) ,
788+ ] ;
789+
790+ let merged = merge_text_items ( items) ;
791+
792+ assert_eq ! ( merged. len( ) , 1 ) ;
793+ assert_eq ! (
794+ merged[ 0 ] . text,
795+ "From tables to data-analytics-ready content"
796+ ) ;
797+ }
798+
799+ #[ test]
800+ fn merge_items_preserves_stream_order_for_reset_word_prefix ( ) {
801+ let items = vec ! [
802+ with_mcid( make_merge_item( "N" , 68.0 , 7.0 ) ) ,
803+ with_mcid( make_merge_item( "e" , 75.1 , 4.0 ) ) ,
804+ with_mcid( make_merge_item( "w fields created" , 68.0 , 82.0 ) ) ,
805+ ] ;
806+
807+ let merged = merge_text_items ( items) ;
808+
809+ assert_eq ! ( merged. len( ) , 1 ) ;
810+ assert_eq ! ( merged[ 0 ] . text, "New fields created" ) ;
811+ }
812+
813+ #[ test]
814+ fn merge_items_uses_x_order_for_untagged_backtracking_text ( ) {
815+ let items = vec ! [
816+ make_merge_item( "N" , 68.0 , 7.0 ) ,
817+ make_merge_item( "e" , 75.1 , 4.0 ) ,
818+ make_merge_item( "w fields created" , 68.2 , 82.0 ) ,
819+ ] ;
820+
821+ let merged = merge_text_items ( items) ;
822+
823+ let texts: Vec < _ > = merged. iter ( ) . map ( |item| item. text . as_str ( ) ) . collect ( ) ;
824+ assert_eq ! ( texts, vec![ "N" , "w fields created" , "e" ] ) ;
825+ }
826+
827+ #[ test]
828+ fn merge_items_preserves_bullet_stream_order_with_backtracking ( ) {
829+ let items = vec ! [
830+ with_mcid( make_merge_item( "•" , 79.4 , 5.0 ) ) ,
831+ with_mcid( make_merge_item( "The MS" , 91.0 , 32.6 ) ) ,
832+ with_mcid( make_merge_item( "A LoS project" , 84.4 , 70.0 ) ) ,
833+ ] ;
834+
835+ let merged = merge_text_items ( items) ;
836+
837+ assert_eq ! ( merged. len( ) , 1 ) ;
838+ assert_eq ! ( merged[ 0 ] . text, "• The MSA LoS project" ) ;
839+ }
840+
841+ #[ test]
842+ fn merge_items_keeps_normal_bullet_gap_limit_without_stream_order ( ) {
843+ let items = vec ! [
844+ make_merge_item( "•" , 79.4 , 5.0 ) ,
845+ make_merge_item( "Distant item" , 91.0 , 60.0 ) ,
846+ ] ;
847+
848+ let merged = merge_text_items ( items) ;
849+
850+ let texts: Vec < _ > = merged. iter ( ) . map ( |item| item. text . as_str ( ) ) . collect ( ) ;
851+ assert_eq ! ( texts, vec![ "•" , "Distant item" ] ) ;
852+ }
853+
628854 #[ test]
629855 fn test_group_into_lines ( ) {
630856 let items = vec ! [
0 commit comments