Coverage for src / quber / core / figures / dpt3 / blocks.py: 93%
75 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"""Group the parse's floating text by the scan's block partition.
3A page like a stat-panel overview prints its content as short label and value
4lines. The parse captures every line, but as separate records — `$78B` is one
5record, `Investor capital` another, joined by nothing — and anything consuming
6the records one at a time hands a reader a label without its number. The scan's
7response partitions the same page into blocks: every line belongs to exactly
8one node, and the lines of one panel come back inside one node.
10This module carries that partition onto the parse's own elements. Each plain
11text item on a scanned page is assigned to the block whose rectangle holds its
12center, and the items of a block are re-parented under one group node in the
13document, in their reading order, at the first member's position in the flow.
14The items themselves are untouched: their text, boxes and references stay the
15parse's own, and nothing is transcribed from the scan. The scan contributes one
16fact the parse does not state — which lines sit together.
18The blocks come from the response's text nodes and from its table nodes. The
19scan reads a panel collage as a table as readily as it reads one as text, and
20a printed panel is the same panel either way. A table node contributes each of
21its cells as a block, so a value and its label bind at the cell, not across
22the whole collage; the node's own rectangle stands behind the cells for text
23that sits between them, and the smallest containing block always wins.
25The same division of authority the graft enforces for figures: the scan looked
26at the page, so it decides the partition; the parse's elements supply the
27content.
29Only plain text items parented to the document body take part. An item under a
30picture is the graft's business, a heading or a footnote or a caption has a
31role the partition must not swallow, and an item already inside a group was
32grouped by an earlier run — a rerun over an enriched parse changes nothing.
33A block holding fewer than two items gains nothing from a group and gets none.
34"""
36from __future__ import annotations
38from typing import Dict, List, Optional, Sequence, Tuple
40from docling_core.types.doc.document import DoclingDocument, TextItem
41from docling_core.types.doc.items.node import NodeItem
42from docling_core.types.doc.labels import DocItemLabel, GroupLabel
43from loguru import logger
45from quber.core.figures.dpt3.models import PageDigest
46from quber.core.figures.geometry import NormBox, norm_box, prov_box
47from quber.core.figures.models import PageScan
49#: The name every group written by this module carries, so a consumer chunking
50#: the document can treat a scan block as one unit, and a rerun can tell its
51#: own grouping from a list or an inline group the parse produced itself.
52SCAN_BLOCK = "scan-text-block"
55def group_scanned_text(
56 document: DoclingDocument,
57 scans: Sequence[PageScan],
58 digests: Dict[int, PageDigest],
59 page_dims: Optional[Dict[int, Tuple[float, float]]] = None,
60) -> int:
61 """Group each scanned page's plain text by the scan's block partition.
63 Mutates `document`: the items of each block move under one group node at
64 the first member's reading-order position. Returns how many groups were
65 written.
66 """
67 dims = dict(page_dims or {})
68 for page_no, page in document.pages.items():
69 if page_no not in dims and page.size is not None:
70 dims[page_no] = (page.size.width, page.size.height)
72 written = 0
73 for scan in scans:
74 digest = digests.get(scan.page)
75 if digest is None:
76 continue
77 blocks = [
78 box
79 for box in (norm_box(item.box) for item in digest.context if item.kind == "text")
80 if box is not None
81 ]
82 for table in digest.tables:
83 blocks.extend(box for row in table.cell_boxes for box in map(norm_box, row) if box is not None)
84 table_box = norm_box(table.box)
85 if table_box is not None:
86 blocks.append(table_box)
87 if not blocks:
88 continue
89 width, height = dims.get(scan.page, (612.0, 792.0))
90 members: Dict[int, List[TextItem]] = {}
91 for item in _floating_text(document, scan.page):
92 box = prov_box(item, width, height)
93 block = _owning_block(box, blocks)
94 if block is not None:
95 members.setdefault(block, []).append(item)
97 for block_index in sorted(members):
98 items = members[block_index]
99 if len(items) < 2:
100 continue
101 group = document.insert_group(
102 sibling=items[0], label=GroupLabel.UNSPECIFIED, name=SCAN_BLOCK, after=False
103 )
104 for item in items:
105 _reparent(document, item, group)
106 written += 1
107 logger.info(
108 "Text blocks: page {} grouped {} item(s) ({})",
109 scan.page,
110 len(items),
111 " / ".join((item.text or "")[:24] for item in items[:4]),
112 )
113 return written
116def _floating_text(document: DoclingDocument, page: int) -> List[TextItem]:
117 """The page's plain text items parented to the body, in document order.
119 Everything else keeps its place: an item under a picture belongs to the
120 graft, a labelled item has a role, and an item already inside a group was
121 grouped before.
122 """
123 body = document.body
124 found: List[TextItem] = []
125 for item in document.texts:
126 if item.label != DocItemLabel.TEXT or not (item.text or "").strip():
127 continue
128 if not item.prov or item.prov[0].page_no != page:
129 continue
130 parent = item.parent.resolve(document) if item.parent else None
131 if parent is not body:
132 continue
133 found.append(item)
134 return found
137def _owning_block(box: Optional[NormBox], blocks: List[NormBox]) -> Optional[int]:
138 """The block whose rectangle holds the item's center; the smallest when
139 blocks overlap; None when no block claims it."""
140 if box is None:
141 return None
142 cx, cy = (box[0] + box[2]) / 2, (box[1] + box[3]) / 2
143 best: Optional[Tuple[float, int]] = None
144 for i, (x1, y1, x2, y2) in enumerate(blocks):
145 if x1 <= cx <= x2 and y1 <= cy <= y2:
146 area = (x2 - x1) * (y2 - y1)
147 if best is None or area < best[0]:
148 best = (area, i)
149 return best[1] if best is not None else None
152def _reparent(document: DoclingDocument, item: TextItem, group: NodeItem) -> None:
153 """Move one item under the group, keeping its identity and its order.
155 Reference surgery rather than delete-and-re-add: the item keeps its
156 self_ref, so nothing else in the document renumbers.
157 """
158 old_parent = item.parent.resolve(document) if item.parent else None
159 if old_parent is not None:
160 old_parent.children = [ref for ref in old_parent.children if ref.cref != item.self_ref]
161 group.children.append(item.get_ref())
162 item.parent = group.get_ref()