Coverage for src / quber / core / figures / dpt3 / graft.py: 91%
67 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
1"""The track's graft: supplant content readings, preserve role-labelled text.
3The scan's reading replaces docling's stray text fragments under a chart — two
4readings of one chart must not coexist. But docling labels its text items, and
5an item labelled footnote or caption is not a reading of the chart: it is
6native page text with a known role. The old graft deletes every text item under
7a grafted picture regardless of label, which makes one model call the only
8custodian of a footnote's text. This graft retains them, which is what gives
9footnote resolution its second source.
11Everything else — how figures and pictures are matched, how a region is
12reshaped to hold one picture per figure, what lands on the picture — is the
13shared machinery in `quber.core.figures.graft`, unchanged. The dpt-2 path
14keeps the old discard until it migrates to this rule.
15"""
17from __future__ import annotations
19from pathlib import Path
20from typing import Dict, List, Optional, Tuple
22from docling_core.types.doc.base import BoundingBox, CoordOrigin
23from docling_core.types.doc.common.reference import ProvenanceItem
24from docling_core.types.doc.document import DoclingDocument
25from docling_core.types.doc.labels import DocItemLabel
26from loguru import logger
28from quber.core.extractors.base import ExtractedTable
29from quber.core.figures.geometry import NormBox, norm_box, prov_box
30from quber.core.figures.graft import graft_figures as shared_graft_figures
31from quber.core.figures.models import PageScan
32from quber.core.fusion.graft import attach_attribution, markdown_to_table_data, reading_order_anchor
34#: The labels that survive under a picture the scan has read. A footnote or a
35#: caption is page text with a role, not a reading of the figure.
36PRESERVED_LABELS = (DocItemLabel.FOOTNOTE, DocItemLabel.CAPTION)
39def graft_figures(
40 document: DoclingDocument,
41 scans: List[PageScan],
42 page_dims: Optional[Dict[int, Tuple[float, float]]] = None,
43 source: Optional[Path] = None,
44) -> Tuple[DoclingDocument, List[str]]:
45 """Carry each scan's figure text onto the matching picture, keeping
46 footnote- and caption-labelled items in the document."""
47 return shared_graft_figures(document, scans, page_dims, source=source, preserve=PRESERVED_LABELS)
50def insert_orphan_tables(
51 document: DoclingDocument,
52 scans: List[PageScan],
53 tables: List[ExtractedTable],
54 page_dims: Dict[int, Tuple[float, float]],
55) -> List[str]:
56 """Insert each captured table the document held no element for.
58 The capture step vetted the grid the way it vets every scanned table, and
59 the region it stands on holds no table, no picture and no parse text — a
60 map's legend is the standing example — so the document gains a table at
61 the region's reading-order position, attributed like any other. Without
62 this the page's only reading of those values stays outside the document.
64 Mutates `document`. Each inserted grid's record is given the new table's
65 reference. Returns one error per orphan whose extraction produced no body.
66 """
67 by_id = {t.table_id: t for t in tables if t.table_id}
68 errors: List[str] = []
69 for scan in scans:
70 for scanned in scan.tables:
71 if scanned.table_id is None or scanned.table_ref is not None or scanned.picture_ref is not None:
72 continue
73 extracted = by_id.get(scanned.table_id)
74 if extracted is None or not (extracted.markdown or "").strip():
75 errors.append(
76 f"page {scan.page}: the scan read a table ({scanned.chunk_id}) over a region "
77 "the parse holds nothing for, and its extraction produced no body; the values "
78 "are in the table records only"
79 )
80 continue
81 data = markdown_to_table_data(extracted.markdown)
82 prov = _table_prov(scanned.box, scan.page, page_dims)
83 sibling, after = reading_order_anchor(document, extracted, page_dims)
84 if sibling is None:
85 inserted = document.add_table(data=data, prov=prov)
86 else:
87 inserted = document.insert_table(sibling=sibling, data=data, prov=prov, after=after)
88 attach_attribution(document, inserted, extracted, page_dims)
89 scanned.table_ref = inserted.self_ref
90 logger.info(
91 "Orphan table: page {} inserted the scan's reading ({}) as {}",
92 scan.page,
93 scanned.table_id,
94 inserted.self_ref,
95 )
96 return errors
99def _table_prov(
100 box: Optional[Dict[str, float]],
101 page: int,
102 page_dims: Dict[int, Tuple[float, float]],
103) -> Optional[ProvenanceItem]:
104 """A provenance record for an inserted table, its box in bottom-left points."""
105 normalized = norm_box(box)
106 if normalized is None:
107 return None
108 width, height = page_dims.get(page, (612.0, 792.0))
109 x1, y1, x2, y2 = normalized
110 bbox = BoundingBox(
111 l=x1 * width,
112 r=x2 * width,
113 t=(1.0 - y1) * height,
114 b=(1.0 - y2) * height,
115 coord_origin=CoordOrigin.BOTTOMLEFT,
116 )
117 return ProvenanceItem(page_no=page, bbox=bbox, charspan=(0, 0))
120def unhomed_tables(
121 scans: List[PageScan],
122 document: DoclingDocument,
123 page_dims: Dict[int, Tuple[float, float]],
124) -> List[str]:
125 """One flag per scanned table over a region the document holds nothing for.
127 The scan reads a stat-panel collage as a table, and the parse routinely
128 holds neither a table nor a picture there — the region is native text. The
129 capture step then never extracts it, and before this check the table
130 vanished with no record that anything had been read. The cells stay in the
131 digest records, and the page's own text is grouped by the same node's
132 rectangles, so the flag is a pointer for review rather than a loss report.
134 A region a table in the document already covers is not flagged: the table
135 engine is authoritative for text-layer tables, and the scan re-reading one
136 is redundancy by design, not a loss.
137 """
138 tables_by_page: Dict[int, List[NormBox]] = {}
139 for item in document.tables:
140 if not item.prov:
141 continue
142 page = item.prov[0].page_no
143 width, height = page_dims.get(page, (612.0, 792.0))
144 box = prov_box(item, width, height)
145 if box is not None:
146 tables_by_page.setdefault(page, []).append(box)
148 flags: List[str] = []
149 for scan in scans:
150 for table in scan.tables:
151 if table.table_id is not None or table.table_ref is not None or table.picture_ref is not None:
152 continue
153 box = norm_box(table.box)
154 if box is not None and any(_overlaps(box, other) for other in tables_by_page.get(scan.page, [])):
155 continue
156 flags.append(
157 f"page {scan.page}: the scan returned a table ({table.chunk_id}) over a region "
158 "the parse holds no table or picture for; its cells are in the digest records "
159 "and the page's own text is grouped by its rectangles"
160 )
161 return flags
164def _overlaps(one: NormBox, other: NormBox) -> bool:
165 """Do the two boxes share any area at all?"""
166 return min(one[2], other[2]) > max(one[0], other[0]) and min(one[3], other[3]) > max(one[1], other[1])