@@ -121,12 +121,24 @@ pub fn resolve(regions: Vec<Region>) -> Vec<Region> {
121121}
122122
123123/// Drop a regular region that is >80% contained in a surviving special region we
124- /// render **as a single unit** — a picture, or a table/table-of-contents index —
125- /// ported from docling's "Remove regular clusters that are included in wrappers"
126- /// step: the special absorbs it as a child (a table cell, an in-figure label), so
127- /// it must not also be emitted as its own paragraph/list-item. This stops the
128- /// survey list-items from appearing both inside the detected table and again as
129- /// bullets (`table_mislabeled_as_picture`).
124+ /// render **as a single unit** — a table/table-of-contents index — ported from
125+ /// docling's "Remove regular clusters that are included in wrappers" step: the
126+ /// special absorbs it as a child (a table cell), so it must not also be emitted
127+ /// as its own paragraph/list-item. This stops the survey list-items from
128+ /// appearing both inside the detected table and again as bullets
129+ /// (`table_mislabeled_as_picture`).
130+ ///
131+ /// `picture` regions stay in the swallow set even after #165: docling keeps a
132+ /// picture's contained clusters as the `PictureItem`'s *children* in the
133+ /// document JSON (`ReadingOrderModel._add_child_elements`), but its
134+ /// `MarkdownPictureSerializer` prints only the caption and the image — the
135+ /// children never reach the Markdown (verified against the corpus groundtruth:
136+ /// `amt_handbook`'s in-figure callout labels are absent). Dropping the
137+ /// fully-contained regulars here reproduces exactly that. What #165 *does*
138+ /// change is upstream, in [`add_orphan_regions`]: pictures no longer claim
139+ /// cells, so a line only partially under a figure box (straddling its border,
140+ /// ≤80 % contained) now forms an orphan region that survives this drop — those
141+ /// words were silently erased before, and docling emits them.
130142///
131143/// `form` / `key_value_region` wrappers are deliberately **excluded**: this
132144/// pipeline does not render them as a structured block (they are skipped), so
@@ -315,10 +327,23 @@ pub fn add_orphan_regions(regions: &mut Vec<Region>, cells: &[TextCell]) {
315327 // intersection-over-self > 0.2; only cells below that for *every* region are
316328 // orphans. (Our text extraction uses a stricter 0.5, but matching docling's
317329 // 0.2 here avoids emitting cells it already placed in a neighbouring region.)
330+ //
331+ // Only *regular* clusters claim cells: docling's `_find_unassigned_cells`
332+ // walks `regular_clusters` alone, so a cell under a `picture` or a wrapper
333+ // (`table`/`document_index`/`form`/`key_value_region`) that no regular
334+ // cluster covers still becomes an orphan text cluster (#165). The orphans
335+ // that end up *fully* inside the special are re-dropped by
336+ // [`drop_contained_regulars`] (docling's Markdown drops them the same way
337+ // — a picture's children never reach its `MarkdownPictureSerializer`
338+ // output, a table's text renders through the reconstructed grid). The
339+ // observable fix is the border-straddlers: a line only partially under a
340+ // figure box used to lose its cells to the picture's 0.2 claim and vanish
341+ // — now it forms an orphan region and is emitted, as docling does.
318342 let assigned = |c : & TextCell | {
319343 let ca = area ( c. l , c. t , c. r , c. b ) . max ( 1.0 ) ;
320344 regions
321345 . iter ( )
346+ . filter ( |r| r. label != "picture" && !is_wrapper ( r. label ) )
322347 . any ( |r| inter ( r, c. l , c. t , c. r , c. b ) / ca > 0.2 )
323348 } ;
324349 // Collect orphan cells (non-empty, unassigned), in page order.
@@ -400,6 +425,11 @@ pub fn recover_text_panels(regions: &mut Vec<Region>, cells: &[TextCell]) {
400425 } )
401426 . collect ( ) ;
402427 let mut out: Vec < Region > = Vec :: with_capacity ( regions. len ( ) ) ;
428+ // Synthesized paragraphs and the demoted panels' boxes are kept separate
429+ // from `out` until the end: the dedup filter below must not confuse a
430+ // paragraph we just built with a pre-existing region inside the panel.
431+ let mut demoted_paras: Vec < Region > = Vec :: new ( ) ;
432+ let mut demoted_boxes: Vec < ( f32 , f32 , f32 , f32 ) > = Vec :: new ( ) ;
403433 for ( i, r) in regions. drain ( ..) . enumerate ( ) {
404434 if r. label != "picture" || captioned[ i] {
405435 out. push ( r) ;
@@ -468,7 +498,7 @@ pub fn recover_text_panels(regions: &mut Vec<Region>, cells: &[TextCell]) {
468498 }
469499 _ => {
470500 if let Some ( ( pl, pt, pr, pb) ) = para. take ( ) {
471- out . push ( Region {
501+ demoted_paras . push ( Region {
472502 label : "text" ,
473503 score : r. score ,
474504 l : pl,
@@ -482,7 +512,7 @@ pub fn recover_text_panels(regions: &mut Vec<Region>, cells: &[TextCell]) {
482512 }
483513 }
484514 if let Some ( ( pl, pt, pr, pb) ) = para {
485- out . push ( Region {
515+ demoted_paras . push ( Region {
486516 label : "text" ,
487517 score : r. score ,
488518 l : pl,
@@ -491,7 +521,23 @@ pub fn recover_text_panels(regions: &mut Vec<Region>, cells: &[TextCell]) {
491521 b : pb,
492522 } ) ;
493523 }
524+ demoted_boxes. push ( ( r. l , r. t , r. r , r. b ) ) ;
525+ }
526+ // The paragraphs are rebuilt from *all* of the panel's cells, so any
527+ // surviving text region inside a demoted panel (an orphan cluster or a
528+ // layout-detected fragment — pictures no longer swallow them, #165) would
529+ // say the same words twice. Consume those; wrappers and pictures stay.
530+ if !demoted_boxes. is_empty ( ) {
531+ out. retain ( |r| {
532+ r. label == "picture" || is_wrapper ( r. label ) || {
533+ let ra = area ( r. l , r. t , r. r , r. b ) . max ( 1.0 ) ;
534+ !demoted_boxes
535+ . iter ( )
536+ . any ( |& ( l, t, rr, b) | inter ( r, l, t, rr, b) / ra > 0.5 )
537+ }
538+ } ) ;
494539 }
540+ out. extend ( demoted_paras) ;
495541 * regions = out;
496542}
497543
@@ -1880,6 +1926,58 @@ mod tests {
18801926 use crate :: pdfium_backend:: { LinkAnnot , PdfPage , TextCell } ;
18811927 use docling_core:: Node ;
18821928
1929+ /// #165: a picture no longer claims cells at 0.2 intersection-over-self.
1930+ /// A line straddling the figure border (≤80 % contained) becomes an orphan
1931+ /// region and survives the contained-regulars drop — before the fix its
1932+ /// cells were silently erased. A line fully inside the picture is still
1933+ /// re-dropped, matching docling's Markdown (a picture's children never
1934+ /// reach its serializer's output).
1935+ #[ test]
1936+ fn border_straddling_lines_survive_picture_interior_is_still_dropped ( ) {
1937+ let pic = Region {
1938+ label : "picture" ,
1939+ score : 0.9 ,
1940+ l : 0.0 ,
1941+ t : 0.0 ,
1942+ r : 100.0 ,
1943+ b : 100.0 ,
1944+ } ;
1945+ // ~35 % of this cell overlaps the picture (l=90..120 of 0..100): above
1946+ // the old 0.2 claim (was swallowed), below full containment (survives).
1947+ let straddler = TextCell {
1948+ text : "axis label" . into ( ) ,
1949+ l : 90.0 ,
1950+ t : 40.0 ,
1951+ r : 120.0 ,
1952+ b : 48.0 ,
1953+ } ;
1954+ let interior = TextCell {
1955+ text : "in-figure callout" . into ( ) ,
1956+ l : 10.0 ,
1957+ t : 10.0 ,
1958+ r : 60.0 ,
1959+ b : 18.0 ,
1960+ } ;
1961+ let mut regions = vec ! [ pic] ;
1962+ super :: add_orphan_regions ( & mut regions, & [ straddler, interior] ) ;
1963+ assert_eq ! (
1964+ regions. iter( ) . filter( |r| r. label == "text" ) . count( ) ,
1965+ 2 ,
1966+ "both unclaimed lines become orphans"
1967+ ) ;
1968+ super :: drop_contained_regulars ( & mut regions) ;
1969+ let texts: Vec < ( f32 , f32 ) > = regions
1970+ . iter ( )
1971+ . filter ( |r| r. label == "text" )
1972+ . map ( |r| ( r. l , r. r ) )
1973+ . collect ( ) ;
1974+ assert_eq ! (
1975+ texts,
1976+ [ ( 90.0 , 120.0 ) ] ,
1977+ "the straddler is emitted, the fully-contained callout is not"
1978+ ) ;
1979+ }
1980+
18831981 /// A colored terms-and-conditions panel detected as `picture` demotes into
18841982 /// per-paragraph `text` regions (the blank line between C.7 and C.8 splits
18851983 /// them); a chart whose only text is a few narrow axis labels keeps its
0 commit comments