Skip to content

Commit bc7a79f

Browse files
committed
fix(pdf): preserve pictures inside table cells
Signed-off-by: Georg Heiler <georg.kf.heiler@gmail.com>
1 parent 2093ad3 commit bc7a79f

2 files changed

Lines changed: 285 additions & 6 deletions

File tree

docling/models/stages/reading_order/readingorder_model.py

Lines changed: 177 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from pathlib import Path
2+
from statistics import median
23

34
from docling_core.types.doc import (
45
DocItemLabel,
@@ -10,6 +11,7 @@
1011
RefItem,
1112
RichTableCell,
1213
TableData,
14+
TableItem,
1315
)
1416
from docling_core.types.doc.document import ContentLayer
1517
from docling_ibm_models.list_item_normalizer.list_marker_processor import (
@@ -40,6 +42,8 @@ class ReadingOrderOptions(BaseModel):
4042

4143

4244
class 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]:

tests/test_readingorder_table_orientation.py

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1-
from docling_core.types.doc import BoundingBox, DocItemLabel, TableCell
2-
from docling_core.types.doc.document import Orientation
1+
from docling_core.types.doc import (
2+
BoundingBox,
3+
DocItemLabel,
4+
DoclingDocument,
5+
PictureItem,
6+
RichTableCell,
7+
Size,
8+
TableCell,
9+
TextItem,
10+
)
11+
from docling_core.types.doc.document import Orientation, ProvenanceItem
312

4-
from docling.datamodel.base_models import Cluster, Table
13+
from docling.datamodel.base_models import Cluster, FigureElement, Table
514
from docling.models.stages.reading_order.readingorder_model import ReadingOrderModel
615

716

@@ -31,6 +40,19 @@ def _make_table(
3140
)
3241

3342

43+
def _make_picture(*, element_id: int, bbox: BoundingBox) -> FigureElement:
44+
return FigureElement(
45+
label=DocItemLabel.PICTURE,
46+
id=element_id,
47+
page_no=1,
48+
cluster=Cluster(
49+
id=element_id,
50+
label=DocItemLabel.PICTURE,
51+
bbox=bbox,
52+
),
53+
)
54+
55+
3456
def test_structured_table_orientation_is_carried_to_table_data():
3557
cell = TableCell(
3658
text="cell",
@@ -84,3 +106,86 @@ def test_rich_cell_fallback_table_orientation_is_carried_to_table_data():
84106
assert table_data.orientation == Orientation.ROT_270
85107
assert table_data.num_rows == 1
86108
assert table_data.num_cols == 1
109+
110+
111+
def test_picture_inside_overlapping_table_cells_becomes_rich_cell():
112+
cells = [
113+
TableCell(
114+
text="",
115+
bbox=BoundingBox(l=0, t=0, r=2, b=2),
116+
start_row_offset_idx=0,
117+
end_row_offset_idx=1,
118+
start_col_offset_idx=0,
119+
end_col_offset_idx=1,
120+
),
121+
TableCell(
122+
text="",
123+
bbox=BoundingBox(l=4, t=0, r=6, b=2),
124+
start_row_offset_idx=0,
125+
end_row_offset_idx=1,
126+
start_col_offset_idx=1,
127+
end_col_offset_idx=2,
128+
),
129+
TableCell(
130+
text="wrong overlapping cell",
131+
bbox=BoundingBox(l=0, t=2, r=8, b=8),
132+
start_row_offset_idx=1,
133+
end_row_offset_idx=2,
134+
start_col_offset_idx=0,
135+
end_col_offset_idx=1,
136+
),
137+
TableCell(
138+
text="structure",
139+
bbox=BoundingBox(l=2, t=3, r=10, b=9),
140+
start_row_offset_idx=1,
141+
end_row_offset_idx=2,
142+
start_col_offset_idx=1,
143+
end_col_offset_idx=2,
144+
),
145+
]
146+
table = _make_table(
147+
num_rows=2,
148+
num_cols=2,
149+
table_cells=cells,
150+
orientation=Orientation.ROT_0,
151+
)
152+
nested_picture = _make_picture(
153+
element_id=2,
154+
bbox=BoundingBox(l=2, t=2, r=8, b=8),
155+
)
156+
outside_picture = _make_picture(
157+
element_id=3,
158+
bbox=BoundingBox(l=60, t=60, r=90, b=90),
159+
)
160+
161+
matches = ReadingOrderModel._match_table_pictures(
162+
[table, nested_picture, outside_picture],
163+
excluded_picture_refs=set(),
164+
)
165+
166+
assert matches == {"#/1/1": {3: [nested_picture]}}
167+
168+
doc = DoclingDocument(name="rich-table-picture")
169+
doc.add_page(page_no=1, size=Size(width=100, height=100))
170+
table_item = doc.add_table(
171+
data=ReadingOrderModel._table_data_from_table(table),
172+
prov=ProvenanceItem(
173+
page_no=1,
174+
charspan=(0, 0),
175+
bbox=table.cluster.bbox.to_bottom_left_origin(100),
176+
),
177+
)
178+
ReadingOrderModel._add_rich_table_pictures(
179+
table_item=table_item,
180+
pictures_by_cell=matches["#/1/1"],
181+
doc=doc,
182+
page_height=100,
183+
)
184+
185+
rich_cell = table_item.data.table_cells[3]
186+
assert isinstance(rich_cell, RichTableCell)
187+
group = rich_cell.ref.resolve(doc)
188+
children = [child.resolve(doc) for child in group.children]
189+
assert [type(child) for child in children] == [TextItem, PictureItem]
190+
assert children[0].text == "structure"
191+
doc.validate_document()

0 commit comments

Comments
 (0)