table-proof-py 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ """TableProof audits PDF table cell associations."""
2
+
3
+ from .api import audit_tables
4
+ from .auditor import AuditConfig, audit_document
5
+ from .docling import DocumentFormatError, normalize_document
6
+ from .models import AuditReport, BBox, Cell, Finding, SourcePage, SourceWord, Table, TableResult
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ "AuditConfig",
12
+ "AuditReport",
13
+ "BBox",
14
+ "Cell",
15
+ "DocumentFormatError",
16
+ "Finding",
17
+ "SourcePage",
18
+ "SourceWord",
19
+ "Table",
20
+ "TableResult",
21
+ "audit_document",
22
+ "audit_tables",
23
+ "normalize_document",
24
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
table_proof/api.py ADDED
@@ -0,0 +1,65 @@
1
+ """Public audit entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .auditor import AuditConfig, audit_document
10
+ from .docling import normalize_document
11
+ from .models import AuditReport, TableResult
12
+ from .pdf import read_pdf_pages
13
+
14
+
15
+ def audit_tables(
16
+ source_pdf: str | Path,
17
+ extracted_document: Any,
18
+ *,
19
+ config: AuditConfig | None = None,
20
+ vision: bool = False,
21
+ allow_model_download: bool = False,
22
+ ) -> AuditReport:
23
+ """Audit table associations without changes to the source or extraction."""
24
+ path = Path(source_pdf)
25
+ with path.open("rb") as source:
26
+ digest = hashlib.file_digest(source, "sha256").hexdigest()
27
+ pages = read_pdf_pages(path)
28
+ tables = normalize_document(extracted_document, pages)
29
+ results = audit_document(tables, pages, config=config)
30
+ limitations = [
31
+ "No findings means that the selected checks found no error. It does not prove accuracy.",
32
+ "The geometry path depends on digital PDF text and cell coordinates.",
33
+ "Scans, multi-page tables, and ambiguous evidence need human review.",
34
+ "The tool does not certify meaning, units, or the factual accuracy of a source document.",
35
+ ]
36
+ if vision:
37
+ from .vision import VisionConfig, inspect_table
38
+
39
+ updated = []
40
+ for table, result in zip(tables, results, strict=True):
41
+ findings = result.findings + inspect_table(
42
+ path, table, config=VisionConfig(allow_download=allow_model_download)
43
+ )
44
+ status = result.status
45
+ if any(finding.severity == "error" for finding in findings):
46
+ status = "review"
47
+ elif findings:
48
+ status = "uncertain"
49
+ updated.append(
50
+ TableResult(
51
+ table_id=result.table_id,
52
+ page=result.page,
53
+ status=status,
54
+ checked_cells=result.checked_cells,
55
+ total_cells=result.total_cells,
56
+ findings=findings,
57
+ )
58
+ )
59
+ results = updated
60
+ limitations.append("Vision detector scores are not calibrated error probabilities.")
61
+ if not tables:
62
+ limitations.append("The extraction contains no tables. Coverage is unknown.")
63
+ return AuditReport(
64
+ source_name=path.name, source_sha256=digest, tables=results, limitations=limitations
65
+ )
table_proof/auditor.py ADDED
@@ -0,0 +1,521 @@
1
+ """Compare table cells with independent PDF text and geometry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ import unicodedata
8
+ from collections import Counter
9
+ from collections.abc import Mapping, Sequence
10
+ from dataclasses import dataclass, field
11
+
12
+ from .models import BBox, Cell, Finding, SourcePage, SourceWord, Status, Table, TableResult
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class AuditConfig:
17
+ """Set geometric tolerances in PDF points."""
18
+
19
+ geometry_tolerance: float = 1.0
20
+ min_word_overlap: float = 0.8
21
+
22
+ def __post_init__(self) -> None:
23
+ if not math.isfinite(self.geometry_tolerance) or self.geometry_tolerance < 0:
24
+ raise ValueError("geometry_tolerance must be finite and nonnegative.")
25
+ if not math.isfinite(self.min_word_overlap) or not 0 < self.min_word_overlap <= 1:
26
+ raise ValueError("min_word_overlap must be in (0, 1].")
27
+
28
+
29
+ @dataclass
30
+ class _CellState:
31
+ cell: Cell
32
+ words: list[SourceWord] = field(default_factory=list)
33
+ usable: bool = True
34
+ uncertain: bool = False
35
+
36
+
37
+ def _tokens(text: str) -> tuple[str, ...]:
38
+ # Preserve case, punctuation, signs, decimal points, and superscripts.
39
+ return tuple(unicodedata.normalize("NFC", text).split())
40
+
41
+
42
+ def _bracket_tokens(text: str) -> Counter[str]:
43
+ # Font changes can separate brackets and move their source reading order.
44
+ # Keep every bracket, decimal point, sign, and other punctuation character.
45
+ return Counter(re.findall(r"[()\[\]{}]|[^\s()\[\]{}]+", unicodedata.normalize("NFC", text)))
46
+
47
+
48
+ def _area(box: BBox) -> float:
49
+ return (box.x1 - box.x0) * (box.bottom - box.top)
50
+
51
+
52
+ def _overlap(inner: BBox, outer: BBox, tolerance: float = 0) -> float:
53
+ width = max(0.0, min(inner.x1, outer.x1 + tolerance) - max(inner.x0, outer.x0 - tolerance))
54
+ height = max(
55
+ 0.0, min(inner.bottom, outer.bottom + tolerance) - max(inner.top, outer.top - tolerance)
56
+ )
57
+ return width * height
58
+
59
+
60
+ def _center_inside(inner: BBox, outer: BBox, tolerance: float) -> bool:
61
+ x = (inner.x0 + inner.x1) / 2
62
+ y = (inner.top + inner.bottom) / 2
63
+ return (
64
+ outer.x0 - tolerance <= x <= outer.x1 + tolerance
65
+ and outer.top - tolerance <= y <= outer.bottom + tolerance
66
+ )
67
+
68
+
69
+ def _inside_page(box: BBox, page: SourcePage, tolerance: float) -> bool:
70
+ return (
71
+ box.x0 >= -tolerance
72
+ and box.top >= -tolerance
73
+ and box.x1 <= page.width + tolerance
74
+ and box.bottom <= page.height + tolerance
75
+ )
76
+
77
+
78
+ def _contains(outer: BBox, inner: BBox, tolerance: float) -> bool:
79
+ return (
80
+ inner.x0 >= outer.x0 - tolerance
81
+ and inner.top >= outer.top - tolerance
82
+ and inner.x1 <= outer.x1 + tolerance
83
+ and inner.bottom <= outer.bottom + tolerance
84
+ )
85
+
86
+
87
+ def _intersects(start_a: int, span_a: int, start_b: int, span_b: int) -> bool:
88
+ return max(start_a, start_b) < min(start_a + span_a, start_b + span_b)
89
+
90
+
91
+ def _finding(
92
+ table: Table,
93
+ code: str,
94
+ message: str,
95
+ *,
96
+ cell: Cell | None = None,
97
+ uncertain: bool = False,
98
+ expected: str | None = None,
99
+ observed: str | None = None,
100
+ evidence: str = "pdf_text_geometry",
101
+ ) -> Finding:
102
+ return Finding(
103
+ code=code,
104
+ severity="uncertain" if uncertain else "error",
105
+ table_id=table.id,
106
+ page=table.page,
107
+ cell_id=cell.id if cell is not None else None,
108
+ message=message,
109
+ expected_text=expected,
110
+ observed_text=observed,
111
+ bbox=cell.bbox if cell is not None else table.bbox,
112
+ evidence=evidence,
113
+ )
114
+
115
+
116
+ def _sort_cell(cell: Cell) -> tuple[int, int, int, int, str, str, tuple[float, ...]]:
117
+ box = cell.bbox
118
+ return (
119
+ cell.row,
120
+ cell.col,
121
+ cell.row_span,
122
+ cell.col_span,
123
+ cell.id,
124
+ cell.text,
125
+ (box.x0, box.top, box.x1, box.bottom) if box is not None else (),
126
+ )
127
+
128
+
129
+ def _geometry_supported(box: BBox, table: Table, page: SourcePage | None, tolerance: float) -> bool:
130
+ if page is None or page.page != table.page or table.unsupported_reason:
131
+ return False
132
+ if not _inside_page(box, page, tolerance):
133
+ return False
134
+ return table.bbox is None or (
135
+ _inside_page(table.bbox, page, tolerance) and _contains(table.bbox, box, tolerance)
136
+ )
137
+
138
+
139
+ def _structure(
140
+ table: Table, states: list[_CellState], config: AuditConfig, page: SourcePage | None
141
+ ) -> list[Finding]:
142
+ findings: list[Finding] = []
143
+ ids = Counter(state.cell.id for state in states)
144
+ for state in states:
145
+ cell = state.cell
146
+ if ids[cell.id] > 1:
147
+ findings.append(
148
+ _finding(
149
+ table,
150
+ "duplicate_cell_id",
151
+ "Cell IDs must be unique.",
152
+ cell=cell,
153
+ evidence="table_structure",
154
+ )
155
+ )
156
+ if cell.row + cell.row_span > table.num_rows or cell.col + cell.col_span > table.num_cols:
157
+ findings.append(
158
+ _finding(
159
+ table,
160
+ "invalid_cell_span",
161
+ "The cell span exceeds the table grid.",
162
+ cell=cell,
163
+ evidence="table_structure",
164
+ )
165
+ )
166
+ if cell.bbox is None:
167
+ state.usable = False
168
+ state.uncertain = True
169
+ findings.append(
170
+ _finding(
171
+ table,
172
+ "cell_bbox_missing",
173
+ "The cell has no source box.",
174
+ cell=cell,
175
+ uncertain=True,
176
+ evidence="table_structure",
177
+ )
178
+ )
179
+
180
+ for index, first in enumerate(states):
181
+ a = first.cell
182
+ for second in states[index + 1 :]:
183
+ b = second.cell
184
+ rows_overlap = _intersects(a.row, a.row_span, b.row, b.row_span)
185
+ cols_overlap = _intersects(a.col, a.col_span, b.col, b.col_span)
186
+ if rows_overlap and cols_overlap:
187
+ findings.append(
188
+ _finding(
189
+ table,
190
+ "grid_cell_overlap",
191
+ f"The grid span overlaps cell {b.id}.",
192
+ cell=a,
193
+ evidence="table_structure",
194
+ )
195
+ )
196
+ if a.bbox is None or b.bbox is None:
197
+ continue
198
+ tolerance = config.geometry_tolerance
199
+ if not all(
200
+ _geometry_supported(box, table, page, tolerance) for box in (a.bbox, b.bbox)
201
+ ):
202
+ continue
203
+ width = min(a.bbox.x1, b.bbox.x1) - max(a.bbox.x0, b.bbox.x0)
204
+ height = min(a.bbox.bottom, b.bbox.bottom) - max(a.bbox.top, b.bbox.top)
205
+ if width > tolerance and height > tolerance:
206
+ first.uncertain = second.uncertain = True
207
+ findings.append(
208
+ _finding(
209
+ table,
210
+ "cell_geometry_overlap",
211
+ f"The source box overlaps cell {b.id}.",
212
+ cell=a,
213
+ uncertain=True,
214
+ evidence="cell_geometry",
215
+ )
216
+ )
217
+ if rows_overlap and not cols_overlap:
218
+ left, right = (a, b) if a.col < b.col else (b, a)
219
+ assert left.bbox is not None and right.bbox is not None
220
+ if left.bbox.x0 >= right.bbox.x1 - tolerance:
221
+ findings.append(
222
+ _finding(
223
+ table,
224
+ "column_order_inversion",
225
+ f"The cell is physically right of later column cell {right.id}.",
226
+ cell=left,
227
+ evidence="cell_geometry",
228
+ )
229
+ )
230
+ if cols_overlap and not rows_overlap:
231
+ upper, lower = (a, b) if a.row < b.row else (b, a)
232
+ assert upper.bbox is not None and lower.bbox is not None
233
+ if upper.bbox.top >= lower.bbox.bottom - tolerance:
234
+ findings.append(
235
+ _finding(
236
+ table,
237
+ "row_order_inversion",
238
+ f"The cell is physically below later row cell {lower.id}.",
239
+ cell=upper,
240
+ evidence="cell_geometry",
241
+ )
242
+ )
243
+ return findings
244
+
245
+
246
+ def _word_text(words: Sequence[SourceWord]) -> str:
247
+ ordered = sorted(words, key=lambda word: (word.bbox.top, word.bbox.x0, word.text))
248
+ return " ".join(word.text for word in ordered)
249
+
250
+
251
+ def _bad_text(text: str) -> bool:
252
+ return any(
253
+ char == "\ufffd"
254
+ or unicodedata.category(char) in {"Co", "Cs"}
255
+ or (unicodedata.category(char) == "Cc" and not char.isspace())
256
+ for char in text
257
+ )
258
+
259
+
260
+ def _assign_words(
261
+ table: Table, page: SourcePage, states: list[_CellState], config: AuditConfig
262
+ ) -> list[Finding]:
263
+ findings: list[Finding] = []
264
+ tolerance = config.geometry_tolerance
265
+ ambiguous: set[int] = set()
266
+ unassigned: list[SourceWord] = []
267
+ seen_boxes: dict[tuple[float, float, float, float], list[int]] = {}
268
+ for word in sorted(page.words, key=lambda item: (item.bbox.top, item.bbox.x0, item.text)):
269
+ if not _tokens(word.text):
270
+ continue
271
+ if table.bbox is not None and not _overlap(word.bbox, table.bbox, tolerance):
272
+ continue
273
+ candidates: list[int] = []
274
+ touches: list[int] = []
275
+ for index, state in enumerate(states):
276
+ box = state.cell.bbox
277
+ if not state.usable or box is None:
278
+ continue
279
+ if _overlap(word.bbox, box) > 0:
280
+ touches.append(index)
281
+ if _center_inside(word.bbox, box, tolerance):
282
+ candidates.append(index)
283
+ if len(candidates) != 1:
284
+ ambiguous.update(candidates or touches)
285
+ if not candidates and not touches and table.bbox is not None:
286
+ if _center_inside(word.bbox, table.bbox, 0):
287
+ unassigned.append(word)
288
+ continue
289
+ index = candidates[0]
290
+ box = states[index].cell.bbox
291
+ assert box is not None
292
+ reliable = (
293
+ _overlap(word.bbox, box, tolerance) / _area(word.bbox) >= config.min_word_overlap
294
+ and _inside_page(word.bbox, page, tolerance)
295
+ and not _bad_text(word.text)
296
+ )
297
+ if not reliable:
298
+ ambiguous.add(index)
299
+ states[index].words.append(word)
300
+ key = (word.bbox.x0, word.bbox.top, word.bbox.x1, word.bbox.bottom)
301
+ if key in seen_boxes:
302
+ ambiguous.update(seen_boxes[key])
303
+ ambiguous.add(index)
304
+ seen_boxes.setdefault(key, []).append(index)
305
+ for index in sorted(ambiguous):
306
+ states[index].uncertain = True
307
+ findings.append(
308
+ _finding(
309
+ table,
310
+ "source_assignment_ambiguous",
311
+ "Source words cross a boundary, overlap, or have unreadable text.",
312
+ cell=states[index].cell,
313
+ uncertain=True,
314
+ )
315
+ )
316
+ if unassigned:
317
+ findings.append(
318
+ _finding(
319
+ table,
320
+ "unassigned_source_text",
321
+ "Source text in the table box has no cell assignment. Review the table extent.",
322
+ uncertain=True,
323
+ expected=_word_text(unassigned),
324
+ )
325
+ )
326
+ return findings
327
+
328
+
329
+ def _compare(table: Table, state: _CellState) -> Finding | None:
330
+ cell = state.cell
331
+ source = _word_text(state.words)
332
+ expected = _tokens(source)
333
+ observed = _tokens(cell.text)
334
+ if Counter(expected) == Counter(observed):
335
+ return None
336
+ if not expected:
337
+ return _finding(
338
+ table,
339
+ "cell_source_text_missing",
340
+ "No readable source words support the cell text.",
341
+ cell=cell,
342
+ uncertain=True,
343
+ observed=cell.text,
344
+ )
345
+ if not observed:
346
+ return _finding(
347
+ table,
348
+ "missing_cell_text",
349
+ "Source words are absent from the empty cell.",
350
+ cell=cell,
351
+ expected=source,
352
+ observed=cell.text,
353
+ )
354
+
355
+ # Token boundaries, ligatures, and line-end hyphens need a human decision.
356
+ def relaxed(text: str) -> str:
357
+ text = unicodedata.normalize("NFKC", text)
358
+ text = re.sub(r"(?<=[^\W\d_])-\s*(?=[^\W\d_])", "", text)
359
+ return "".join(text.split())
360
+
361
+ if (
362
+ relaxed(source) == relaxed(cell.text)
363
+ or _bracket_tokens(source) == _bracket_tokens(cell.text)
364
+ or _bad_text(cell.text)
365
+ ):
366
+ return _finding(
367
+ table,
368
+ "text_normalization_uncertain",
369
+ "Text differs in token boundaries, bracket order, glyphs, or hyphens.",
370
+ cell=cell,
371
+ uncertain=True,
372
+ expected=source,
373
+ observed=cell.text,
374
+ )
375
+ return _finding(
376
+ table,
377
+ "cell_text_mismatch",
378
+ "Cell tokens differ from the source words.",
379
+ cell=cell,
380
+ expected=source,
381
+ observed=cell.text,
382
+ )
383
+
384
+
385
+ def _result(table: Table, findings: list[Finding], checked: int) -> TableResult:
386
+ findings.sort(key=lambda finding: (finding.cell_id or "", finding.code, finding.message))
387
+ status: Status = "no_findings"
388
+ if any(finding.severity == "error" for finding in findings):
389
+ status = "review"
390
+ elif findings:
391
+ status = "uncertain"
392
+ return TableResult(
393
+ table_id=table.id,
394
+ page=table.page,
395
+ status=status,
396
+ checked_cells=checked,
397
+ total_cells=len(table.cells),
398
+ findings=findings,
399
+ )
400
+
401
+
402
+ def _audit_table(table: Table, pages: Mapping[int, SourcePage], config: AuditConfig) -> TableResult:
403
+ states = [_CellState(cell) for cell in sorted(table.cells, key=_sort_cell)]
404
+ page = pages.get(table.page)
405
+ findings = _structure(table, states, config, page)
406
+ if table.unsupported_reason:
407
+ findings.append(
408
+ _finding(
409
+ table,
410
+ "unsupported_table",
411
+ table.unsupported_reason,
412
+ uncertain=True,
413
+ evidence="table_structure",
414
+ )
415
+ )
416
+ return _result(table, findings, 0)
417
+ if page is None or page.page != table.page:
418
+ findings.append(
419
+ _finding(
420
+ table, "source_page_missing", "The source page is unavailable.", uncertain=True
421
+ )
422
+ )
423
+ return _result(table, findings, 0)
424
+ if not any(_tokens(word.text) for word in page.words):
425
+ findings.append(
426
+ _finding(
427
+ table,
428
+ "source_text_unavailable",
429
+ "The page has no readable source words. OCR is outside this audit.",
430
+ uncertain=True,
431
+ )
432
+ )
433
+ return _result(table, findings, 0)
434
+ if not states:
435
+ findings.append(
436
+ _finding(
437
+ table,
438
+ "no_cells",
439
+ "The extraction contains no cells.",
440
+ uncertain=True,
441
+ evidence="table_structure",
442
+ )
443
+ )
444
+ tolerance = config.geometry_tolerance
445
+ if table.bbox is None:
446
+ findings.append(
447
+ _finding(
448
+ table,
449
+ "table_bbox_missing",
450
+ "The table has no source box.",
451
+ uncertain=True,
452
+ evidence="table_structure",
453
+ )
454
+ )
455
+ elif not _inside_page(table.bbox, page, tolerance):
456
+ findings.append(
457
+ _finding(
458
+ table,
459
+ "table_outside_page",
460
+ "The table box exceeds the source page.",
461
+ uncertain=True,
462
+ evidence="cell_geometry",
463
+ )
464
+ )
465
+ return _result(table, findings, 0)
466
+ for state in states:
467
+ box = state.cell.bbox
468
+ if box is None:
469
+ continue
470
+ if not _inside_page(box, page, tolerance):
471
+ code, message = "cell_outside_page", "The cell box exceeds the source page."
472
+ elif table.bbox is not None and not _contains(table.bbox, box, tolerance):
473
+ code, message = "cell_outside_table", "The cell box exceeds the table box."
474
+ else:
475
+ continue
476
+ state.usable = False
477
+ findings.append(
478
+ _finding(
479
+ table, code, message, cell=state.cell, uncertain=True, evidence="cell_geometry"
480
+ )
481
+ )
482
+ scope = [table.bbox] if table.bbox is not None else [state.cell.bbox for state in states]
483
+ if not any(
484
+ _tokens(word.text)
485
+ and any(box is not None and _overlap(word.bbox, box, tolerance) > 0 for box in scope)
486
+ for word in page.words
487
+ ):
488
+ findings.append(
489
+ _finding(
490
+ table,
491
+ "table_source_text_unavailable",
492
+ "The table region has no source words. Its content is unknown.",
493
+ uncertain=True,
494
+ )
495
+ )
496
+ return _result(table, findings, 0)
497
+ findings.extend(_assign_words(table, page, states, config))
498
+ checked = 0
499
+ for state in states:
500
+ if not state.usable or state.uncertain:
501
+ continue
502
+ finding = _compare(table, state)
503
+ if finding is not None:
504
+ findings.append(finding)
505
+ if finding is None or finding.severity == "error":
506
+ checked += 1
507
+ return _result(table, findings, checked)
508
+
509
+
510
+ def audit_document(
511
+ tables: Sequence[Table],
512
+ pages: Mapping[int, SourcePage],
513
+ config: AuditConfig | None = None,
514
+ ) -> list[TableResult]:
515
+ """Audit supplied tables without changing the inputs.
516
+
517
+ A result with no findings covers only the implemented checks. It does not
518
+ prove semantic correctness, reading order, or complete table extraction.
519
+ """
520
+ options = config if config is not None else AuditConfig()
521
+ return [_audit_table(table, pages, options) for table in tables]
table_proof/cli.py ADDED
@@ -0,0 +1,68 @@
1
+ """Command-line audit interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .api import audit_tables
12
+
13
+
14
+ def _same_file(first: Path, second: Path) -> bool:
15
+ return first.resolve() == second.resolve() or (
16
+ first.exists() and second.exists() and first.samefile(second)
17
+ )
18
+
19
+
20
+ def main(argv: list[str] | None = None) -> int:
21
+ parser = argparse.ArgumentParser(description="Audit PDF table cell associations.")
22
+ parser.add_argument("--version", action="version", version=__version__)
23
+ parser.add_argument("source_pdf", type=Path, help="Original digital PDF.")
24
+ parser.add_argument("extraction", type=Path, help="Docling JSON export.")
25
+ parser.add_argument("--json", dest="json_path", type=Path, help="Write the JSON report.")
26
+ parser.add_argument("--html", dest="html_path", type=Path, help="Write an HTML report.")
27
+ parser.add_argument("--vision", action="store_true", help="Use the optional vision model.")
28
+ parser.add_argument(
29
+ "--allow-model-download", action="store_true", help="Allow the pinned model download."
30
+ )
31
+ args = parser.parse_args(argv)
32
+ if args.allow_model_download and not args.vision:
33
+ parser.error("--allow-model-download needs --vision.")
34
+ try:
35
+ inputs = (args.source_pdf, args.extraction)
36
+ outputs = [path for path in (args.json_path, args.html_path) if path is not None]
37
+ if any(_same_file(output, source) for output in outputs for source in inputs):
38
+ raise ValueError("A report path must not replace an input file.")
39
+ if len(outputs) == 2 and _same_file(outputs[0], outputs[1]):
40
+ raise ValueError("The JSON and HTML reports need different paths.")
41
+ document = json.loads(args.extraction.read_text(encoding="utf-8"))
42
+ report = audit_tables(
43
+ args.source_pdf,
44
+ document,
45
+ vision=args.vision,
46
+ allow_model_download=args.allow_model_download,
47
+ )
48
+ if args.json_path:
49
+ report.export_json(str(args.json_path))
50
+ if args.html_path:
51
+ report.export_html(str(args.html_path), source_pdf=str(args.source_pdf))
52
+ print(
53
+ json.dumps(
54
+ {
55
+ "status": report.status,
56
+ "tables": len(report.tables),
57
+ "findings": sum(len(table.findings) for table in report.tables),
58
+ }
59
+ )
60
+ )
61
+ return {"no_findings": 0, "review": 1, "uncertain": 3}[report.status]
62
+ except Exception as error:
63
+ print(json.dumps({"status": "error", "message": str(error)}), file=sys.stderr)
64
+ return 2
65
+
66
+
67
+ if __name__ == "__main__":
68
+ raise SystemExit(main())