Coverage for src / quber / core / figures / grid.py: 98%

102 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-09-23 22:14 -0400

1"""Read a scanned page's tables back as grids. 

2 

3A scan returns a table as a table. The chunk carries an HTML grid with an id on 

4every cell, and the response's grounding map carries, against each of those ids, 

5the cell's box and its row, column and spans. Putting the two together gives a 

6dense grid — one entry per row and column — with a box on every cell, which is 

7the shape the rest of the table pipeline already works in. 

8 

9A table is not always its own chunk. Where a page prints a chart and a table as 

10one composite, the scan returns one figure, and writes the table inside that 

11figure's text as markdown between a pair of markers it emits itself. The values 

12are complete and correctly separated there — on one page the scan read a bond 

13issuance table's four credit-rating rows exactly, while the parse ran four 

14ratings together into a single cell and left the next three blank. 

15 

16So the markers are read and the markdown between them is taken as a table. 

17Nothing infers where the table is in the text; the scan labelled it. 

18 

19What such a table does not carry is geometry. The composite is grounded as one 

20box covering the whole figure, with nothing per cell, so its cells arrive with no 

21boxes. That costs nothing downstream: the structure correction reads the page 

22image rather than the boxes, and a table with no reading to check against already 

23puts every cell to the agent. 

24 

25Nothing here interprets the content. Spans are honoured as geometry: a cell that 

26spans two columns is written once at its own position and the positions it covers 

27are left blank, the same way a grid extracted from a text layer arrives. What the 

28header block is, which cells merged, and what the title says are read later, off 

29the table image, by the steps that read every other table. 

30""" 

31 

32from __future__ import annotations 

33 

34import re 

35from html.parser import HTMLParser 

36from typing import Any, Dict, List, Optional, Tuple 

37 

38from quber.core.extractors.set_of_mark.merge_grounding import markdown_rows 

39from quber.core.figures.models import Box, ScannedTable 

40 

41#: The grounding entry type carrying one table cell's box and grid position. 

42CELL_TYPE = "tableCell" 

43 

44#: The grounding entry type carrying the box around a whole table. It is drawn 

45#: tighter than the chunk's own box, which reaches out to the surrounding text. 

46TABLE_TYPE = "table" 

47 

48_TABLE_ID_RE = re.compile(r"<table[^>]*\bid=[\"']([^\"']+)[\"']") 

49 

50#: The markers the scan writes around a markdown table it printed inside a 

51#: figure's text, when the page prints a chart and a table as one composite. 

52_FIGURE_TABLE_RE = re.compile(r"<::table::>(.*?)<::/table::>", re.DOTALL) 

53 

54 

55def scanned_tables(response: Dict[str, Any], page: int, job_id: Optional[str]) -> List[ScannedTable]: 

56 """Every table in one page's raw response, as a dense grid with per-cell boxes. 

57 

58 `page` is the 1-based source page the submission came from. The response 

59 numbers its own single page from zero, so the source page is carried in 

60 rather than read back out of it. 

61 """ 

62 grounding = response.get("grounding") or {} 

63 cells_by_chunk = _cells_by_chunk(grounding) 

64 

65 tables: List[ScannedTable] = [] 

66 for chunk in response.get("chunks") or []: 

67 if chunk.get("type") != "table": 

68 continue 

69 chunk_id = chunk.get("id") 

70 markdown = chunk.get("markdown") or "" 

71 text_by_id = _cell_text(markdown) 

72 cells, boxes = _dense_grid(cells_by_chunk.get(chunk_id, []), text_by_id) 

73 tables.append( 

74 ScannedTable( 

75 page=page, 

76 cells=cells, 

77 cell_boxes=boxes, 

78 box=_table_box(markdown, grounding) or (chunk.get("grounding") or {}).get("box"), 

79 chunk_id=chunk_id, 

80 job_id=job_id, 

81 ) 

82 ) 

83 

84 tables.extend(_composite_tables(response, page, job_id)) 

85 return tables 

86 

87 

88def _composite_tables(response: Dict[str, Any], page: int, job_id: Optional[str]) -> List[ScannedTable]: 

89 """The tables the scan printed inside a figure's text rather than as chunks. 

90 

91 The figure's own box stands as the table's box. It is the only geometry the 

92 scan grounded for the composite, and it is drawn around the chart and the 

93 table together, so the region is generous rather than wrong. 

94 """ 

95 out: List[ScannedTable] = [] 

96 for chunk in response.get("chunks") or []: 

97 if chunk.get("type") != "figure": 

98 continue 

