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
« 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.
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.
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.
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.
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:
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.
31Figures and pictures are grouped by overlap before anything is assigned, so the
32order the figures arrive in never decides an outcome.
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.
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.
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.
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.
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"""
58from __future__ import annotations
60from pathlib import Path
61from typing import Collection, Dict, List, Optional, Tuple
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
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
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
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
90#: What the picture's description records as its author.
91DESCRIPTION_AUTHOR = "quber-chart-scan"
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"
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"
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"
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
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`.
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.
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.
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.
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)
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)
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)
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 )
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
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.
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.
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.
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}
212 errors: List[str] = []
213 by_picture = {p.self_ref: p for p in document.pictures}
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
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.
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.
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.
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)
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
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])
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.
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.
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)
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.
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.
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.
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)
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.
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.
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)}
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)
424 unmatched = [chart for i, chart in enumerate(figures) if i not in under]
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
458def _reading_order(figures: List[FigureRecord]) -> List[FigureRecord]:
459 """Figures in the order the page prints them: down the page, then across."""
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])
465 return sorted(figures, key=key)
468def _is_furniture(pictures: List[PictureItem]) -> bool:
469 """True when the parse called every picture in the region page furniture.
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)
481def _discard(scan: PageScan, figures: List[FigureRecord], pictures: List[PictureItem]) -> None:
482 """Drop the records for a region the parse called page furniture.
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"
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.
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.
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.
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.
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])]
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 )
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])]
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
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.
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
609def _attach(picture: PictureItem, chart: FigureRecord, scan: PageScan) -> None:
610 """Carry the figure's text and the scan's identity onto one picture.
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
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))