Coverage for src / quber / core / figures / graft.py: 94%

220 statements  

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

1"""Put each figure's text onto the picture the parse already found. 

2 

3The placeholder is already in the document. Every picture the parse detected sits 

4in the body in reading order carrying its page and its box, and nomination read 

5that placeholder rather than creating it. Grafting fills it in: the picture that 

6was always there now carries the figure's content. 

7 

8So a chart is not a new kind of element competing with tables for a position. It 

9carries no cells and no per-value location, and is not shaped like a table. 

10 

11The text lands as the picture's description, the document model's own field for 

12what a picture depicts, and the scan's identity lands in the picture's metadata 

13so a picture can be traced back to the scan that read it and a model change is 

14visible without re-reading the text. Each record is given the reference of the 

15picture it filled, so a plotted value traces back to the element that holds it. 

16 

17The two counts do not always agree. The scan decides, because it looked at the 

18page and the box it drew is already a source coordinate, while the parse is 

19working from a layout model. So a region ends up holding exactly as many pictures 

20as the scan returned figures: 

21 

22- One picture the scan read as several figures is split into one per figure. The 

23 parse draws a single region over two charts printed side by side. 

24- Several pictures the scan read as one figure become one. The parse splits a 

25 chart pair the scan describes together. 

26- A region already agreeing takes the scan's boxes and is otherwise untouched, 

27 which makes a second run over an enriched parse a no-op. 

28- A figure over no picture at all is surfaced as an error and never dropped, 

29 since a figure the scan read that nothing holds is a value that vanishes. 

30 

31Figures and pictures are grouped by overlap before anything is assigned, so the 

32order the figures arrive in never decides an outcome. 

33 

34What a picture *is* remains the parse's call, not the scan's. The scan describes a 

35wordmark as readily as a chart, and a branded deck prints one on every page, so a 

36region the parse classified as page furniture is dropped with its records. That 

37judgement reads a class the parse already published rather than a rule written 

38against a description that has no fixed shape. 

39 

40The text the parse lifted out of a picture is removed once the scan has read it. 

41Those fragments are bare numbers with nothing saying what they measure, and the 

42description now carries the same values with their series and their axes. Keeping 

43both would put two readings of one chart in the document. 

44 

45Titles and notes are not grafted onto any picture. One note commonly serves 

46several charts on a page and nothing here matches a superscript to the note it 

47points at, so they stay in the run record, unattached. 

48 

49A table read off a page image goes into the table the parse already found for it, 

50the same way. The element stays a table; only its body changes, from the parse's 

51own reading of the image to the scan's, along with the title, subtitle, units and 

52footnotes read off the page with it. 

53 

54The source document is never mutated by `graft_figures`; the caller gets a clone. 

55`graft_tables` mutates the document it is given, which is that clone. 

56""" 

57 

58from __future__ import annotations 

59 

60from pathlib import Path 

61from typing import Collection, Dict, List, Optional, Tuple 

62 

63from docling_core.types.doc.base import BoundingBox, CoordOrigin 

64from docling_core.types.doc.common.meta import DescriptionMetaField 

65from docling_core.types.doc.common.reference import ImageRef, ProvenanceItem 

66from docling_core.types.doc.document import DoclingDocument, PictureItem, PictureMeta, TextItem 

67from docling_core.types.doc.labels import DocItemLabel 

68from loguru import logger 

69 

70from quber.core.extractors.base import ExtractedTable 

71from quber.core.extractors.camelot.correspondence.geometry import coverage_fraction 

72from quber.core.figures.geometry import NormBox, norm_box, prov_box 

73from quber.core.figures.models import FigureRecord, PageScan, ScannedTable 

74from quber.core.figures.nominate import FURNITURE_CLASSES, picture_classes 

75from quber.core.fusion.graft import READ_BY_FIELD, attach_attribution, markdown_to_table_data 

76from quber.files.pdf import render_region 

77 

78#: Minimum box overlap for a returned figure and a parse picture to be the same 

79#: region, measured both ways so a large picture containing a small figure and a 

80#: figure drawn wider than the picture both register. Carried over from the 

