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

1"""Group the parse's floating text by the scan's block partition. 

2 

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. 

9 

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. 

17 

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. 

24 

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. 

28 

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""" 

35 

36from __future__ import annotations 

37 

38from typing import Dict, List, Optional, Sequence, Tuple 

39 

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 

44 

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 

48 

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" 

53 

54 

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. 

62 

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) 

71 

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) 

96 

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 

114 

115 

116def _floating_text(document: DoclingDocument, page: int) -> List[TextItem]: 

117 """The page's plain text items parented to the body, in document order. 

118 

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 

135 

136 

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 

150 

151 

152def _reparent(document: DoclingDocument, item: TextItem, group: NodeItem) -> None: 

153 """Move one item under the group, keeping its identity and its order. 

154 

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()