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/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")
|