81#: table matcher, whose regions are the same size and drawn by the same kinds of 

82#: detector. 

83MATCH_FRACTION = 0.20 

84 

85#: Two figures whose tops sit within this fraction of the page height are printed 

86#: side by side rather than stacked. A chart occupies far more of a page than 

87#: this, so the band separates rows without needing to know the layout. 

88ROW_BAND = 0.05 

89 

90#: What the picture's description records as its author. 

91DESCRIPTION_AUTHOR = "quber-chart-scan" 

92 

93#: The reader this workflow uses, recorded on every region it reads so a later 

94#: reader can see the region is already read and by what. 

95ADE = "ade" 

96 

97#: Where a figure records the footnote reference markers its labels carry, each 

98#: with what it points at. A table states its markers as an attribute rather than 

99#: leaving them inside its rendered body, and a figure states them the same way, 

100#: so resolving them later reads a field instead of the description they sit in. 

101FOOTNOTE_MARKS_FIELD = "quber__footnote_marks" 

102 

103#: Where a figure records the notes printed on its page, as marker and text. The 

104#: agent reads them off the image the same way a table's correction agent reads 

105#: the notes below the table, so resolution starts from a pair it was given 

106#: rather than from a scan over the page's lines. 

107FOOTNOTES_FIELD = "quber__footnotes" 

108 

109#: Resolution for a re-cut bitmap when the picture being split carried none to 

110#: match. 72 dots per inch is one pixel per PDF point, the document model's own 

111#: unscaled default. 

112DEFAULT_CROP_DPI = 72 

113 

114 

115def graft_figures( 

116 document: DoclingDocument, 

117 scans: List[PageScan], 

118 page_dims: Optional[Dict[int, Tuple[float, float]]] = None, 

119 match_fraction: float = MATCH_FRACTION, 

120 source: Optional[Path] = None, 

121 preserve: Collection[DocItemLabel] = (), 

122) -> Tuple[DoclingDocument, List[str]]: 

123 """Carry each scan's figure text onto the matching picture in a clone of `document`. 

124 

125 `page_dims` gives each page's width and height in points, used to bring the 

126 parse's picture boxes into the scan's normalized frame. Pages absent from it 

127 fall back to the document's own page sizes. 

128 

129 `source` is the document the parse was made from. Splitting a picture moves 

130 the boundaries its stored bitmap was cut to, so each part's bitmap is cut 

131 again from the page. Without the source a split part carries no bitmap and the 

132 loss is logged. 

133 

134 `preserve` names text labels that survive under a picture the scan has read. 

135 The scan's reading supersedes the parse's stray content fragments, but an 

136 item the parse labelled a footnote or a caption is native page text with a 

137 known role, not a reading of the figure, and a caller passing its label 

138 keeps it in the document. 

139 

140 Returns the refined document and one error string per figure that overlaps no 

141 picture. The records in `scans` are given the reference of the picture each 

142 one filled, and a record whose region the parse called page furniture is 

143 removed from the scan. 

144 """ 

145 refined = document.model_copy(deep=True) 

146 dims = dict(page_dims or {}) 

147 for page_no, page in refined.pages.items(): 

148 if page_no not in dims and page.size is not None: 

149 dims[page_no] = (page.size.width, page.size.height) 

150 

151 pictures_by_page: Dict[int, List[PictureItem]] = {} 

152 for picture in refined.pictures: 

153 if picture.prov: 

154 pictures_by_page.setdefault(picture.prov[0].page_no, []).append(picture) 

155 

156 errors: List[str] = [] 

157 # Structure is reshaped first and the text attached afterwards. Removing a 

158 # picture renumbers the ones after it, so a reference read before the last 

159 # reshape would name the wrong element. 

160 pairs: List[Tuple[PictureItem, FigureRecord, PageScan]] = [] 

161 for scan in scans: 

162 width, height = dims.get(scan.page, (612.0, 792.0)) 

163 candidates = pictures_by_page.get(scan.page, []) 

164 regions, unmatched = _regions(scan.figures, candidates, width, height, match_fraction) 

165 

166 for chart in unmatched: 

