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.
- table_proof/__init__.py +24 -0
- table_proof/__main__.py +3 -0
- table_proof/api.py +65 -0
- table_proof/auditor.py +521 -0
- table_proof/cli.py +68 -0
- table_proof/docling.py +168 -0
- table_proof/models.py +111 -0
- table_proof/pdf.py +28 -0
- table_proof/py.typed +0 -0
- table_proof/report.py +217 -0
- table_proof/vision.py +381 -0
- table_proof_py-0.1.0.dist-info/METADATA +177 -0
- table_proof_py-0.1.0.dist-info/RECORD +16 -0
- table_proof_py-0.1.0.dist-info/WHEEL +4 -0
- table_proof_py-0.1.0.dist-info/entry_points.txt +2 -0
- table_proof_py-0.1.0.dist-info/licenses/LICENSE +21 -0
table_proof/vision.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Optional, local Table Transformer evidence for PDF table structure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import math
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from time import perf_counter
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
from .models import BBox, Cell, Finding, Table
|
|
14
|
+
|
|
15
|
+
MODEL_ID = "microsoft/table-transformer-structure-recognition-v1.1-all"
|
|
16
|
+
MODEL_REVISION = "7587a7ef111d9dcbf8ac695f1376ab7014340a0c"
|
|
17
|
+
EVIDENCE = f"table_transformer:{MODEL_ID}@{MODEL_REVISION}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class VisionConfig:
|
|
22
|
+
"""Model files stay local unless the caller explicitly permits download."""
|
|
23
|
+
|
|
24
|
+
allow_download: bool = False
|
|
25
|
+
cache_dir: Path | None = None
|
|
26
|
+
dpi: int = 144
|
|
27
|
+
padding: float = 8.0
|
|
28
|
+
score_threshold: float = 0.8
|
|
29
|
+
uncertainty_threshold: float = 0.35
|
|
30
|
+
max_pixels: int = 12_000_000
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if not 72 <= self.dpi <= 300:
|
|
34
|
+
raise ValueError("dpi must be between 72 and 300.")
|
|
35
|
+
if not math.isfinite(self.padding) or self.padding < 0:
|
|
36
|
+
raise ValueError("padding must be finite and nonnegative.")
|
|
37
|
+
if not 0 < self.uncertainty_threshold < self.score_threshold < 1:
|
|
38
|
+
raise ValueError("Require 0 < uncertainty_threshold < score_threshold < 1.")
|
|
39
|
+
if self.max_pixels < 1:
|
|
40
|
+
raise ValueError("max_pixels must be positive.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class VisionRegion:
|
|
45
|
+
label: str
|
|
46
|
+
score: float
|
|
47
|
+
bbox: BBox
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class VisionResult:
|
|
52
|
+
regions: tuple[VisionRegion, ...]
|
|
53
|
+
crop_bbox: BBox
|
|
54
|
+
image_width: int
|
|
55
|
+
image_height: int
|
|
56
|
+
inference_seconds: float
|
|
57
|
+
model_id: str = MODEL_ID
|
|
58
|
+
model_revision: str = MODEL_REVISION
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pixel_box_to_pdf(box: list[float], crop: BBox, image_width: int, image_height: int) -> BBox:
|
|
62
|
+
"""Map crop pixels to top-left PDF points using actual rendered dimensions."""
|
|
63
|
+
if len(box) != 4 or not all(math.isfinite(value) for value in box):
|
|
64
|
+
raise ValueError("A pixel box needs four finite values.")
|
|
65
|
+
if image_width <= 0 or image_height <= 0:
|
|
66
|
+
raise ValueError("Image dimensions must be positive.")
|
|
67
|
+
x0, y0, x1, y1 = box
|
|
68
|
+
sx = (crop.x1 - crop.x0) / image_width
|
|
69
|
+
sy = (crop.bottom - crop.top) / image_height
|
|
70
|
+
return BBox(
|
|
71
|
+
x0=crop.x0 + max(0.0, min(float(image_width), x0)) * sx,
|
|
72
|
+
top=crop.top + max(0.0, min(float(image_height), y0)) * sy,
|
|
73
|
+
x1=crop.x0 + max(0.0, min(float(image_width), x1)) * sx,
|
|
74
|
+
bottom=crop.top + max(0.0, min(float(image_height), y1)) * sy,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@lru_cache(maxsize=2)
|
|
79
|
+
def _load_model(allow_download: bool, cache_dir: str | None) -> tuple[Any, Any, Any]:
|
|
80
|
+
# Lazy imports keep the normal audit and its installation free of torch.
|
|
81
|
+
torch = importlib.import_module("torch")
|
|
82
|
+
transformers = importlib.import_module("transformers")
|
|
83
|
+
kwargs = {
|
|
84
|
+
"revision": MODEL_REVISION,
|
|
85
|
+
"cache_dir": cache_dir,
|
|
86
|
+
"local_files_only": not allow_download,
|
|
87
|
+
"trust_remote_code": False,
|
|
88
|
+
}
|
|
89
|
+
# The checkpoint stores only longest_edge. DetrImageProcessor 4.x requires
|
|
90
|
+
# both keys; equal limits preserve aspect ratio with a 1000-pixel long side.
|
|
91
|
+
processor = transformers.AutoImageProcessor.from_pretrained(
|
|
92
|
+
MODEL_ID,
|
|
93
|
+
use_fast=False,
|
|
94
|
+
size={"shortest_edge": 1000, "longest_edge": 1000},
|
|
95
|
+
**kwargs,
|
|
96
|
+
)
|
|
97
|
+
model = transformers.TableTransformerForObjectDetection.from_pretrained(
|
|
98
|
+
MODEL_ID,
|
|
99
|
+
use_safetensors=True,
|
|
100
|
+
use_pretrained_backbone=False,
|
|
101
|
+
**kwargs,
|
|
102
|
+
)
|
|
103
|
+
model.to("cpu")
|
|
104
|
+
model.eval()
|
|
105
|
+
return torch, processor, model
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _render_crop(source_pdf: Path, table: Table, config: VisionConfig) -> tuple[Any, BBox]:
|
|
109
|
+
import pdfplumber
|
|
110
|
+
|
|
111
|
+
if table.bbox is None:
|
|
112
|
+
raise ValueError("A source table bounding box is required.")
|
|
113
|
+
with pdfplumber.open(source_pdf) as pdf:
|
|
114
|
+
if table.page > len(pdf.pages):
|
|
115
|
+
raise ValueError("The source page does not exist.")
|
|
116
|
+
page = pdf.pages[table.page - 1]
|
|
117
|
+
if page.rotation:
|
|
118
|
+
raise ValueError("Rotated pages are outside the vision scope.")
|
|
119
|
+
px0, py0, _, _ = page.bbox
|
|
120
|
+
box = table.bbox
|
|
121
|
+
if box.x0 < 0 or box.top < 0 or box.x1 > page.width or box.bottom > page.height:
|
|
122
|
+
raise ValueError("The table box is outside the source page.")
|
|
123
|
+
# Normalized Table boxes start at (0, 0), even if MediaBox does not.
|
|
124
|
+
# Keep returned coordinates normalized; translate only the renderer input.
|
|
125
|
+
crop = BBox(
|
|
126
|
+
x0=max(0.0, box.x0 - config.padding),
|
|
127
|
+
top=max(0.0, box.top - config.padding),
|
|
128
|
+
x1=min(page.width, box.x1 + config.padding),
|
|
129
|
+
bottom=min(page.height, box.bottom + config.padding),
|
|
130
|
+
)
|
|
131
|
+
# pdfplumber renders the page before cropping it.
|
|
132
|
+
page_pixels = page.width * page.height * (config.dpi / 72) ** 2
|
|
133
|
+
if page_pixels > config.max_pixels:
|
|
134
|
+
raise ValueError("The page exceeds the configured render pixel limit.")
|
|
135
|
+
cropped_page = page.crop((crop.x0 + px0, crop.top + py0, crop.x1 + px0, crop.bottom + py0))
|
|
136
|
+
if not cropped_page.chars:
|
|
137
|
+
raise ValueError("The table crop has no digital text; scanned tables are unsupported.")
|
|
138
|
+
image = cropped_page.to_image(resolution=config.dpi, antialias=True).original.convert("RGB")
|
|
139
|
+
return image, crop
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def detect_structure(
|
|
143
|
+
source_pdf: Path, table: Table, *, config: VisionConfig | None = None
|
|
144
|
+
) -> VisionResult:
|
|
145
|
+
"""Run the pretrained model on source pixels, without extracted cell text."""
|
|
146
|
+
selected = config or VisionConfig()
|
|
147
|
+
image, crop = _render_crop(source_pdf, table, selected)
|
|
148
|
+
torch, processor, model = _load_model(
|
|
149
|
+
selected.allow_download, str(selected.cache_dir) if selected.cache_dir else None
|
|
150
|
+
)
|
|
151
|
+
start = perf_counter()
|
|
152
|
+
inputs = processor(images=image, return_tensors="pt")
|
|
153
|
+
with torch.inference_mode():
|
|
154
|
+
outputs = model(**inputs)
|
|
155
|
+
result = processor.post_process_object_detection(
|
|
156
|
+
outputs,
|
|
157
|
+
threshold=selected.uncertainty_threshold,
|
|
158
|
+
target_sizes=torch.tensor([[image.height, image.width]]),
|
|
159
|
+
)[0]
|
|
160
|
+
regions = []
|
|
161
|
+
for score, label, raw_box in zip(
|
|
162
|
+
result["scores"].tolist(),
|
|
163
|
+
result["labels"].tolist(),
|
|
164
|
+
result["boxes"].tolist(),
|
|
165
|
+
strict=True,
|
|
166
|
+
):
|
|
167
|
+
try:
|
|
168
|
+
bbox = pixel_box_to_pdf(raw_box, crop, image.width, image.height)
|
|
169
|
+
except ValueError:
|
|
170
|
+
continue # Degenerate or entirely off-crop predictions carry no evidence.
|
|
171
|
+
regions.append(VisionRegion(str(model.config.id2label[label]), float(score), bbox))
|
|
172
|
+
return VisionResult(
|
|
173
|
+
regions=tuple(regions),
|
|
174
|
+
crop_bbox=crop,
|
|
175
|
+
image_width=image.width,
|
|
176
|
+
image_height=image.height,
|
|
177
|
+
inference_seconds=perf_counter() - start,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _finding(
|
|
182
|
+
table: Table,
|
|
183
|
+
code: str,
|
|
184
|
+
message: str,
|
|
185
|
+
*,
|
|
186
|
+
cell: Cell | None = None,
|
|
187
|
+
severity: Literal["error", "uncertain"] = "uncertain",
|
|
188
|
+
expected: str | None = None,
|
|
189
|
+
observed: str | None = None,
|
|
190
|
+
) -> Finding:
|
|
191
|
+
return Finding(
|
|
192
|
+
code=code,
|
|
193
|
+
severity=severity,
|
|
194
|
+
table_id=table.id,
|
|
195
|
+
page=table.page,
|
|
196
|
+
cell_id=cell.id if cell else None,
|
|
197
|
+
bbox=cell.bbox if cell else table.bbox,
|
|
198
|
+
message=message,
|
|
199
|
+
expected_text=expected,
|
|
200
|
+
observed_text=observed,
|
|
201
|
+
evidence=EVIDENCE,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _interval(region: VisionRegion, axis: Literal["row", "col"]) -> tuple[float, float]:
|
|
206
|
+
box = region.bbox
|
|
207
|
+
return (box.top, box.bottom) if axis == "row" else (box.x0, box.x1)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _ambiguous_axis(regions: list[VisionRegion], axis: Literal["row", "col"]) -> bool:
|
|
211
|
+
for left, right in zip(regions, regions[1:], strict=False):
|
|
212
|
+
a, b = _interval(left, axis)
|
|
213
|
+
c, d = _interval(right, axis)
|
|
214
|
+
if max(0.0, min(b, d) - max(a, c)) > 0.2 * min(b - a, d - c):
|
|
215
|
+
return True
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _contains(box: BBox, x: float, y: float) -> bool:
|
|
220
|
+
return box.x0 <= x <= box.x1 and box.top <= y <= box.bottom
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _locate(point: float, regions: list[VisionRegion], axis: Literal["row", "col"]) -> list[int]:
|
|
224
|
+
return [
|
|
225
|
+
i
|
|
226
|
+
for i, region in enumerate(regions)
|
|
227
|
+
if _interval(region, axis)[0] < point < _interval(region, axis)[1]
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def compare_structure(
|
|
232
|
+
table: Table, result: VisionResult, *, config: VisionConfig | None = None
|
|
233
|
+
) -> list[Finding]:
|
|
234
|
+
"""Compare model regions with exported cell indices; abstain on ambiguous grids."""
|
|
235
|
+
selected = config or VisionConfig()
|
|
236
|
+
strong = [r for r in result.regions if r.score >= selected.score_threshold]
|
|
237
|
+
weak = [
|
|
238
|
+
r
|
|
239
|
+
for r in result.regions
|
|
240
|
+
if selected.uncertainty_threshold <= r.score < selected.score_threshold
|
|
241
|
+
]
|
|
242
|
+
rows = sorted((r for r in strong if r.label == "table row"), key=lambda r: r.bbox.top)
|
|
243
|
+
cols = sorted((r for r in strong if r.label == "table column"), key=lambda r: r.bbox.x0)
|
|
244
|
+
if (
|
|
245
|
+
not rows
|
|
246
|
+
or not cols
|
|
247
|
+
or len(rows) != table.num_rows
|
|
248
|
+
or len(cols) != table.num_cols
|
|
249
|
+
or any(r.label in {"table row", "table column"} for r in weak)
|
|
250
|
+
or _ambiguous_axis(rows, "row")
|
|
251
|
+
or _ambiguous_axis(cols, "col")
|
|
252
|
+
or len([r for r in strong if r.label == "table"]) != 1
|
|
253
|
+
):
|
|
254
|
+
return [
|
|
255
|
+
_finding(
|
|
256
|
+
table,
|
|
257
|
+
"vision_grid_uncertain",
|
|
258
|
+
"The model grid is ambiguous or differs in size. Review the source table.",
|
|
259
|
+
expected=f"model rows={len(rows)}, columns={len(cols)}",
|
|
260
|
+
observed=f"export rows={table.num_rows}, columns={table.num_cols}",
|
|
261
|
+
)
|
|
262
|
+
]
|
|
263
|
+
headers = [r for r in strong if r.label == "table column header"]
|
|
264
|
+
spans = [r for r in strong if r.label == "table spanning cell"]
|
|
265
|
+
findings: list[Finding] = []
|
|
266
|
+
for cell in table.cells:
|
|
267
|
+
if cell.bbox is None:
|
|
268
|
+
findings.append(
|
|
269
|
+
_finding(
|
|
270
|
+
table,
|
|
271
|
+
"vision_cell_uncertain",
|
|
272
|
+
"The cell has no source bounding box.",
|
|
273
|
+
cell=cell,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
continue
|
|
277
|
+
x = (cell.bbox.x0 + cell.bbox.x1) / 2
|
|
278
|
+
y = (cell.bbox.top + cell.bbox.bottom) / 2
|
|
279
|
+
matching_spans = [r for r in spans if _contains(r.bbox, x, y)]
|
|
280
|
+
if len(matching_spans) > 1 or any(
|
|
281
|
+
r.label == "table spanning cell" and _contains(r.bbox, x, y) for r in weak
|
|
282
|
+
):
|
|
283
|
+
findings.append(
|
|
284
|
+
_finding(
|
|
285
|
+
table,
|
|
286
|
+
"vision_cell_uncertain",
|
|
287
|
+
"Spanning-cell evidence is ambiguous.",
|
|
288
|
+
cell=cell,
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
continue
|
|
292
|
+
if matching_spans:
|
|
293
|
+
span = matching_spans[0].bbox
|
|
294
|
+
ri = [
|
|
295
|
+
i
|
|
296
|
+
for i, r in enumerate(rows)
|
|
297
|
+
if span.top < (r.bbox.top + r.bbox.bottom) / 2 < span.bottom
|
|
298
|
+
]
|
|
299
|
+
ci = [i for i, r in enumerate(cols) if span.x0 < (r.bbox.x0 + r.bbox.x1) / 2 < span.x1]
|
|
300
|
+
else:
|
|
301
|
+
ri, ci = _locate(y, rows, "row"), _locate(x, cols, "col")
|
|
302
|
+
if cell.row_span > 1 or cell.col_span > 1:
|
|
303
|
+
findings.append(
|
|
304
|
+
_finding(
|
|
305
|
+
table,
|
|
306
|
+
"vision_cell_uncertain",
|
|
307
|
+
"The model does not establish this merged cell.",
|
|
308
|
+
cell=cell,
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
continue
|
|
312
|
+
if not ri or not ci or (not matching_spans and (len(ri) != 1 or len(ci) != 1)):
|
|
313
|
+
findings.append(
|
|
314
|
+
_finding(
|
|
315
|
+
table,
|
|
316
|
+
"vision_cell_uncertain",
|
|
317
|
+
"The cell does not map to a unique model region.",
|
|
318
|
+
cell=cell,
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
continue
|
|
322
|
+
source = (ri[0], ci[0], len(ri), len(ci))
|
|
323
|
+
exported = (cell.row, cell.col, cell.row_span, cell.col_span)
|
|
324
|
+
if source != exported:
|
|
325
|
+
findings.append(
|
|
326
|
+
_finding(
|
|
327
|
+
table,
|
|
328
|
+
"vision_cell_relation_mismatch",
|
|
329
|
+
"Source-image regions disagree with the exported cell position or span.",
|
|
330
|
+
cell=cell,
|
|
331
|
+
severity="error",
|
|
332
|
+
expected=str(source),
|
|
333
|
+
observed=str(exported),
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
# Projected row headers are not ordinary row headers. Do not equate them.
|
|
337
|
+
if headers and not any(r.label == "table column header" for r in weak):
|
|
338
|
+
is_header = any(_contains(r.bbox, x, y) for r in headers)
|
|
339
|
+
if is_header != cell.column_header:
|
|
340
|
+
findings.append(
|
|
341
|
+
_finding(
|
|
342
|
+
table,
|
|
343
|
+
"vision_column_header_mismatch",
|
|
344
|
+
"Source-image header regions disagree with the exported header flag.",
|
|
345
|
+
cell=cell,
|
|
346
|
+
severity="error",
|
|
347
|
+
expected=str(is_header),
|
|
348
|
+
observed=str(cell.column_header),
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
elif cell.column_header:
|
|
352
|
+
findings.append(
|
|
353
|
+
_finding(
|
|
354
|
+
table,
|
|
355
|
+
"vision_header_uncertain",
|
|
356
|
+
"The model does not establish the column header.",
|
|
357
|
+
cell=cell,
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
return findings
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def inspect_table(
|
|
364
|
+
source_pdf: Path, table: Table, *, config: VisionConfig | None = None
|
|
365
|
+
) -> list[Finding]:
|
|
366
|
+
"""Return review findings. Missing models and unsupported inputs are uncertain."""
|
|
367
|
+
if table.unsupported_reason:
|
|
368
|
+
return [_finding(table, "vision_unsupported", table.unsupported_reason)]
|
|
369
|
+
if not table.cells or table.num_rows == 0 or table.num_cols == 0:
|
|
370
|
+
return [_finding(table, "vision_unsupported", "The exported table is empty.")]
|
|
371
|
+
try:
|
|
372
|
+
result = detect_structure(source_pdf, table, config=config)
|
|
373
|
+
except (ImportError, OSError, RuntimeError, ValueError) as exc:
|
|
374
|
+
return [
|
|
375
|
+
_finding(
|
|
376
|
+
table,
|
|
377
|
+
"vision_unavailable",
|
|
378
|
+
f"Vision inspection did not complete: {type(exc).__name__}: {str(exc)[:240]}",
|
|
379
|
+
)
|
|
380
|
+
]
|
|
381
|
+
return compare_structure(table, result, config=config)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: table-proof-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Audit PDF table cell associations against source evidence
|
|
5
|
+
Project-URL: Homepage, https://github.com/eduard-s/table-proof
|
|
6
|
+
Project-URL: Repository, https://github.com/eduard-s/table-proof
|
|
7
|
+
Project-URL: Issues, https://github.com/eduard-s/table-proof/issues
|
|
8
|
+
Author: Eduard Smirnov
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: pdfplumber<0.12,>=0.11.4
|
|
20
|
+
Requires-Dist: pydantic<3,>=2.11
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build<2,>=1; extra == 'dev'
|
|
23
|
+
Requires-Dist: docling-core<3,>=2.48; extra == 'dev'
|
|
24
|
+
Requires-Dist: hypothesis<7,>=6; extra == 'dev'
|
|
25
|
+
Requires-Dist: jsonschema<5,>=4; extra == 'dev'
|
|
26
|
+
Requires-Dist: mypy<2,>=1.15; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov<8,>=6; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest<10,>=8; extra == 'dev'
|
|
29
|
+
Requires-Dist: reportlab<5,>=4; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff<1,>=0.14; extra == 'dev'
|
|
31
|
+
Requires-Dist: twine<7,>=6; extra == 'dev'
|
|
32
|
+
Provides-Extra: vision
|
|
33
|
+
Requires-Dist: timm<2,>=1; extra == 'vision'
|
|
34
|
+
Requires-Dist: torch>=2.6; extra == 'vision'
|
|
35
|
+
Requires-Dist: transformers<5,>=4.50; extra == 'vision'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# TableProof
|
|
39
|
+
|
|
40
|
+
[](https://github.com/eduard-s/table-proof/actions/workflows/ci.yml)
|
|
41
|
+
|
|
42
|
+
TableProof finds suspected errors in extracted PDF tables. It compares cell text and row or column positions with source evidence.
|
|
43
|
+
|
|
44
|
+
Every number can survive extraction while its row label changes. TableProof reports the disputed cell and its original PDF region.
|
|
45
|
+
|
|
46
|
+
This is an alpha Python library. It needs Python 3.11 or later.
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
python -m pip install table-proof-py
|
|
52
|
+
table-proof source.pdf docling-export.json --html report.html --json report.json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`docling-export.json` is a Docling document export with table cells and source coordinates.
|
|
56
|
+
|
|
57
|
+
For the optional local vision model, install the extra dependencies:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
python -m pip install 'table-proof-py[vision]'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Try the real regression case
|
|
64
|
+
|
|
65
|
+
The recorded regression pair contains this error:
|
|
66
|
+
|
|
67
|
+
| Source row | Affected extracted row | Result |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| `BBB → 20.0 mg` | `CCC → 20.0 mg` | Row label differs from the source region. |
|
|
70
|
+
| `CCC → 30.0 mg` | `BBB → 30.0 mg` | Row label differs from the source region. |
|
|
71
|
+
|
|
72
|
+
The regression fixtures are in the source repository. Run these commands:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
git clone https://github.com/eduard-s/table-proof.git
|
|
76
|
+
cd table-proof
|
|
77
|
+
python3 -m venv .venv
|
|
78
|
+
.venv/bin/python -m pip install -e .
|
|
79
|
+
.venv/bin/table-proof \
|
|
80
|
+
benchmarks/fixtures/docling_5198/source.pdf \
|
|
81
|
+
benchmarks/fixtures/docling_5198/affected.json \
|
|
82
|
+
--html report.html --json report.json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The command exits with code `1`. It reports two swapped row labels in the affected fixture.
|
|
86
|
+
|
|
87
|
+
Replace `affected.json` with `fixed.json` to examine the paired control. That command exits with code `0`.
|
|
88
|
+
|
|
89
|
+
The fixture reproduces a real Docling defect on a generated PDF. It does not represent an independent production document.
|
|
90
|
+
|
|
91
|
+
Read the [benchmark results](https://github.com/eduard-s/table-proof/blob/main/benchmarks/results.md) and [fixture provenance](https://github.com/eduard-s/table-proof/blob/main/benchmarks/README.md).
|
|
92
|
+
|
|
93
|
+
## Python API
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import json
|
|
97
|
+
from pathlib import Path
|
|
98
|
+
|
|
99
|
+
from table_proof import audit_tables
|
|
100
|
+
|
|
101
|
+
fixture = Path("benchmarks/fixtures/docling_5198")
|
|
102
|
+
document = json.loads((fixture / "affected.json").read_text())
|
|
103
|
+
report = audit_tables(fixture / "source.pdf", document)
|
|
104
|
+
|
|
105
|
+
print(report.status)
|
|
106
|
+
report.export_json("report.json")
|
|
107
|
+
report.export_html("report.html", source_pdf=str(fixture / "source.pdf"))
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The adapter accepts Docling JSON, a `DoclingDocument`, or a conversion result with a `document` field. Docling is optional at runtime.
|
|
111
|
+
|
|
112
|
+
Rich cells can contain text references. The adapter resolves the references that control the rendered table. It does not trust stale cell text.
|
|
113
|
+
|
|
114
|
+
## What the MVP does
|
|
115
|
+
|
|
116
|
+
- Compares cell tokens with PDF words in the cell region.
|
|
117
|
+
- Detects reversed row or column positions and invalid cell spans.
|
|
118
|
+
- Reports missing source text, incomplete geometry, and ambiguous word assignments.
|
|
119
|
+
- Produces JSON findings and local HTML evidence.
|
|
120
|
+
- Preserves the source PDF and extraction.
|
|
121
|
+
- Supports an optional, pinned Table Transformer model for independent structure evidence.
|
|
122
|
+
|
|
123
|
+
The default path uses PDF text and geometry. It makes no network requests.
|
|
124
|
+
|
|
125
|
+
The optional model runs locally. It needs an explicit download option before its first use. Read the [vision guide](https://github.com/eduard-s/table-proof/blob/main/docs/VISION.md).
|
|
126
|
+
|
|
127
|
+
## Results and limits
|
|
128
|
+
|
|
129
|
+
| Status | Meaning | CLI exit code |
|
|
130
|
+
| --- | --- | --- |
|
|
131
|
+
| `no_findings` | The selected checks found no error in the supplied evidence. | `0` |
|
|
132
|
+
| `review` | At least one check found a suspected error. | `1` |
|
|
133
|
+
| `uncertain` | Evidence is missing, ambiguous, or unsupported. | `3` |
|
|
134
|
+
| `error` | The command did not process the input. | `2` |
|
|
135
|
+
|
|
136
|
+
`no_findings` does not prove semantic accuracy. An empty table list returns `uncertain`.
|
|
137
|
+
|
|
138
|
+
Start with digital PDFs and tables that occupy one page. Scans and multi-page tables need other methods.
|
|
139
|
+
|
|
140
|
+
The tool does not certify units or source facts. It does not repair data automatically. Detector scores are not calibrated error probabilities.
|
|
141
|
+
|
|
142
|
+
The [small development benchmark](https://github.com/eduard-s/table-proof/blob/main/benchmarks/results.md) compares TableProof with local controls and pinned upstream pdfmux code.
|
|
143
|
+
Its cases establish a reproducible regression result. They do not establish broad superiority on real documents.
|
|
144
|
+
|
|
145
|
+
On the included public paper, TableProof flags six missing headers. It returns uncertainty for a clipped superscript in another parser output.
|
|
146
|
+
|
|
147
|
+
The optional vision model recovers the table grid but adds no error detection on that public example.
|
|
148
|
+
Read the [recorded model results](https://github.com/eduard-s/table-proof/blob/main/docs/VISION.md) before a model accuracy claim.
|
|
149
|
+
|
|
150
|
+
## Development
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
.venv/bin/python -m pip install -e '.[dev]'
|
|
154
|
+
.venv/bin/ruff check .
|
|
155
|
+
.venv/bin/ruff format --check .
|
|
156
|
+
.venv/bin/mypy src/table_proof
|
|
157
|
+
.venv/bin/pytest --cov=table_proof --cov-report=term-missing
|
|
158
|
+
.venv/bin/python -m build
|
|
159
|
+
.venv/bin/python -m twine check dist/*
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Use Python 3.12 for the type analysis. CI runs tests on Python 3.11 through 3.14.
|
|
163
|
+
|
|
164
|
+
## Documents
|
|
165
|
+
|
|
166
|
+
- [MVP plan and acceptance criteria](https://github.com/eduard-s/table-proof/blob/main/docs/MVP_PLAN.md)
|
|
167
|
+
- [Architecture and Python stack](https://github.com/eduard-s/table-proof/blob/main/docs/ARCHITECTURE.md)
|
|
168
|
+
- [API and report contract](https://github.com/eduard-s/table-proof/blob/main/docs/API.md)
|
|
169
|
+
- [Datasets and evaluation plan](https://github.com/eduard-s/table-proof/blob/main/docs/DATASETS.md)
|
|
170
|
+
- [Benchmark reproduction](https://github.com/eduard-s/table-proof/blob/main/benchmarks/README.md)
|
|
171
|
+
- [Optional vision model](https://github.com/eduard-s/table-proof/blob/main/docs/VISION.md)
|
|
172
|
+
- [Development and contribution](https://github.com/eduard-s/table-proof/blob/main/CONTRIBUTING.md)
|
|
173
|
+
- [Release instructions](https://github.com/eduard-s/table-proof/blob/main/PUBLISHING.md)
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
MIT. See [LICENSE](https://github.com/eduard-s/table-proof/blob/main/LICENSE) and [third-party notices](https://github.com/eduard-s/table-proof/blob/main/THIRD_PARTY.md).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
table_proof/__init__.py,sha256=sNjEZp_E7Tb1gv657BPghwYMArnn1LioTLx47UrlC8Q,569
|
|
2
|
+
table_proof/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
table_proof/api.py,sha256=CFeP_kkCaO4OD3ElN1Mt1PoNr2VQAIoC9Vu5pRfGgzo,2470
|
|
4
|
+
table_proof/auditor.py,sha256=pHbcAH-K3IkEq7KS-DtLePbYvcBSeU01nlB97yo9E-o,18186
|
|
5
|
+
table_proof/cli.py,sha256=_-gPG1FHGO2-N9I2mqLhocfHnjzMkx1PPgXm6GY1hys,2771
|
|
6
|
+
table_proof/docling.py,sha256=0WvZ3j9G8f9kyb1v1Q7iACl6g9PZWmYNHAF4sMZRV6c,7732
|
|
7
|
+
table_proof/models.py,sha256=IX9rW37_NIYiH5gKGwcV5VuC6kmycevXBEL1PhmSVFs,2709
|
|
8
|
+
table_proof/pdf.py,sha256=oEh5N3m3bh9yOsdEmUwIsQXVaaTLq1yvewBHwMG0s_Y,1107
|
|
9
|
+
table_proof/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
table_proof/report.py,sha256=36HzAEZ8M8oBRcI1Xfekc0j1Zc1LRsgiKWeKw-Pmpd8,7427
|
|
11
|
+
table_proof/vision.py,sha256=tYyV9icGdJLvH69VzXclRXFF5D_a6i6FAf8sZDn0epk,14517
|
|
12
|
+
table_proof_py-0.1.0.dist-info/METADATA,sha256=fTvr-JEsnv48oyDlutG9wkFI4IU9Pzl4ti5Ce1qtHm4,7684
|
|
13
|
+
table_proof_py-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
14
|
+
table_proof_py-0.1.0.dist-info/entry_points.txt,sha256=UYEbog74Gne-XoIwGYsrWRn3keSDNycsyBz_PVI6WhI,53
|
|
15
|
+
table_proof_py-0.1.0.dist-info/licenses/LICENSE,sha256=zaO2HyIyrh_67VlCYrK48jHjPbhbLU-In6kBzrGFgIo,1071
|
|
16
|
+
table_proof_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eduard Smirnov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|