55//! assigned to its best-containing region, regions are ordered in reading order
66//! (two-column aware), and each becomes a typed node by its layout label.
77
8- use fleischwolf_core:: { DoclingDocument , Node , PictureImage , Table } ;
8+ use fleischwolf_core:: { Node , PictureImage , Table } ;
99
1010use crate :: layout:: Region ;
1111use crate :: pdfium_backend:: { PdfPage , TextCell } ;
@@ -455,20 +455,27 @@ fn region_text(region: &Region, cells: &[TextCell]) -> String {
455455 let h = ( c. b - c. t ) . abs ( ) . max ( ( p. b - p. t ) . abs ( ) ) . max ( 1.0 ) ;
456456 let gap = if rtl { p. l - c. r } else { c. l - p. r } ;
457457 // Dehyphenate a wrapped word: a line ending in a hyphen/dash followed
458- // by a lowercase continuation joins without the dash or a space
459- // (`platforms—` + ` reflects` → `platformsreflects`). The dash is still
460- // raw here (clean_text normalizes em/en dashes later), so match them all.
458+ // by a continuation joins without the dash or a space (`platforms—` +
459+ // ` reflects` → `platformsreflects`). The dash is still raw here
460+ // (clean_text normalizes em/en dashes later), so match them all.
461461 let ends_dash = matches ! (
462462 joined. chars( ) . last( ) ,
463463 Some ( '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' )
464464 ) ;
465+ let before = joined. chars ( ) . nth_back ( 1 ) ; // char before the dash
466+ let next = t. chars ( ) . next ( ) ;
465467 let dehyph = dp
466468 && ends_dash
467- && joined
468- . chars ( )
469- . nth_back ( 1 )
470- . is_some_and ( |c| c. is_alphabetic ( ) )
471- && t. chars ( ) . next ( ) . is_some_and ( |c| c. is_lowercase ( ) ) ;
469+ && before. is_some_and ( |c| c. is_alphabetic ( ) )
470+ && next. is_some_and ( |n| {
471+ // Ordinary hyphenation (lowercase continuation), or a CamelCase
472+ // compound name wrapped at the hyphen — a lowercase letter before
473+ // the dash continuing with an uppercase one (`PubTab-Net` →
474+ // `PubTabNet`, `Table-Former` → `TableFormer`). Excludes runs like
475+ // `MS-COCO` (uppercase before the dash) and `PubTables-1M` (digit).
476+ n. is_lowercase ( )
477+ || ( n. is_uppercase ( ) && before. is_some_and ( |b| b. is_lowercase ( ) ) )
478+ } ) ;
472479 if dehyph {
473480 joined. pop ( ) ;
474481 } else if dp || !same_band || gap > h * 0.25 {
@@ -656,10 +663,10 @@ pub fn assemble_page(
656663 page : & PdfPage ,
657664 regions : Vec < Region > ,
658665 table_rows : & [ Option < Vec < Vec < String > > > ] ,
659- doc : & mut DoclingDocument ,
660- ) {
666+ ) -> ( Vec < Node > , Vec < ( String , String ) > ) {
667+ let mut nodes : Vec < Node > = Vec :: new ( ) ;
661668 // Recover this page's hyperlinks (rendered only in strict Markdown).
662- doc . links . extend ( resolve_link_anchors ( page) ) ;
669+ let links = resolve_link_anchors ( page) ;
663670 // Pair each region with its precomputed TableFormer grid (indexed by original
664671 // order) and order by reading order together, so they stay aligned.
665672 let mut items: Vec < ( Region , Option < Vec < Vec < String > > > ) > = regions
@@ -697,9 +704,9 @@ pub fn assemble_page(
697704 let caption = caption_for[ i]
698705 . map ( |ci| region_text ( & regions[ ci] , & page. cells ) )
699706 . filter ( |t| !t. is_empty ( ) ) ;
700- doc . push ( Node :: Picture {
707+ nodes . push ( Node :: Picture {
701708 caption,
702- image : crop_region ( page, region) ,
709+ image : crate :: timing :: timed ( " crop_region" , || crop_region ( page, region) ) ,
703710 } ) ;
704711 continue ;
705712 }
@@ -710,7 +717,7 @@ pub fn assemble_page(
710717 match region. label {
711718 // docling renders both the document title and section headers as
712719 // `##` (it never emits a top-level `#` for PDFs), so match that.
713- "title" | "section_header" => doc . push ( Node :: Heading {
720+ "title" | "section_header" => nodes . push ( Node :: Heading {
714721 level : 2 ,
715722 text : md_escape ( & text) ,
716723 } ) ,
@@ -723,15 +730,15 @@ pub fn assemble_page(
723730 . trim_start ( )
724731 . to_string ( ) ;
725732 if let Some ( ( number, rest) ) = parse_ordered_marker ( & stripped) {
726- doc . push ( Node :: ListItem {
733+ nodes . push ( Node :: ListItem {
727734 ordered : true ,
728735 number,
729736 first_in_list : false ,
730737 text : md_escape ( & rest) ,
731738 level : 0 ,
732739 } ) ;
733740 } else {
734- doc . push ( Node :: ListItem {
741+ nodes . push ( Node :: ListItem {
735742 ordered : false ,
736743 number : 0 ,
737744 first_in_list : false ,
@@ -752,11 +759,11 @@ pub fn assemble_page(
752759 vec ! [ vec![ text. clone( ) ] ]
753760 }
754761 } ) ;
755- doc . push ( Node :: Table ( Table { rows } ) ) ;
762+ nodes . push ( Node :: Table ( Table { rows } ) ) ;
756763 }
757764 // docling does not decode formulas in the standard pipeline; it emits
758765 // a placeholder comment rather than the (garbled) raw glyph text.
759- "formula" => doc . push ( Node :: Paragraph {
766+ "formula" => nodes . push ( Node :: Paragraph {
760767 text : "<!-- formula-not-decoded -->" . into ( ) ,
761768 } ) ,
762769 // Code blocks: use the space-glyph-only grouping (monospace keeps its
@@ -772,24 +779,25 @@ pub fn assemble_page(
772779 . replace ( " ;" , ";" )
773780 . replace ( " )" , ")" )
774781 . replace ( " (" , "(" ) ;
775- doc . push ( Node :: Code {
782+ nodes . push ( Node :: Code {
776783 language : None ,
777784 text : code,
778785 } ) ;
779786 // docling emits the `Listing N:` caption after the code block.
780787 if let Some ( ci) = code_caption_for[ i] {
781788 let cap = region_text ( & regions[ ci] , & page. cells ) ;
782789 if !cap. is_empty ( ) {
783- doc . push ( Node :: Paragraph { text : cap } ) ;
790+ nodes . push ( Node :: Paragraph { text : cap } ) ;
784791 }
785792 }
786793 }
787794 // text, caption, footnote → paragraph
788- _ => doc . push ( Node :: Paragraph {
795+ _ => nodes . push ( Node :: Paragraph {
789796 text : md_escape ( & text) ,
790797 } ) ,
791798 }
792799 }
800+ ( nodes, links)
793801}
794802
795803/// Merge paragraph fragments split across a column or page break. docling joins a
@@ -800,13 +808,31 @@ pub fn assemble_page(
800808/// past a figure resumes below it (`…The wing type that is` ⟶[figure]⟶ `the most
801809/// common…`), and docling emits the whole paragraph before the figure. A heading,
802810/// table, or list between them ends the paragraph (no merge).
811+ /// A paragraph that is really a figure/table caption (`Fig. 1. …`, `Table 2 …`).
812+ /// Used to skip an unpaired caption when stitching a paragraph that wraps around
813+ /// a figure.
814+ fn looks_like_caption ( text : & str ) -> bool {
815+ let head: String = text. trim_start ( ) . chars ( ) . take ( 14 ) . collect ( ) ;
816+ ( head. starts_with ( "Fig" ) || head. starts_with ( "Table" ) )
817+ && head. contains ( |c : char | c. is_ascii_digit ( ) )
818+ }
819+
803820pub ( crate ) fn merge_continuations ( nodes : & mut Vec < Node > ) {
804821 let mut i = 0 ;
805822 while i + 1 < nodes. len ( ) {
806823 let Node :: Paragraph { text : a } = & nodes[ i] else {
807824 i += 1 ;
808825 continue ;
809826 } ;
827+ // A figure/table caption is a self-contained unit; body text resuming
828+ // after a figure is the continuation case, not the caption itself. Never
829+ // stitch *from* a caption — otherwise a caption that ends in a lone glyph
830+ // (`Fig. 5. … PubTabNet. μ`) would swallow a following stray figure label
831+ // (a standalone `μ`) into `… μ μ`.
832+ if looks_like_caption ( a) {
833+ i += 1 ;
834+ continue ;
835+ }
810836 // Open if the fragment ends mid-word (a letter) or with a wrap hyphen/dash
811837 // — docling joins `vocab-` + `ulary` → `vocab- ulary`.
812838 let a_open = a. trim_end ( ) . chars ( ) . next_back ( ) . is_some_and ( |c| {
@@ -817,9 +843,13 @@ pub(crate) fn merge_continuations(nodes: &mut Vec<Node>) {
817843 continue ;
818844 }
819845 // The continuation is the next paragraph, looking past any figures the
820- // text wraps around (but nothing else).
846+ // text wraps around — and a figure/table caption that was emitted as its
847+ // own paragraph (an above-the-figure caption that didn't pair), since the
848+ // body text resumes after the whole figure+caption block.
821849 let mut j = i + 1 ;
822- while matches ! ( nodes. get( j) , Some ( Node :: Picture { .. } ) ) {
850+ while matches ! ( nodes. get( j) , Some ( Node :: Picture { .. } ) )
851+ || matches ! ( nodes. get( j) , Some ( Node :: Paragraph { text } ) if looks_like_caption( text) )
852+ {
823853 j += 1 ;
824854 }
825855 let cont = matches ! ( nodes. get( j) , Some ( Node :: Paragraph { text: b } )
0 commit comments