167 errors.append( 

168 f"page {scan.page}: the scan returned a figure at {chart.box} that overlaps no " 

169 f"picture in the parse (job {chart.job_id}); its text is in the figure records only" 

170 ) 

171 for pictures, figures in regions: 

172 if _is_furniture(pictures): 

173 _discard(scan, figures, pictures) 

174 continue 

175 pairs.extend( 

176 (picture, chart, scan) 

177 for picture, chart in _reshape(refined, pictures, figures, scan, width, height, source) 

178 ) 

179 

180 for picture, chart, scan in pairs: 

181 _attach(picture, chart, scan) 

182 # Done in one pass at the end: removing a text renumbers the texts after it, 

183 # and the pictures were being collected until the last attachment. 

184 _discard_superseded(refined, [picture for picture, _c, _s in pairs], preserve) 

185 return refined, errors 

186 

187 

188def graft_tables( 

189 document: DoclingDocument, 

190 scans: List[PageScan], 

191 tables: List[ExtractedTable], 

192 page_dims: Dict[int, Tuple[float, float]], 

193) -> List[str]: 

194 """Put each table read off a page image into the parse table it was read for. 

195 

196 The element stays a table. Nothing is relabelled: a balance sheet is a table 

197 whichever tool read it, and calling it an image so it could travel a 

198 picture-shaped path would bend the document to suit the code. 

199 

200 The extracted body replaces the parse's own reading of the image, and the 

201 title, subtitle, units and footnotes the correction read off the page are 

202 attached with it, exactly as they are for a table the table engine produced. 

203 Nothing is lost by replacing: the page's raw response is stored as it came 

204 back, so what was read and by which model version stays on record. 

205 

206 Mutates `document` in place — the caller owns the clone — and returns one 

207 error per table that produced no body to put in. 

208 """ 

209 by_id = {t.table_id: t for t in tables if t.table_id} 

210 by_ref = {t.self_ref: t for t in document.tables} 

211 

212 errors: List[str] = [] 

213 by_picture = {p.self_ref: p for p in document.pictures} 

214 

215 for scan in scans: 

216 for scanned in scan.tables: 

217 if scanned.table_id is None: 

218 continue 

219 if scanned.table_ref is None and scanned.picture_ref is not None: 

220 _add_beside_picture(document, scan, scanned, by_picture, by_id, page_dims, errors) 

221 continue 

222 if scanned.table_ref is None: 

223 continue 

224 table_item = by_ref.get(scanned.table_ref) 

225 extracted = by_id.get(scanned.table_id) 

226 if table_item is None or extracted is None: 

227 errors.append( 

228 f"page {scan.page}: the table read off the page image ({scanned.table_id}) has " 

229 f"no home in the parse ({scanned.table_ref}); it is in the table records only" 

230 ) 

231 continue 

232 if not (extracted.markdown or "").strip(): 

233 errors.append( 

234 f"page {scan.page}: the scan returned a table over {scanned.table_ref} with no " 

235 "cells in it; the parse's own reading of the image is left standing" 

236 ) 

237 continue 

238 logger.info( 

239 "Table graft: page {} replacing {} with the scan's reading ({})", 

240 scan.page, 

241 scanned.table_ref, 

242 scanned.table_id, 

243 ) 

244 table_item.data = markdown_to_table_data(extracted.markdown) 

245 attach_attribution(document, table_item, extracted, page_dims) 

246 return errors 

247 

248 

249def unread_pictures( 

250 document: DoclingDocument, 

251 page_dims: Dict[int, Tuple[float, float]], 

252) -> List[str]: 

253 """One error per picture the run left with nothing in it. 

254 

255 A figure the scan returned that covers no picture is already reported, so a 

256 value the scan read never disappears quietly. The reverse was not: a picture 

257 the scan returned nothing over ended the run empty and said nothing about it. 

258 That is how a fourteen-row table of property sales went missing on a page the 

259 run reported no errors for — the parse had filed the region as a picture, and 

260 a picture nothing claimed was indistinguishable from a picture correctly left 

261 alone. 

262 

263 A picture is accounted for when it carries a description, or when a table in 

264 the document covers it. The second case is a region the parse detected twice, 

265 once as a table and once as a picture; the table holds the content and the 

266 picture is a duplicate outline of it. 

267 

268 Page furniture is not reported. A logo is never read on purpose. 

269 """ 

