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/docling.py ADDED
@@ -0,0 +1,168 @@
1
+ """Read Docling JSON and objects without a Docling runtime dependency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Any
7
+
8
+ from .models import BBox, Cell, SourcePage, Table
9
+
10
+
11
+ class DocumentFormatError(ValueError):
12
+ """The input does not match a supported document format."""
13
+
14
+
15
+ def _mapping(value: Any, name: str) -> Mapping[str, Any]:
16
+ if not isinstance(value, Mapping):
17
+ raise DocumentFormatError(f"{name} must be an object.")
18
+ return value
19
+
20
+
21
+ def _integer(value: Any, name: str) -> int:
22
+ if isinstance(value, bool) or not isinstance(value, int):
23
+ raise DocumentFormatError(f"{name} must be an integer.")
24
+ return int(value)
25
+
26
+
27
+ def _box(value: Any, page_height: float, scale_x: float, scale_y: float) -> BBox | None:
28
+ if value is None:
29
+ return None
30
+ raw = _mapping(value, "bbox")
31
+ origin = raw.get("coord_origin", "TOPLEFT")
32
+ if origin not in {"TOPLEFT", "BOTTOMLEFT"}:
33
+ raise DocumentFormatError(f"Unsupported coordinate origin: {origin}")
34
+ try:
35
+ left, right = float(raw["l"]) * scale_x, float(raw["r"]) * scale_x
36
+ top, bottom = float(raw["t"]) * scale_y, float(raw["b"]) * scale_y
37
+ except (KeyError, TypeError, ValueError) as error:
38
+ raise DocumentFormatError("A Docling box needs numeric l, t, r, and b values.") from error
39
+ if origin == "BOTTOMLEFT":
40
+ top, bottom = page_height - top, page_height - bottom
41
+ return BBox(x0=left, top=top, x1=right, bottom=bottom)
42
+
43
+
44
+ def _reference_text(document: Mapping[str, Any], reference: Any, visited: set[str]) -> str:
45
+ raw = _mapping(reference, "ref")
46
+ pointer = raw.get("$ref")
47
+ if not isinstance(pointer, str) or not pointer.startswith("#/"):
48
+ raise DocumentFormatError("A rich cell needs a local JSON reference.")
49
+ if pointer in visited or len(visited) >= 32:
50
+ raise DocumentFormatError("A rich cell has a cyclic or excessive reference chain.")
51
+ node: Any = document
52
+ try:
53
+ for token in pointer[2:].split("/"):
54
+ token = token.replace("~1", "/").replace("~0", "~")
55
+ if isinstance(node, list):
56
+ if not token.isdecimal():
57
+ raise KeyError(token)
58
+ node = node[int(token)]
59
+ else:
60
+ node = node[token]
61
+ except (KeyError, IndexError, TypeError) as error:
62
+ raise DocumentFormatError(f"Unresolved rich cell reference: {pointer}") from error
63
+ item = _mapping(node, "Referenced item")
64
+ if "text" in item:
65
+ if not isinstance(item["text"], str):
66
+ raise DocumentFormatError("Referenced text must be a string.")
67
+ return str(item["text"])
68
+ children = item.get("children", [])
69
+ if not isinstance(children, list):
70
+ raise DocumentFormatError("Referenced children must be a list.")
71
+ return " ".join(_reference_text(document, child, visited | {pointer}) for child in children)
72
+
73
+
74
+ def normalize_document(document: Any, pages: Mapping[int, SourcePage]) -> list[Table]:
75
+ """Resolve rendered rich-cell text and normalize source coordinates."""
76
+ if isinstance(document, Sequence) and not isinstance(document, (str, bytes)):
77
+ if all(isinstance(table, Table) for table in document):
78
+ return list(document)
79
+ raise DocumentFormatError("A table sequence must contain Table objects.")
80
+ if hasattr(document, "document"):
81
+ document = document.document
82
+ if hasattr(document, "model_dump"):
83
+ document = document.model_dump(mode="json")
84
+ root = _mapping(document, "Document")
85
+ if "document" in root and "tables" not in root:
86
+ root = _mapping(root["document"], "document")
87
+ raw_tables = root.get("tables")
88
+ if not isinstance(raw_tables, list):
89
+ raise DocumentFormatError("A Docling document must contain a tables list.")
90
+ declared_pages = _mapping(root.get("pages", {}), "pages")
91
+ tables: list[Table] = []
92
+ seen: set[str] = set()
93
+ for table_index, raw_table in enumerate(raw_tables):
94
+ raw = _mapping(raw_table, "Table")
95
+ table_id = str(raw.get("self_ref", f"#/tables/{table_index}"))
96
+ if table_id in seen:
97
+ raise DocumentFormatError(f"Duplicate table reference: {table_id}")
98
+ seen.add(table_id)
99
+ data = _mapping(raw.get("data"), "Table data")
100
+ rows = _integer(data.get("num_rows"), "num_rows")
101
+ cols = _integer(data.get("num_cols"), "num_cols")
102
+ provenance = raw.get("prov", [])
103
+ if not isinstance(provenance, list):
104
+ raise DocumentFormatError("Table provenance must be a list.")
105
+ reason: str | None = None
106
+ if len(provenance) != 1:
107
+ reason = "The table needs exactly one source page. Multi-page tables are unsupported."
108
+ prov = _mapping(provenance[0], "Provenance") if provenance else {}
109
+ page_no = _integer(prov.get("page_no", 1), "page_no")
110
+ source_page = pages.get(page_no)
111
+ size = _mapping(
112
+ _mapping(declared_pages.get(str(page_no), {}), "Page").get("size", {}), "Page size"
113
+ )
114
+ height = source_page.height if source_page else float(size.get("height", 1))
115
+ width = source_page.width if source_page else float(size.get("width", 1))
116
+ declared_width = float(size.get("width", width))
117
+ declared_height = float(size.get("height", height))
118
+ if declared_width <= 0 or declared_height <= 0:
119
+ raise DocumentFormatError("Declared page dimensions must be positive.")
120
+ sx, sy = width / declared_width, height / declared_height
121
+ cells: list[Cell] = []
122
+ raw_cells = data.get("table_cells")
123
+ if not isinstance(raw_cells, list):
124
+ raise DocumentFormatError("Table data must contain a table_cells list.")
125
+ for index, raw_cell in enumerate(raw_cells):
126
+ cell = _mapping(raw_cell, "Cell")
127
+ text = cell.get("text", "")
128
+ if not isinstance(text, str):
129
+ raise DocumentFormatError("Cell text must be a string.")
130
+ if cell.get("ref") is not None:
131
+ try:
132
+ text = _reference_text(root, cell["ref"], set())
133
+ except DocumentFormatError as error:
134
+ reason = str(error)
135
+ row = _integer(cell.get("start_row_offset_idx"), "start_row_offset_idx")
136
+ col = _integer(cell.get("start_col_offset_idx"), "start_col_offset_idx")
137
+ row_end = _integer(cell.get("end_row_offset_idx"), "end_row_offset_idx")
138
+ col_end = _integer(cell.get("end_col_offset_idx"), "end_col_offset_idx")
139
+ row_span, col_span = row_end - row, col_end - col
140
+ if row_span != cell.get("row_span", row_span) or col_span != cell.get(
141
+ "col_span", col_span
142
+ ):
143
+ raise DocumentFormatError("Cell spans disagree with their start and end offsets.")
144
+ cells.append(
145
+ Cell(
146
+ id=f"{table_id}/cells/{index}",
147
+ row=row,
148
+ col=col,
149
+ text=text,
150
+ bbox=_box(cell.get("bbox"), height, sx, sy),
151
+ row_span=row_span,
152
+ col_span=col_span,
153
+ column_header=bool(cell.get("column_header", False)),
154
+ row_header=bool(cell.get("row_header", False)),
155
+ )
156
+ )
157
+ tables.append(
158
+ Table(
159
+ id=table_id,
160
+ page=page_no,
161
+ bbox=_box(prov.get("bbox"), height, sx, sy),
162
+ num_rows=rows,
163
+ num_cols=cols,
164
+ cells=cells,
165
+ unsupported_reason=reason,
166
+ )
167
+ )
168
+ return tables
table_proof/models.py ADDED
@@ -0,0 +1,111 @@
1
+ """Typed source evidence and audit results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
8
+
9
+ Status = Literal["no_findings", "review", "uncertain"]
10
+
11
+
12
+ class Record(BaseModel):
13
+ model_config = ConfigDict(extra="forbid", frozen=True, allow_inf_nan=False)
14
+
15
+
16
+ class BBox(Record):
17
+ """A box in PDF points, with a top-left origin."""
18
+
19
+ x0: float
20
+ top: float
21
+ x1: float
22
+ bottom: float
23
+
24
+ @model_validator(mode="after")
25
+ def positive_area(self) -> BBox:
26
+ if self.x1 <= self.x0 or self.bottom <= self.top:
27
+ raise ValueError("A box must have positive width and height.")
28
+ return self
29
+
30
+
31
+ class SourceWord(Record):
32
+ text: str
33
+ bbox: BBox
34
+
35
+
36
+ class SourcePage(Record):
37
+ page: int = Field(ge=1)
38
+ width: float = Field(gt=0)
39
+ height: float = Field(gt=0)
40
+ words: list[SourceWord]
41
+
42
+
43
+ class Cell(Record):
44
+ id: str
45
+ row: int = Field(ge=0)
46
+ col: int = Field(ge=0)
47
+ text: str
48
+ bbox: BBox | None = None
49
+ row_span: int = Field(default=1, ge=1)
50
+ col_span: int = Field(default=1, ge=1)
51
+ column_header: bool = False
52
+ row_header: bool = False
53
+
54
+
55
+ class Table(Record):
56
+ id: str
57
+ page: int = Field(ge=1)
58
+ bbox: BBox | None = None
59
+ num_rows: int = Field(ge=0)
60
+ num_cols: int = Field(ge=0)
61
+ cells: list[Cell]
62
+ unsupported_reason: str | None = None
63
+
64
+
65
+ class Finding(Record):
66
+ code: str
67
+ severity: Literal["error", "uncertain"]
68
+ table_id: str
69
+ page: int
70
+ cell_id: str | None = None
71
+ message: str
72
+ expected_text: str | None = None
73
+ observed_text: str | None = None
74
+ bbox: BBox | None = None
75
+ evidence: str = "pdf_text_geometry"
76
+
77
+
78
+ class TableResult(Record):
79
+ table_id: str
80
+ page: int
81
+ status: Status
82
+ checked_cells: int = Field(ge=0)
83
+ total_cells: int = Field(ge=0)
84
+ findings: list[Finding]
85
+
86
+
87
+ class AuditReport(Record):
88
+ schema_version: str = "1.0"
89
+ engine_version: str = "0.1.0"
90
+ source_name: str
91
+ source_sha256: str
92
+ tables: list[TableResult]
93
+ limitations: list[str] = Field(default_factory=list)
94
+
95
+ @property
96
+ def status(self) -> Status:
97
+ if any(table.status == "review" for table in self.tables):
98
+ return "review"
99
+ if not self.tables or any(table.status == "uncertain" for table in self.tables):
100
+ return "uncertain"
101
+ return "no_findings"
102
+
103
+ def export_json(self, path: str) -> None:
104
+ from .report import export_json
105
+
106
+ export_json(self, path)
107
+
108
+ def export_html(self, path: str, *, source_pdf: str | None = None) -> None:
109
+ from .report import export_html
110
+
111
+ export_html(self, path, source_pdf=source_pdf)
table_proof/pdf.py ADDED
@@ -0,0 +1,28 @@
1
+ """Read digital PDF words without a model or network request."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pdfplumber
8
+
9
+ from .models import BBox, SourcePage, SourceWord
10
+
11
+
12
+ def read_pdf_pages(source_pdf: str | Path) -> dict[int, SourcePage]:
13
+ pages: dict[int, SourcePage] = {}
14
+ with pdfplumber.open(source_pdf) as document:
15
+ for index, page in enumerate(document.pages, 1):
16
+ left, top, _, _ = page.bbox
17
+ words = []
18
+ for word in page.dedupe_chars().extract_words(
19
+ x_tolerance=2, y_tolerance=2, keep_blank_chars=False, extra_attrs=["size"]
20
+ ):
21
+ x0, x1 = float(word["x0"]) - left, float(word["x1"]) - left
22
+ y0, y1 = float(word["top"]) - top, float(word["bottom"]) - top
23
+ if x1 > x0 and y1 > y0:
24
+ words.append(
25
+ SourceWord(text=word["text"], bbox=BBox(x0=x0, top=y0, x1=x1, bottom=y1))
26
+ )
27
+ pages[index] = SourcePage(page=index, width=page.width, height=page.height, words=words)
28
+ return pages
table_proof/py.typed ADDED
File without changes
table_proof/report.py ADDED
@@ -0,0 +1,217 @@
1
+ """Write local reports with escaped text and optional source images."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import io
8
+ import json
9
+ from html import escape
10
+ from pathlib import Path
11
+
12
+ import pdfplumber
13
+
14
+ from .models import AuditReport
15
+
16
+
17
+ def export_json(report: AuditReport, path: str | Path) -> None:
18
+ data = report.model_dump(mode="json")
19
+ data["status"] = report.status
20
+ Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
21
+
22
+
23
+ def _source_images(report: AuditReport, source_pdf: str) -> str:
24
+ path = Path(source_pdf)
25
+ with path.open("rb") as file:
26
+ if hashlib.file_digest(file, "sha256").hexdigest() != report.source_sha256:
27
+ raise ValueError("The evidence PDF does not match the audited source hash.")
28
+ pages = sorted({table.page for table in report.tables})
29
+ blocks: list[str] = []
30
+ with pdfplumber.open(path) as pdf:
31
+ for page_no in pages[:10]:
32
+ if page_no < 1 or page_no > len(pdf.pages):
33
+ continue
34
+ page = pdf.pages[page_no - 1]
35
+ resolution = min(100.0, 1600 * 72 / max(page.width, page.height))
36
+ img = page.to_image(resolution=resolution, force_mediabox=True).original
37
+ buffer = io.BytesIO()
38
+ img.save(buffer, format="PNG")
39
+ encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
40
+ boxes = []
41
+ for table in report.tables:
42
+ if table.page != page_no:
43
+ continue
44
+ for finding in table.findings:
45
+ box = finding.bbox
46
+ if box is None:
47
+ continue
48
+ x = max(0.0, box.x0 / page.width * 100)
49
+ y = max(0.0, box.top / page.height * 100)
50
+ width = min(100.0 - x, (box.x1 - box.x0) / page.width * 100)
51
+ height = min(100.0 - y, (box.bottom - box.top) / page.height * 100)
52
+ if width <= 0 or height <= 0:
53
+ continue
54
+ boxes.append(
55
+ f'<span class="box {finding.severity}" '
56
+ f'style="left:{x:.4f}%;top:{y:.4f}%;width:{width:.4f}%;'
57
+ f'height:{height:.4f}%" title="{escape(finding.message, quote=True)}">'
58
+ '<span class="sr-only">Suspected cell</span></span>'
59
+ )
60
+ blocks.append(
61
+ f'<figure><figcaption>Source page {page_no}</figcaption><div class="page">'
62
+ f'<img alt="Source PDF page {page_no}" src="data:image/png;base64,{encoded}">'
63
+ f"{''.join(boxes)}</div></figure>"
64
+ )
65
+ if len(pages) > 10:
66
+ blocks.append("<p>The report shows the first 10 source pages.</p>")
67
+ return "".join(blocks)
68
+
69
+
70
+ def export_html(report: AuditReport, path: str | Path, *, source_pdf: str | None = None) -> None:
71
+ destination = Path(path)
72
+ if source_pdf:
73
+ source = Path(source_pdf)
74
+ if destination.resolve() == source.resolve() or (
75
+ destination.exists() and source.exists() and destination.samefile(source)
76
+ ):
77
+ raise ValueError("The HTML report must not replace the source PDF.")
78
+ tables = []
79
+ for table in report.tables:
80
+ findings = []
81
+ for finding in table.findings:
82
+ values = ""
83
+ if finding.expected_text is not None:
84
+ values += f"<dt>Source evidence</dt><dd>{escape(finding.expected_text)}</dd>"
85
+ if finding.observed_text is not None:
86
+ values += f"<dt>Extracted text</dt><dd>{escape(finding.observed_text)}</dd>"
87
+ findings.append(
88
+ f'<li class="finding {finding.severity}"><strong>{escape(finding.code)}</strong>'
89
+ f"<p>{escape(finding.message)}</p><dl><dt>Cell</dt>"
90
+ f"<dd>{escape(finding.cell_id or 'Table')}</dd>{values}"
91
+ f"<dt>Evidence method</dt><dd>{escape(finding.evidence)}</dd></dl></li>"
92
+ )
93
+ tables.append(
94
+ f'<section><div class="table-title"><h2>{escape(table.table_id)}</h2>'
95
+ f'<span class="badge">{table.status}</span></div>'
96
+ f"<p>Page {table.page} · Cells with source evidence: "
97
+ f"{table.checked_cells} / {table.total_cells}</p>"
98
+ f'<ul class="findings">{"".join(findings)}</ul></section>'
99
+ )
100
+ limitations = "".join(f"<li>{escape(text)}</li>" for text in report.limitations)
101
+ images = _source_images(report, source_pdf) if source_pdf else ""
102
+ markup = f"""<!doctype html>
103
+ <html lang="en"><head><meta charset="utf-8">
104
+ <meta name="viewport" content="width=device-width, initial-scale=1">
105
+ <meta http-equiv="Content-Security-Policy"
106
+ content="default-src 'none'; img-src data:; style-src 'unsafe-inline'">
107
+ <title>TableProof · {escape(report.source_name)}</title>
108
+ <style>
109
+ :root {{ color-scheme: light;
110
+ font-family: ui-sans-serif, system-ui, sans-serif;
111
+ color:#182936;
112
+ background:#f2f5f4;
113
+ }}
114
+ body {{ margin:0;
115
+ }} main {{ max-width:1060px;
116
+ margin:auto;
117
+ padding:36px 22px;
118
+ }}
119
+ header {{ border-top:5px solid #0a7368;
120
+ padding:22px 0;
121
+ }}
122
+ .eyebrow {{ color:#0a7368;
123
+ text-transform:uppercase;
124
+ letter-spacing:.14em;
125
+ font-size:12px;
126
+ font-weight:700;
127
+ }}
128
+ h1 {{ font-size:34px;
129
+ margin:10px 0;
130
+ overflow-wrap:anywhere;
131
+ }}
132
+ h2 {{ font-size:19px;
133
+ margin:0;
134
+ overflow-wrap:anywhere;
135
+ }} p {{ line-height:1.6;
136
+ }}
137
+ .badge {{ background:#dceae6;
138
+ color:#174e43;
139
+ border-radius:4px;
140
+ padding:6px 10px;
141
+ font:600 13px monospace;
142
+ }}
143
+ .table-title {{ display:flex;
144
+ justify-content:space-between;
145
+ gap:16px;
146
+ flex-wrap:wrap;
147
+ }}
148
+ section, figure {{ background:white;
149
+ border:1px solid #d4dfdc;
150
+ padding:22px;
151
+ margin:18px 0;
152
+ border-radius:7px;
153
+ }}
154
+ .findings {{ list-style:none;
155
+ margin:0;
156
+ padding:0;
157
+ }} .finding {{ padding:18px;
158
+ margin:12px 0;
159
+ background:#faf5ef;
160
+ border-left:4px solid #ac5a21;
161
+ }}
162
+ .finding.uncertain {{ background:#f3f1e5;
163
+ border-left-color:#877521;
164
+ }}
165
+ dl {{ display:grid;
166
+ grid-template-columns:150px 1fr;
167
+ gap:8px 14px;
168
+ font-size:14px;
169
+ }}
170
+ dt {{ color:#57646c;
171
+ }} dd {{ margin:0;
172
+ white-space:pre-wrap;
173
+ overflow-wrap:anywhere;
174
+ }}
175
+ .page {{ position:relative;
176
+ margin:auto;
177
+ max-width:800px;
178
+ }} img {{ width:100%;
179
+ display:block;
180
+ }}
181
+ .box {{ position:absolute;
182
+ box-sizing:border-box;
183
+ border:2px solid #b64222;
184
+ background:#ff6b3625;
185
+ }}
186
+ .box.uncertain {{ border-color:#9a7b14;
187
+ background:#ffdf3020;
188
+ }}
189
+ figcaption {{ margin-bottom:16px;
190
+ font-weight:600;
191
+ }}
192
+ .sr-only {{ position:absolute;
193
+ width:1px;
194
+ height:1px;
195
+ overflow:hidden;
196
+ clip:rect(0,0,0,0);
197
+ }}
198
+ footer {{ font-size:12px;
199
+ color:#5b6870;
200
+ overflow-wrap:anywhere;
201
+ }}
202
+ @media(max-width:600px) {{ dl {{ grid-template-columns:1fr;
203
+ }} section,figure {{ padding:14px;
204
+ }} h1 {{ font-size:26px;
205
+ }} }}
206
+ </style></head><body><main>
207
+ <header><div class="eyebrow">TableProof / Evidence report</div>
208
+ <h1>{escape(report.source_name)}</h1><span class="badge">{report.status}</span>
209
+ <p>Examine each alert against the original source. A report does not certify the table.</p></header>
210
+ {"".join(tables) or "<section><p>No tables were supplied. Coverage is unknown.</p></section>"}
211
+ {images}<section><h2>Limits</h2><ul>{limitations}</ul></section>
212
+ <footer>TableProof {escape(report.engine_version)}
213
+ · Report schema {escape(report.schema_version)}<br>
214
+ Source SHA-256: {escape(report.source_sha256)}</footer>
215
+ </main></body></html>
216
+ """
217
+ Path(path).write_text(markup, encoding="utf-8")