@@ -374,6 +374,17 @@ fn parse_pdf(path: &Path) -> Result<String> {
374374 . with_context ( || format ! ( "failed to extract text from PDF {}" , path. display( ) ) )
375375}
376376
377+ /// Text item with position for table detection.
378+ #[ derive( Debug , Clone ) ]
379+ struct PositionedTextItem {
380+ page : usize ,
381+ y : f32 ,
382+ x : f32 ,
383+ text : String ,
384+ /// Y coordinate scaled to integer for grouping
385+ y_scaled : i32 ,
386+ }
387+
377388/// Extract text from PDF using lopdf for better position-aware extraction.
378389///
379390/// This provides improved text ordering (top-to-bottom, left-to-right) compared to
@@ -390,10 +401,8 @@ fn parse_pdf_with_lopdf(path: &Path) -> Result<String> {
390401 anyhow:: bail!( "PDF has no pages: {}" , path. display( ) ) ;
391402 }
392403
393- // Track text with positions: (page, y_coord_inverted, x_coord, text)
394- // Using BTreeMap for automatic sorting
395- // Scale coordinates to integers to work around f32 not implementing Ord
396- let mut all_text: BTreeMap < ( usize , i32 , i32 ) , String > = BTreeMap :: new ( ) ;
404+ // Collect all text items with positions
405+ let mut all_items: Vec < PositionedTextItem > = Vec :: new ( ) ;
397406
398407 // Collect all pages and sort by page number
399408 let mut page_list: Vec < ( u32 , lopdf:: ObjectId ) > = pages. iter ( ) . map ( |( & k, & v) | ( k, v) ) . collect ( ) ;
@@ -406,38 +415,135 @@ fn parse_pdf_with_lopdf(path: &Path) -> Result<String> {
406415 let text_items = extract_text_from_content_stream ( & contents, page_num as usize ) ;
407416 for ( y, x, text) in text_items {
408417 if !text. trim ( ) . is_empty ( ) {
409- // Invert y so higher values come first (top of page)
410- // Scale by 1000 to preserve decimal precision as integers
411- let y_key = -( y * 1000.0 ) as i32 ;
412- let x_key = ( x * 100.0 ) as i32 ;
413- all_text. insert ( ( page_num as usize , y_key, x_key) , text) ;
418+ // Scale y by 1000 to group rows (tolerance of ~1 pixel for 72dpi)
419+ let y_scaled = ( y * 1000.0 ) as i32 ;
420+ all_items. push ( PositionedTextItem {
421+ page : page_num as usize ,
422+ y,
423+ x,
424+ text,
425+ y_scaled,
426+ } ) ;
414427 }
415428 }
416429 }
417430 }
418431
419- // Build output with page markers
432+ if all_items. is_empty ( ) {
433+ anyhow:: bail!( "PDF has no extractable text: {}" , path. display( ) ) ;
434+ }
435+
436+ // Build output with page markers and row structure preserved
420437 let mut output = String :: new ( ) ;
421438 let mut current_page = 0usize ;
422439
423- for ( ( page_num, _, _) , text) in all_text {
440+ // Group items by page and y position (row grouping)
441+ let mut page_groups: BTreeMap < usize , BTreeMap < i32 , Vec < PositionedTextItem > > > = BTreeMap :: new ( ) ;
442+ for item in all_items {
443+ page_groups
444+ . entry ( item. page )
445+ . or_default ( )
446+ . entry ( item. y_scaled )
447+ . or_default ( )
448+ . push ( item) ;
449+ }
450+
451+ for ( page_num, y_groups) in page_groups {
424452 // Add page break marker when page changes
425453 if page_num != current_page {
426454 if current_page > 0 {
427455 output. push_str ( "\n \n [_PAGE_BREAK_]\n \n " ) ;
428456 }
429457 current_page = page_num;
430458 }
431- // Preserve position hints for alignment detection
432- if !output. is_empty ( ) && !output. ends_with ( '\n' ) {
433- output. push ( ' ' ) ;
459+
460+ // Process rows in order (BTreeMap is sorted by y_scaled)
461+ for ( _y_key, mut items) in y_groups {
462+ // Sort items within row by x position
463+ items. sort_by ( |a, b| {
464+ let a_x = ( a. x * 100.0 ) as i32 ;
465+ let b_x = ( b. x * 100.0 ) as i32 ;
466+ let x_cmp = a_x. cmp ( & b_x) ;
467+ x_cmp. then_with ( || a. text . cmp ( & b. text ) )
468+ } ) ;
469+
470+ // Join items on same row
471+ let row_text = items
472+ . iter ( )
473+ . map ( |item| item. text . as_str ( ) )
474+ . collect :: < Vec < _ > > ( )
475+ . join ( " " ) ;
476+
477+ // Detect potential table rows by checking:
478+ // 1. Row has multiple cell-like fragments (separated by tabs or consistent spacing)
479+ // 2. Row looks like it could be a table row (multiple aligned segments)
480+ let is_potential_table_row = detect_table_row ( & items) ;
481+
482+ if is_potential_table_row {
483+ // Mark as potential table row for downstream table detection
484+ output. push_str ( & format ! ( "[_TABLE_ROW_]{}[_TABLE_ROW_]" , row_text) ) ;
485+ } else {
486+ output. push_str ( & row_text) ;
487+ }
488+ output. push ( '\n' ) ;
434489 }
435- output. push_str ( & text) ;
436490 }
437491
438492 Ok ( output)
439493}
440494
495+ /// Detect if a row of text items might be part of a table.
496+ ///
497+ /// Looks for patterns like:
498+ /// - Multiple tab-separated cells
499+ /// - Multiple fragments with consistent horizontal spacing (column alignment)
500+ /// - Fragments that look like table cells (short text, consistent width)
501+ fn detect_table_row ( items : & [ PositionedTextItem ] ) -> bool {
502+ if items. len ( ) < 2 {
503+ return false ;
504+ }
505+
506+ // Check for explicit tab separators
507+ let tab_count = items. iter ( ) . filter ( |i| i. text . contains ( '\t' ) ) . count ( ) ;
508+ if tab_count > 0 {
509+ return true ;
510+ }
511+
512+ // Check for consistent spacing between items (column alignment indicator)
513+ if items. len ( ) >= 2 {
514+ let mut gaps: Vec < i32 > = Vec :: new ( ) ;
515+ for i in 1 ..items. len ( ) {
516+ let gap = ( ( items[ i] . x - items[ i - 1 ] . x ) * 100.0 ) as i32 ;
517+ gaps. push ( gap) ;
518+ }
519+
520+ // If we have 3+ items and gaps are somewhat consistent, might be a table
521+ if items. len ( ) >= 3 {
522+ let avg_gap: i32 = gaps. iter ( ) . sum :: < i32 > ( ) / gaps. len ( ) as i32 ;
523+ let variance: i32 =
524+ gaps. iter ( ) . map ( |g| ( g - avg_gap) . abs ( ) ) . sum :: < i32 > ( ) / gaps. len ( ) as i32 ;
525+ // Low variance indicates column alignment
526+ if variance < avg_gap / 4 && avg_gap > 50 {
527+ return true ;
528+ }
529+ }
530+ }
531+
532+ // Check if items look like table cells (short, similar length)
533+ let all_short = items
534+ . iter ( )
535+ . all ( |i| i. text . len ( ) <= 30 && !i. text . contains ( ' ' ) ) ;
536+ let similar_length = items. len ( ) >= 2 && {
537+ let avg_len = items. iter ( ) . map ( |i| i. text . len ( ) as i32 ) . sum :: < i32 > ( ) / items. len ( ) as i32 ;
538+ items. iter ( ) . all ( |i| {
539+ let len = i. text . len ( ) as i32 ;
540+ ( len - avg_len) . abs ( ) < avg_len / 2
541+ } )
542+ } ;
543+
544+ all_short && similar_length && items. len ( ) >= 3
545+ }
546+
441547/// Extract text items from PDF content stream with position information.
442548///
443549/// Returns vector of (y_position, x_position, text) tuples.
0 commit comments