270 errors: List[str] = [] 

271 tables_by_page: Dict[int, List[NormBox]] = {} 

272 for table in document.tables: 

273 if not table.prov: 

274 continue 

275 page = table.prov[0].page_no 

276 width, height = page_dims.get(page, (612.0, 792.0)) 

277 box = prov_box(table, width, height) 

278 if box is not None: 

279 tables_by_page.setdefault(page, []).append(box) 

280 

281 for picture in document.pictures: 

282 if not picture.prov: 

283 continue 

284 classes = picture_classes(picture) 

285 if classes and all(c in FURNITURE_CLASSES for c in classes): 

286 continue 

287 if (picture.meta.description.text if picture.meta and picture.meta.description else "").strip(): 

288 continue 

289 page = picture.prov[0].page_no 

290 width, height = page_dims.get(page, (612.0, 792.0)) 

291 box = prov_box(picture, width, height) 

292 if box is not None and any(_overlaps(box, other) for other in tables_by_page.get(page, [])): 

293 continue 

294 errors.append( 

295 f"page {page}: picture {picture.self_ref} " 

296 f"({', '.join(classes) or 'unclassified'}) was read by nothing — the scan returned no " 

297 "figure over it and the document holds no table there; whatever it shows is not in the " 

298 "document" 

299 ) 

300 return errors 

301 

302 

303def _overlaps(one: NormBox, other: NormBox) -> bool: 

304 """Do the two boxes share any area at all?""" 

305 return min(one[2], other[2]) > max(one[0], other[0]) and min(one[3], other[3]) > max(one[1], other[1]) 

306 

307 

308def _add_beside_picture( 

309 document: DoclingDocument, 

310 scan: PageScan, 

311 scanned: ScannedTable, 

312 by_picture: Dict[str, PictureItem], 

313 by_id: Dict[str, ExtractedTable], 

314 page_dims: Dict[int, Tuple[float, float]], 

315 errors: List[str], 

316) -> None: 

317 """Put a table the parse filed as a picture into the document as a table. 

318 

319 The parse detected the region and called it a picture. The scan read it and 

320 returned a grid, so the page prints a table and the document should hold one. 

321 It is added rather than swapped in, because the picture is a real element and 

322 the printed image is what the values were read from. 

323 

324 Nothing else about it is special. The body is the corrected grid every other 

325 scanned table carries, attached the same way, on a provenance box taken from 

326 the picture the table was printed over. 

327 """ 

328 picture = by_picture.get(scanned.picture_ref or "") 

329 extracted = by_id.get(scanned.table_id or "") 

330 if picture is None or extracted is None or not (extracted.markdown or "").strip(): 

331 errors.append( 

332 f"page {scan.page}: the scan read a table over picture {scanned.picture_ref}, which the " 

333 f"parse holds no table for, and the grid produced no body; the values are in the table " 

334 f"records only" 

335 ) 

336 return 

337 logger.info( 

338 "Table graft: page {} adding the scan's reading of {} as a table ({})", 

339 scan.page, 

340 scanned.picture_ref, 

341 scanned.table_id, 

342 ) 

343 table_item = document.add_table( 

344 data=markdown_to_table_data(extracted.markdown), 

345 prov=picture.prov[0] if picture.prov else None, 

346 ) 

347 attach_attribution(document, table_item, extracted, page_dims) 

348 

349 

350def _discard_superseded( 

351 document: DoclingDocument, 

352 pictures: List[PictureItem], 

353 preserve: Collection[DocItemLabel] = (), 

354) -> int: 

