11from pathlib import Path
2+ from statistics import median
23
34from docling_core .types .doc import (
45 DocItemLabel ,
1011 RefItem ,
1112 RichTableCell ,
1213 TableData ,
14+ TableItem ,
1315)
1416from docling_core .types .doc .document import ContentLayer
1517from docling_ibm_models .list_item_normalizer .list_marker_processor import (
@@ -40,6 +42,8 @@ class ReadingOrderOptions(BaseModel):
4042
4143
4244class ReadingOrderModel :
45+ _RICH_CELL_PICTURE_COVERAGE_THRESHOLD = 0.8
46+
4347 def __init__ (self , options : ReadingOrderOptions ):
4448 self .options = options
4549 self .ro_model = ReadingOrderPredictor ()
@@ -59,7 +63,7 @@ def _assembled_to_readingorder_elements(
5963 elements .append (
6064 ReadingOrderPageElement (
6165 cid = len (elements ),
62- ref = RefItem (cref = f"#/ { element . page_no } / { element . cluster . id } " ),
66+ ref = RefItem (cref = self . _element_ref ( element ) ),
6367 text = text ,
6468 page_no = element .page_no ,
6569 page_size = page_no_to_pages [element .page_no ].size ,
@@ -150,6 +154,152 @@ def _table_data_from_table(element: Table) -> TableData:
150154 orientation = element .orientation ,
151155 )
152156
157+ @staticmethod
158+ def _element_ref (element : BasePageElement ) -> str :
159+ return f"#/{ element .page_no } /{ element .cluster .id } "
160+
161+ @classmethod
162+ def _match_table_pictures (
163+ cls ,
164+ elements : list [BasePageElement ],
165+ excluded_picture_refs : set [str ],
166+ ) -> dict [str , dict [int , list [FigureElement ]]]:
167+ tables = [element for element in elements if isinstance (element , Table )]
168+ matches : dict [str , dict [int , list [FigureElement ]]] = {}
169+
170+ for picture in (
171+ element for element in elements if isinstance (element , FigureElement )
172+ ):
173+ if cls ._element_ref (picture ) in excluded_picture_refs :
174+ continue
175+
176+ best_match : tuple [float , Table , int ] | None = None
177+ for table in tables :
178+ if table .page_no != picture .page_no :
179+ continue
180+ if (
181+ picture .cluster .bbox .intersection_over_self (table .cluster .bbox )
182+ < cls ._RICH_CELL_PICTURE_COVERAGE_THRESHOLD
183+ ):
184+ continue
185+
186+ cell_match = cls ._match_picture_to_table_cell (table , picture )
187+ if cell_match is not None and (
188+ best_match is None or cell_match [0 ] > best_match [0 ]
189+ ):
190+ best_match = (cell_match [0 ], table , cell_match [1 ])
191+
192+ if best_match is None :
193+ continue
194+
195+ _ , table , cell_index = best_match
196+ matches .setdefault (cls ._element_ref (table ), {}).setdefault (
197+ cell_index , []
198+ ).append (picture )
199+
200+ return matches
201+
202+ @classmethod
203+ def _match_picture_to_table_cell (
204+ cls , table : Table , picture : FigureElement
205+ ) -> tuple [float , int ] | None :
206+ eligible = [
207+ (
208+ picture .cluster .bbox .intersection_over_self (cell .bbox ),
209+ cell_index ,
210+ cell ,
211+ )
212+ for cell_index , cell in enumerate (table .table_cells )
213+ if cell .bbox is not None
214+ and picture .cluster .bbox .intersection_over_self (cell .bbox )
215+ >= cls ._RICH_CELL_PICTURE_COVERAGE_THRESHOLD
216+ ]
217+ if not eligible :
218+ return None
219+
220+ # Cell boxes can overlap across logical rows and columns. Infer the
221+ # picture's grid position before choosing among containing cells.
222+ row_centers : dict [int , list [float ]] = {}
223+ column_centers : dict [int , list [float ]] = {}
224+ for cell in table .table_cells :
225+ if cell .bbox is None :
226+ continue
227+ for row in range (cell .start_row_offset_idx , cell .end_row_offset_idx ):
228+ row_centers .setdefault (row , []).append ((cell .bbox .t + cell .bbox .b ) / 2 )
229+ for column in range (cell .start_col_offset_idx , cell .end_col_offset_idx ):
230+ column_centers .setdefault (column , []).append (
231+ (cell .bbox .l + cell .bbox .r ) / 2
232+ )
233+
234+ picture_x = (picture .cluster .bbox .l + picture .cluster .bbox .r ) / 2
235+ picture_y = (picture .cluster .bbox .t + picture .cluster .bbox .b ) / 2
236+ row = min (
237+ row_centers ,
238+ key = lambda index : abs (median (row_centers [index ]) - picture_y ),
239+ )
240+ column = min (
241+ column_centers ,
242+ key = lambda index : abs (median (column_centers [index ]) - picture_x ),
243+ )
244+ logical_matches = [
245+ match
246+ for match in eligible
247+ if match [2 ].start_row_offset_idx <= row < match [2 ].end_row_offset_idx
248+ and match [2 ].start_col_offset_idx <= column < match [2 ].end_col_offset_idx
249+ ]
250+ coverage , cell_index , _ = max (logical_matches or eligible )
251+ return coverage , cell_index
252+
253+ @staticmethod
254+ def _add_rich_table_pictures (
255+ * ,
256+ table_item : TableItem ,
257+ pictures_by_cell : dict [int , list [FigureElement ]],
258+ doc : DoclingDocument ,
259+ page_height : float ,
260+ ) -> None :
261+ for cell_index , pictures in pictures_by_cell .items ():
262+ cell = table_item .data .table_cells [cell_index ]
263+ group = doc .add_group (
264+ label = GroupLabel .UNSPECIFIED ,
265+ name = (
266+ f"rich_cell_group_{ len (doc .tables )} _"
267+ f"{ cell .start_col_offset_idx } _{ cell .start_row_offset_idx } "
268+ ),
269+ parent = table_item ,
270+ )
271+
272+ if cell .text :
273+ cell_bbox = (
274+ cell .bbox if cell .bbox is not None else pictures [0 ].cluster .bbox
275+ )
276+ doc .add_text (
277+ label = DocItemLabel .TEXT ,
278+ text = cell .text ,
279+ prov = ProvenanceItem (
280+ page_no = pictures [0 ].page_no ,
281+ charspan = (0 , len (cell .text )),
282+ bbox = cell_bbox .to_bottom_left_origin (page_height ),
283+ ),
284+ parent = group ,
285+ )
286+
287+ for picture in pictures :
288+ doc .add_picture (
289+ annotations = picture .annotations or None ,
290+ prov = ProvenanceItem (
291+ page_no = picture .page_no ,
292+ charspan = (0 , 0 ),
293+ bbox = picture .cluster .bbox .to_bottom_left_origin (page_height ),
294+ ),
295+ parent = group ,
296+ )
297+
298+ table_item .data .table_cells [cell_index ] = RichTableCell (
299+ ** cell .model_dump (exclude = {"ref" }),
300+ ref = group .get_ref (),
301+ )
302+
153303 def _readingorder_elements_to_docling_doc (
154304 self ,
155305 conv_res : ConversionResult ,
@@ -159,10 +309,24 @@ def _readingorder_elements_to_docling_doc(
159309 el_merges_mapping : dict [int , list [int ]],
160310 ) -> DoclingDocument :
161311 id_to_elem = {
162- RefItem (cref = f"#/{ elem .page_no } /{ elem .cluster .id } " ).cref : elem
163- for elem in conv_res .assembled .elements
312+ self ._element_ref (elem ): elem for elem in conv_res .assembled .elements
164313 }
165314 cid_to_rels = {rel .cid : rel for rel in ro_elements }
315+ excluded_picture_refs = {
316+ rel .ref .cref
317+ for rel in ro_elements
318+ if rel .cid in el_to_captions_mapping or rel .cid in el_to_footnotes_mapping
319+ }
320+ rich_table_pictures = self ._match_table_pictures (
321+ conv_res .assembled .elements ,
322+ excluded_picture_refs = excluded_picture_refs ,
323+ )
324+ rich_picture_refs = {
325+ self ._element_ref (picture )
326+ for cells in rich_table_pictures .values ()
327+ for pictures in cells .values ()
328+ for picture in pictures
329+ }
166330
167331 origin = DocumentOrigin (
168332 mimetype = "application/pdf" ,
@@ -191,6 +355,9 @@ def _readingorder_elements_to_docling_doc(
191355 for lst in mapping .values ()
192356 for cid in lst
193357 }
358+ skippable_cids .update (
359+ rel .cid for rel in ro_elements if rel .ref .cref in rich_picture_refs
360+ )
194361
195362 page_no_to_pages = {p .page_no : p for p in conv_res .pages }
196363
@@ -255,6 +422,13 @@ def _readingorder_elements_to_docling_doc(
255422 tbl = out_doc .add_table (
256423 data = tbl_data , prov = prov , label = element .cluster .label
257424 )
425+ if rel .ref .cref in rich_table_pictures :
426+ self ._add_rich_table_pictures (
427+ table_item = tbl ,
428+ pictures_by_cell = rich_table_pictures [rel .ref .cref ],
429+ doc = out_doc ,
430+ page_height = page_height ,
431+ )
258432
259433 if rel .cid in el_to_captions_mapping .keys ():
260434 for caption_cid in el_to_captions_mapping [rel .cid ]:
0 commit comments