99 for markdown in _FIGURE_TABLE_RE.findall(chunk.get("markdown") or ""): 

100 rows = markdown_rows(markdown) 

101 if not rows: 

102 continue 

103 width = max(len(r) for r in rows) 

104 cells = [r + [""] * (width - len(r)) for r in rows] 

105 out.append( 

106 ScannedTable( 

107 page=page, 

108 cells=cells, 

109 cell_boxes=[[None] * width for _ in cells], 

110 box=(chunk.get("grounding") or {}).get("box"), 

111 chunk_id=chunk.get("id"), 

112 job_id=job_id, 

113 ) 

114 ) 

115 return out 

116 

117 

118def _cells_by_chunk(grounding: Dict[str, Any]) -> Dict[str, List[Tuple[str, Dict[str, Any]]]]: 

119 """The grounding map's cell entries, grouped by the table chunk each belongs to.""" 

120 by_chunk: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {} 

121 for cell_id, entry in grounding.items(): 

122 if entry.get("type") != CELL_TYPE: 

123 continue 

124 position = entry.get("position") or {} 

125 chunk_id = position.get("chunk_id") 

126 if chunk_id is None: 

127 continue 

128 by_chunk.setdefault(chunk_id, []).append((cell_id, entry)) 

129 return by_chunk 

130 

131 

132def _table_box(markdown: str, grounding: Dict[str, Any]) -> Optional[Box]: 

133 """The box around the table itself, from the id its opening tag carries.""" 

134 match = _TABLE_ID_RE.search(markdown) 

135 if match is None: 

136 return None 

137 entry = grounding.get(match.group(1)) or {} 

138 return entry.get("box") if entry.get("type") == TABLE_TYPE else None 

139 

140 

141def _dense_grid( 

142 cells: List[Tuple[str, Dict[str, Any]]], 

143 text_by_id: Dict[str, str], 

144) -> Tuple[List[List[str]], List[List[Optional[Box]]]]: 

145 """One row per row and one column per column, with a box beside every cell. 

146 

147 A cell that spans is written at its own row and column; the positions it 

148 covers stay blank and carry no box of their own, so the grid never states the 

149 same value twice. 

150 """ 

151 placed: List[Tuple[int, int, str, Optional[Box]]] = [] 

152 rows = 0 

153 columns = 0 

154 for cell_id, entry in cells: 

155 position = entry.get("position") or {} 

156 row = int(position.get("row", 0)) 

157 col = int(position.get("col", 0)) 

158 rows = max(rows, row + int(position.get("rowspan", 1) or 1)) 

159 columns = max(columns, col + int(position.get("colspan", 1) or 1)) 

160 placed.append((row, col, text_by_id.get(cell_id, ""), entry.get("box"))) 

161 

162 grid = [["" for _ in range(columns)] for _ in range(rows)] 

163 boxes: List[List[Optional[Box]]] = [[None for _ in range(columns)] for _ in range(rows)] 

164 for row, col, text, box in placed: 

165 if 0 <= row < rows and 0 <= col < columns: 

166 grid[row][col] = text 

167 boxes[row][col] = box 

168 return grid, boxes 

169 

170 

171class _CellText(HTMLParser): 

172 """Collects the text of every `td`/`th` carrying an id, keyed by that id. 

173 

174 Markup inside a cell is dropped and its text kept, so a value typeset with an 

175 emphasis or a line break reads as the one string the page prints. 

176 """ 

177 

178 def __init__(self) -> None: 

179 super().__init__(convert_charrefs=True) 

180 self.text: Dict[str, str] = {} 

181 self._open: List[Optional[str]] = [] 

182 self._parts: List[str] = [] 

183 

184 def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None: 

185 if tag in ("td", "th"): 

186 self._flush() 

187 self._open.append(dict(attrs).get("id")) 

188 elif tag == "br" and self._open: 

189 self._parts.append(" ") 

190 

191 def handle_endtag(self, tag: str) -> None: 

192 if tag in ("td", "th"): 

193 self._flush() 

194 

195 def handle_data(self, data: str) -> None: 

196 if self._open: 

197 self._parts.append(data) 

198 

199 def _flush(self) -> None: 

200 if not self._open: 

201 return 

202 cell_id = self._open.pop() 

203 if cell_id is not None: 

204 self.text[cell_id] = " ".join("".join(self._parts).split()) 

205 self._parts = [] 

206 

207 

208def _cell_text(markdown: str) -> Dict[str, str]: 

209 """Each cell's printed text, keyed by the id its tag carries.""" 

210 parser = _CellText() 

211 parser.feed(markdown) 

212 parser.close() 

213 return parser.text