355 """Remove the text the parse pulled out of a picture the scan has now read. 

356 

357 A picture the parse detected carries the fragments it managed to lift off the 

358 image as its own children — a chart's plotted labels arrive as `$0.05`, 

359 `$0.02`, `$0.06`, bare numbers with nothing saying which series or which year 

360 they belong to. Once the scan has read the figure, those fragments are the 

361 same region read worse, and keeping them puts two readings of one chart in 

362 the document with only one of them saying what the numbers mean. 

363 

364 An item whose label is in `preserve` stays: it is native page text with a 

365 role the parse named, not a reading of the figure. 

366 

367 This is the same replacement a table gets. A table read off a page image has 

368 its body replaced rather than doubled, for the same reason and with the same 

369 justification: the raw response is on disk, so nothing is unrecoverable. 

370 """ 

371 doomed = [] 

372 for picture in pictures: 

373 for child in picture.children: 

374 item = child.resolve(document) 

375 if isinstance(item, TextItem) and item.label not in preserve: 

376 doomed.append(item) 

377 if not doomed: 

378 return 0 

379 logger.info( 

380 "Figure graft: removing {} text fragment(s) the parse lifted from {} picture(s) the scan has read", 

381 len(doomed), 

382 len(pictures), 

383 ) 

384 document.delete_items(node_items=doomed) 

385 return len(doomed) 

386 

387 

388def _regions( 

389 figures: List[FigureRecord], 

390 candidates: List[PictureItem], 

391 width: float, 

392 height: float, 

393 match_fraction: float, 

394) -> Tuple[List[Tuple[List[PictureItem], List[FigureRecord]]], List[FigureRecord]]: 

395 """Group figures and pictures that overlap into regions, one region per subject. 

396 

397 Every figure is scored against every picture before anything is grouped, so 

398 the order the figures arrive in never decides an assignment. Overlap is 

399 transitive: a figure joins every picture it overlaps and a picture joins every 

400 figure that overlaps it, and the connected result is one region. A region can 

401 then hold any mix — one to one, one picture the scan read as several figures, 

402 several pictures the scan read as one figure, or a tangle of both. 

403 

404 Returns the regions in the pictures' document order, each region's pictures in 

405 document order and its figures in reading order, plus the figures that overlap 

406 no picture past the threshold. 

407 """ 

408 boxed = [(p, box) for p, box in ((p, prov_box(p, width, height)) for p in candidates) if box] 

409 order = {p.self_ref: i for i, (p, _b) in enumerate(boxed)} 

410 

411 # picture ref -> figures over it, and figure index -> pictures under it. 

412 over: Dict[str, List[int]] = {} 

413 under: Dict[int, List[str]] = {} 

414 for i, chart in enumerate(figures): 

415 cbox = norm_box(chart.box) 

416 if cbox is None: 

417 continue 

418 for picture, pbox in boxed: 

419 cov = max(coverage_fraction(cbox, pbox), coverage_fraction(pbox, cbox)) 

420 if cov >= match_fraction: 

421 over.setdefault(picture.self_ref, []).append(i) 

422 under.setdefault(i, []).append(picture.self_ref) 

423 

424 unmatched = [chart for i, chart in enumerate(figures) if i not in under] 

425 

426 by_ref = {p.self_ref: p for p, _b in boxed} 

427 regions: List[Tuple[List[PictureItem], List[FigureRecord]]] = [] 

428 seen_pictures: set[str] = set() 

429 seen_charts: set[int] = set() 

430 for picture, _box in boxed: 

431 if picture.self_ref in seen_pictures or picture.self_ref not in over: 

432 continue 

433 # Walk the overlap both ways until the region stops growing. 

434 refs = {picture.self_ref} 

435 idxs: set[int] = set() 

436 frontier = [picture.self_ref] 

437 while frontier: 

438 ref = frontier.pop() 

439 for i in over.get(ref, []): 

440 if i in idxs: 

441 continue 

442 idxs.add(i) 

443 for other in under.get(i, []): 

444 if other not in refs: 

445 refs.add(other) 

446 frontier.append(other) 

447 seen_pictures |= refs 

448 seen_charts |= idxs 

449 regions.append( 

450 ( 

451 sorted((by_ref[r] for r in refs), key=lambda p: order[p.self_ref]), 

452 _reading_order([figures[i] for i in sorted(idxs)]), 

453 ) 

454 ) 

455 return regions, unmatched 

456 

457 

458def _reading_order(figures: List[FigureRecord]) -> List[FigureRecord]: 

459 """Figures in the order the page prints them: down the page, then across.""" 

460 

461 def key(chart: FigureRecord) -> Tuple[int, float]: 

462 box = norm_box(chart.box) or (0.0, 0.0, 0.0, 0.0) 

463 return (round(box[1] / ROW_BAND), box[0]) 

464 

465 return sorted(figures, key=key) 

466 

467 

468def _is_furniture(pictures: List[PictureItem]) -> bool: 

469 """True when the parse called every picture in the region page furniture. 

470 

471 A logo or an icon is the same mark on every page of a deck, and the scan 

472 describes it as readily as it describes a chart. The parse's class is the 

473 signal for what a picture is, and it is trusted here rather than reading the 

474 scan's description to guess — one is a prediction the parse already made and 

475 published, the other is a rule written against prose with no fixed shape. 

476 """ 

477 classes = [c for picture in pictures for c in picture_classes(picture)] 

478 return bool(classes) and all(c in FURNITURE_CLASSES for c in classes) 

479 

480 

481def _discard(scan: PageScan, figures: List[FigureRecord], pictures: List[PictureItem]) -> None: 

482 """Drop the records for a region the parse called page furniture. 

483 

484 The scan read the region and was right about it, so nothing is being 

485 corrected. The record is dropped because a description of a wordmark is not 

486 document content, and keeping it would put a figure on every page of a branded 

487 deck and count it among the figures. 

488 """ 

489 logger.info( 

490 "Figure graft: page {} dropping {} record(s) over page furniture ({})", 

491 scan.page, 

492 len(figures), 

493 ", ".join(sorted({c for p in pictures for c in picture_classes(p)})), 

494 ) 

495 for chart in figures: 

496 if chart in scan.figures: 

497 scan.figures.remove(chart) 

498 if not scan.figures and scan.status == "figures": 

499 scan.status = "empty" 

500 

501 

502def _reshape( 

503 document: DoclingDocument, 

504 pictures: List[PictureItem], 

505 figures: List[FigureRecord], 

506 scan: PageScan, 

507 width: float, 

508 height: float, 

509 source: Optional[Path], 

510) -> List[Tuple[PictureItem, FigureRecord]]: 

511 """Make the region hold one picture per figure the scan returned. 

512 

513 The scan is the authority on how many figures a region holds and where each 

514 one sits. It looked at the page, and the box it draws is already a source 

515 coordinate, so the parse's count yields to it in both directions: a picture 

516 the scan read as several figures becomes several, and several pictures the 

517 scan read as one figure become one. A region already agreeing is left alone 

518 structurally and simply takes the scan's boxes, which makes a second run over 

519 an enriched parse a no-op. 

520 

521 The first picture in document order survives, so the reading-order position 

522 the parse gave the region is kept, and any others are removed. Extra figures 

523 are inserted behind the survivor. Each resulting picture carries the parse's 

524 class prediction for the region. 

525 

526 Only a region whose shape actually changed takes the scan's boxes, and its 

527 bitmaps are then cut again from those boxes, because a bitmap cut to the old 

528 boundary depicts a region that is no longer an element. A region the two sides 

529 already agree on keeps the box and bitmap the parse measured — there is nothing 

530 to correct, and replacing them would discard a good crop for no gain. 

531 

532 Returns the picture paired with its figure, for the caller to attach once 

533 every region has been reshaped. 

534 """ 

535 survivor = pictures[0] 

536 template_meta = survivor.meta 

537 dpi = survivor.image.dpi if survivor.image is not None else DEFAULT_CROP_DPI 

538 wanted_image = any(p.image is not None for p in pictures) 

539 reshaped = len(pictures) > 1 or len(figures) > 1 

540 if not reshaped: 

541 return [(survivor, figures[0])] 

542 

543 if len(pictures) > 1: 

544 logger.info( 

545 "Figure graft: page {} the scan read {} as {} figure(s); merging into {}", 

546 scan.page, 

547 ", ".join(p.self_ref for p in pictures), 

548 len(figures), 

549 survivor.self_ref, 

550 ) 

551 document.delete_items(node_items=list(pictures[1:])) 

552 if len(figures) > len(pictures): 

553 logger.info( 

554 "Figure graft: page {} picture {} covers {} figures the scan returned separately; " 

555 "splitting it into {}", 

556 scan.page, 

557 survivor.self_ref, 

558 len(figures), 

559 len(figures), 

560 ) 

561 

562 survivor.prov = [_prov(figures[0], scan.page, width, height)] 

563 survivor.image = _crop(source, scan.page, figures[0], dpi) if wanted_image else None 

564 pairs = [(survivor, figures[0])] 

565 

566 anchor: PictureItem = survivor 

567 for chart in figures[1:]: 

568 anchor = document.insert_picture( 

569 sibling=anchor, 

570 prov=_prov(chart, scan.page, width, height), 

571 image=_crop(source, scan.page, chart, dpi) if wanted_image else None, 

572 after=True, 

573 ) 

574 if template_meta is not None: 

575 anchor.meta = template_meta.model_copy(deep=True) 

576 pairs.append((anchor, chart)) 

577 return pairs 

578 

579 

580def _crop(source: Optional[Path], page: int, chart: FigureRecord, dpi: int) -> Optional[ImageRef]: 

581 """The part's own bitmap, cut from the page at the resolution the parse used. 

582 

583 Returns None when there is no source to cut from or the cut fails, so a split 

584 still happens and the part carries a box with no bitmap rather than one that 

585 depicts the wrong region. Either way the loss is logged, never silent. 

586 """ 

587 box = norm_box(chart.box) 

588 if source is None or box is None: 

589 logger.warning( 

590 "Figure graft: page {} figure {} keeps no bitmap ({}); its box is recorded", 

591 page, 

592 chart.chunk_id, 

593 "no source document to cut from" if source is None else "the scan returned no box", 

594 ) 

595 return None 

596 try: 

597 return ImageRef.from_pil(image=render_region(source, page, box, dpi), dpi=dpi) 

598 except Exception as exc: 

599 logger.warning( 

600 "Figure graft: page {} figure {} keeps no bitmap; cutting it from the page failed ({}: {})", 

601 page, 

602 chart.chunk_id, 

603 type(exc).__name__, 

604 exc, 

605 ) 

606 return None 

607 

608 

609def _attach(picture: PictureItem, chart: FigureRecord, scan: PageScan) -> None: 

610 """Carry the figure's text and the scan's identity onto one picture. 

611 

612 Both ride on `meta`. The text goes in its description field, the document 

613 model's own place for what a picture depicts, and the scan's identity goes in 

614 alongside — the meta model allows extra fields, so they serialize with the 

615 document. The picture's existing metadata, its class prediction included, is 

616 kept. The record is given this picture's reference in return. 

617 """ 

618 base = picture.meta or PictureMeta() 

619 picture.meta = base.model_copy( 

620 update={ 

621 "description": DescriptionMetaField(text=chart.text, created_by=DESCRIPTION_AUTHOR), 

622 "quber__figure_job_id": scan.job_id, 

623 "quber__figure_model": scan.model, 

624 "quber__figure_version": scan.version, 

625 "quber__figure_chunk_id": chart.chunk_id, 

626 READ_BY_FIELD: [*(getattr(base, READ_BY_FIELD, None) or []), {"reader": ADE, "produced": []}], 

627 } 

628 ) 

629 chart.picture_ref = picture.self_ref 

630 

631 

632def _prov(chart: FigureRecord, page: int, width: float, height: float) -> ProvenanceItem: 

633 """A provenance record for one figure, its box in bottom-left points.""" 

634 x1, y1, x2, y2 = norm_box(chart.box) or (0.0, 0.0, 1.0, 1.0) 

635 bbox = BoundingBox( 

636 l=x1 * width, 

637 r=x2 * width, 

638 t=(1.0 - y1) * height, 

639 b=(1.0 - y2) * height, 

640 coord_origin=CoordOrigin.BOTTOMLEFT, 

641 ) 

642 return ProvenanceItem(page_no=page, bbox=bbox, charspan=(0, 0))