parsearena 0.1.3__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.
- parsearena/__init__.py +3 -0
- parsearena/cli.py +288 -0
- parsearena/demo/pdfs/NOTICE.md +36 -0
- parsearena/demo/pdfs/census-income-2023.pdf +0 -0
- parsearena/demo/pdfs/dpo-arxiv-2305.18290.pdf +0 -0
- parsearena/demo/pdfs/naca-report-1135-scan.pdf +0 -0
- parsearena/demo/questions.yaml +104 -0
- parsearena/demo/recorded.json +304 -0
- parsearena/errors.py +14 -0
- parsearena/evaluate.py +130 -0
- parsearena/llm.py +154 -0
- parsearena/parsers.py +197 -0
- parsearena/pipeline.py +276 -0
- parsearena/questions.py +125 -0
- parsearena/render.py +40 -0
- parsearena/report.py +57 -0
- parsearena/report_template.html +450 -0
- parsearena/retrieval.py +197 -0
- parsearena/runners/_common.py +148 -0
- parsearena/runners/run_docling.py +25 -0
- parsearena/runners/run_marker.py +54 -0
- parsearena/runners/run_mineru.py +59 -0
- parsearena/runners/run_pypdf.py +22 -0
- parsearena/runners/run_unstructured.py +38 -0
- parsearena/text.py +90 -0
- parsearena-0.1.3.dist-info/METADATA +267 -0
- parsearena-0.1.3.dist-info/RECORD +30 -0
- parsearena-0.1.3.dist-info/WHEEL +4 -0
- parsearena-0.1.3.dist-info/entry_points.txt +2 -0
- parsearena-0.1.3.dist-info/licenses/LICENSE +21 -0
parsearena/pipeline.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Run parsers over PDFs, one isolated subprocess per parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import re
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pypdfium2 as pdfium
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .errors import UserError
|
|
20
|
+
from .evaluate import DocOutput
|
|
21
|
+
from .parsers import ParserSpec, ensure_env, runner_dir
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def doc_id_for(path: Path, taken: set[str]) -> str:
|
|
25
|
+
base = re.sub(r"[^A-Za-z0-9._-]+", "-", path.stem).strip("-") or "doc"
|
|
26
|
+
doc_id, n = base, 2
|
|
27
|
+
while doc_id in taken:
|
|
28
|
+
doc_id, n = f"{base}-{n}", n + 1
|
|
29
|
+
taken.add(doc_id)
|
|
30
|
+
return doc_id
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def collect_docs(paths: list[Path]) -> list[dict]:
|
|
34
|
+
docs, taken = [], set()
|
|
35
|
+
for p in paths:
|
|
36
|
+
if not p.exists():
|
|
37
|
+
raise UserError(f"File not found: {p}")
|
|
38
|
+
if p.suffix.lower() != ".pdf":
|
|
39
|
+
raise UserError(f"Not a PDF: {p}", "parsearena v0.1 reads PDF files only.")
|
|
40
|
+
try:
|
|
41
|
+
pdf = pdfium.PdfDocument(str(p))
|
|
42
|
+
n = len(pdf)
|
|
43
|
+
pdf.close()
|
|
44
|
+
except pdfium.PdfiumError as exc:
|
|
45
|
+
raise UserError(f"Could not open {p}: {exc}") from exc
|
|
46
|
+
docs.append({"id": doc_id_for(p, taken), "file": p.name, "path": str(p.resolve()), "num_pages": n})
|
|
47
|
+
if not docs:
|
|
48
|
+
raise UserError("No PDFs given.", "Example: parsearena run docs/*.pdf")
|
|
49
|
+
return docs
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def hardware() -> str:
|
|
53
|
+
machine = platform.machine()
|
|
54
|
+
cpu = platform.processor() or machine
|
|
55
|
+
if platform.system() == "Darwin":
|
|
56
|
+
try:
|
|
57
|
+
cpu = (
|
|
58
|
+
subprocess.run(
|
|
59
|
+
["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True, text=True, timeout=5
|
|
60
|
+
).stdout.strip()
|
|
61
|
+
or cpu
|
|
62
|
+
)
|
|
63
|
+
mem = int(
|
|
64
|
+
subprocess.run(
|
|
65
|
+
["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=5
|
|
66
|
+
).stdout
|
|
67
|
+
)
|
|
68
|
+
return f"{cpu}, {round(mem / 2**30)} GB RAM, macOS {platform.mac_ver()[0]}"
|
|
69
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
70
|
+
pass
|
|
71
|
+
mem = ""
|
|
72
|
+
try:
|
|
73
|
+
mem = f", {round(os.sysconf('SC_PAGE_SIZE') * os.sysconf('SC_PHYS_PAGES') / 2**30)} GB RAM"
|
|
74
|
+
except (ValueError, OSError, AttributeError):
|
|
75
|
+
pass
|
|
76
|
+
return f"{cpu}{mem}, {platform.system()} {platform.release()}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def make_warmup_pdf(src: Path, dst: Path) -> dict:
|
|
80
|
+
"""Two pages: the first page of ``src`` as is, and the same page as an image only."""
|
|
81
|
+
source = pdfium.PdfDocument(str(src))
|
|
82
|
+
out = pdfium.PdfDocument.new()
|
|
83
|
+
try:
|
|
84
|
+
out.import_pages(source, [0])
|
|
85
|
+
page = source[0]
|
|
86
|
+
w, h = page.get_size()
|
|
87
|
+
bitmap = page.render(scale=150 / 72)
|
|
88
|
+
image = pdfium.PdfImage.new(out)
|
|
89
|
+
image.set_bitmap(bitmap)
|
|
90
|
+
image.set_matrix(pdfium.PdfMatrix().scale(w, h))
|
|
91
|
+
new = out.new_page(w, h)
|
|
92
|
+
new.insert_obj(image)
|
|
93
|
+
new.gen_content()
|
|
94
|
+
out.save(str(dst))
|
|
95
|
+
finally:
|
|
96
|
+
source.close()
|
|
97
|
+
out.close()
|
|
98
|
+
return {"path": str(dst), "num_pages": 2}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_parser(
|
|
102
|
+
spec: ParserSpec,
|
|
103
|
+
docs: list[dict],
|
|
104
|
+
work: Path,
|
|
105
|
+
device: str = "auto",
|
|
106
|
+
timeout: int = 3600,
|
|
107
|
+
warmup: bool = True,
|
|
108
|
+
log=print,
|
|
109
|
+
) -> dict[str, DocOutput]:
|
|
110
|
+
python = ensure_env(spec, log=log)
|
|
111
|
+
# The runner's working directory is pdir, so every path handed to it is absolute.
|
|
112
|
+
pdir = work.resolve() / spec.name
|
|
113
|
+
pdir.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
warmup_doc = make_warmup_pdf(Path(docs[0]["path"]), pdir / "warmup.pdf") if (warmup and docs) else None
|
|
115
|
+
manifest = {
|
|
116
|
+
"device": device,
|
|
117
|
+
"warmup_doc": warmup_doc,
|
|
118
|
+
"docs": [
|
|
119
|
+
{
|
|
120
|
+
"id": d["id"],
|
|
121
|
+
"path": d["path"],
|
|
122
|
+
"num_pages": d["num_pages"],
|
|
123
|
+
"out": str(pdir / f"{d['id']}.json"),
|
|
124
|
+
}
|
|
125
|
+
for d in docs
|
|
126
|
+
],
|
|
127
|
+
}
|
|
128
|
+
for d in manifest["docs"]:
|
|
129
|
+
Path(d["out"]).unlink(missing_ok=True)
|
|
130
|
+
mpath = pdir / "manifest.json"
|
|
131
|
+
mpath.write_text(json.dumps(manifest, indent=2))
|
|
132
|
+
env = {**dict(spec.env), **os.environ, "PYTHONUNBUFFERED": "1", "TOKENIZERS_PARALLELISM": "false"}
|
|
133
|
+
env.pop("PYTHONPATH", None)
|
|
134
|
+
if device == "cpu":
|
|
135
|
+
env["CUDA_VISIBLE_DEVICES"] = ""
|
|
136
|
+
if device != "auto":
|
|
137
|
+
env["TORCH_DEVICE"] = device
|
|
138
|
+
log_path = pdir / "stderr.log"
|
|
139
|
+
started = time.perf_counter()
|
|
140
|
+
error = None
|
|
141
|
+
with open(log_path, "w") as lf:
|
|
142
|
+
proc = subprocess.Popen(
|
|
143
|
+
[str(python), str(runner_dir() / spec.runner), str(mpath)],
|
|
144
|
+
stdout=lf,
|
|
145
|
+
stderr=subprocess.STDOUT,
|
|
146
|
+
env=env,
|
|
147
|
+
cwd=pdir,
|
|
148
|
+
start_new_session=True,
|
|
149
|
+
)
|
|
150
|
+
try:
|
|
151
|
+
code = proc.wait(timeout=timeout)
|
|
152
|
+
if code != 0:
|
|
153
|
+
error = f"runner exited with code {code}"
|
|
154
|
+
except subprocess.TimeoutExpired:
|
|
155
|
+
error = f"timed out after {timeout}s"
|
|
156
|
+
finally:
|
|
157
|
+
stop_process_tree(proc)
|
|
158
|
+
wall = time.perf_counter() - started
|
|
159
|
+
results: dict[str, DocOutput] = {}
|
|
160
|
+
for d in manifest["docs"]:
|
|
161
|
+
out = Path(d["out"])
|
|
162
|
+
if out.exists():
|
|
163
|
+
raw = json.loads(out.read_text())
|
|
164
|
+
results[d["id"]] = DocOutput(
|
|
165
|
+
pages=raw.get("pages", []),
|
|
166
|
+
seconds=raw.get("seconds", 0.0),
|
|
167
|
+
error=raw.get("error"),
|
|
168
|
+
device=raw.get("device", ""),
|
|
169
|
+
warmup_seconds=raw.get("warmup_seconds"),
|
|
170
|
+
extra={"version": raw.get("version"), "wall_seconds": wall},
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
tail = _tail(log_path)
|
|
174
|
+
results[d["id"]] = DocOutput(
|
|
175
|
+
pages=[], error=f"{error or 'no output'}: {tail}"[:1500], extra={"wall_seconds": wall}
|
|
176
|
+
)
|
|
177
|
+
return results
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _descendants(pid: int) -> list[int]:
|
|
181
|
+
try:
|
|
182
|
+
out = subprocess.run(
|
|
183
|
+
["ps", "-A", "-o", "pid=,ppid="], capture_output=True, text=True, timeout=10
|
|
184
|
+
).stdout
|
|
185
|
+
except (OSError, subprocess.SubprocessError):
|
|
186
|
+
return []
|
|
187
|
+
children: dict[int, list[int]] = {}
|
|
188
|
+
for line in out.splitlines():
|
|
189
|
+
parts = line.split()
|
|
190
|
+
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
|
|
191
|
+
children.setdefault(int(parts[1]), []).append(int(parts[0]))
|
|
192
|
+
found, stack = [], [pid]
|
|
193
|
+
while stack:
|
|
194
|
+
for child in children.get(stack.pop(), []):
|
|
195
|
+
found.append(child)
|
|
196
|
+
stack.append(child)
|
|
197
|
+
return found
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def stop_process_tree(proc: subprocess.Popen, grace: float = 5.0) -> None:
|
|
201
|
+
"""Make sure neither the runner nor anything it started outlives it.
|
|
202
|
+
|
|
203
|
+
The runner kills its own helpers on a normal exit; this covers timeouts and
|
|
204
|
+
crashes: every descendant (even one in its own session) and the runner's
|
|
205
|
+
process group get SIGTERM, then SIGKILL.
|
|
206
|
+
"""
|
|
207
|
+
running = proc.poll() is None
|
|
208
|
+
# Only signal PIDs we know are ours: after a normal exit the runner is reaped and
|
|
209
|
+
# its PID may be reused, so only its process group is signalled.
|
|
210
|
+
targets = [*_descendants(proc.pid), proc.pid] if running else []
|
|
211
|
+
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
212
|
+
for p in targets:
|
|
213
|
+
try:
|
|
214
|
+
os.kill(p, sig)
|
|
215
|
+
except (ProcessLookupError, PermissionError):
|
|
216
|
+
pass
|
|
217
|
+
try:
|
|
218
|
+
os.killpg(proc.pid, sig)
|
|
219
|
+
except (ProcessLookupError, PermissionError):
|
|
220
|
+
pass
|
|
221
|
+
try:
|
|
222
|
+
proc.wait(timeout=grace)
|
|
223
|
+
except subprocess.TimeoutExpired:
|
|
224
|
+
continue
|
|
225
|
+
if sig == signal.SIGTERM:
|
|
226
|
+
time.sleep(0.2)
|
|
227
|
+
targets = [p for p in targets if _pid_alive(p)]
|
|
228
|
+
if not targets:
|
|
229
|
+
break
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _pid_alive(pid: int) -> bool:
|
|
233
|
+
try:
|
|
234
|
+
os.kill(pid, 0)
|
|
235
|
+
return True
|
|
236
|
+
except (ProcessLookupError, PermissionError):
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _tail(path: Path, lines: int = 6) -> str:
|
|
241
|
+
try:
|
|
242
|
+
text = path.read_text(errors="replace").strip().splitlines()
|
|
243
|
+
except OSError:
|
|
244
|
+
return ""
|
|
245
|
+
return " | ".join(text[-lines:])
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def save_outputs(path: Path, docs: list[dict], specs: list[ParserSpec], outputs, device: str) -> dict:
|
|
249
|
+
data = {
|
|
250
|
+
"meta": {
|
|
251
|
+
"tool": "parsearena",
|
|
252
|
+
"tool_version": __version__,
|
|
253
|
+
"created": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
254
|
+
"hardware": hardware(),
|
|
255
|
+
"device_requested": device,
|
|
256
|
+
},
|
|
257
|
+
"parsers": [
|
|
258
|
+
{
|
|
259
|
+
"name": s.name,
|
|
260
|
+
"label": s.label,
|
|
261
|
+
"version": s.version,
|
|
262
|
+
"packages": list(s.packages),
|
|
263
|
+
"license": s.license,
|
|
264
|
+
"url": s.url,
|
|
265
|
+
}
|
|
266
|
+
for s in specs
|
|
267
|
+
],
|
|
268
|
+
"docs": [{k: v for k, v in d.items() if k != "path"} for d in docs],
|
|
269
|
+
"outputs": {p: {d: asdict(o) for d, o in per.items()} for p, per in outputs.items()},
|
|
270
|
+
}
|
|
271
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=1))
|
|
272
|
+
return data
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def load_outputs(data: dict) -> dict[str, dict[str, DocOutput]]:
|
|
276
|
+
return {p: {d: DocOutput(**o) for d, o in per.items()} for p, per in data["outputs"].items()}
|
parsearena/questions.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Question sets: hand-written YAML or drafted by an LLM from the PDF text layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from .errors import BudgetExceeded, UserError
|
|
12
|
+
from .evaluate import Question
|
|
13
|
+
from .text import contains, normalize
|
|
14
|
+
|
|
15
|
+
EXAMPLE = """questions:
|
|
16
|
+
- doc: report.pdf # file name (or its stem) of one of the PDFs
|
|
17
|
+
q: What was the median household income in 2023?
|
|
18
|
+
answer: "80,610" # a short string that must appear in the document
|
|
19
|
+
- doc: report.pdf
|
|
20
|
+
q: Which region had the highest median income?
|
|
21
|
+
answers: ["West", "the West"] # any of these counts
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_questions(path: Path, docs: list[dict]) -> list[Question]:
|
|
26
|
+
if not path.exists():
|
|
27
|
+
raise UserError(f"Questions file not found: {path}", "Format:\n" + EXAMPLE)
|
|
28
|
+
try:
|
|
29
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
30
|
+
except yaml.YAMLError as exc:
|
|
31
|
+
raise UserError(f"Could not parse {path}: {exc}", "Format:\n" + EXAMPLE) from exc
|
|
32
|
+
items = raw.get("questions") if isinstance(raw, dict) else raw
|
|
33
|
+
if not isinstance(items, list):
|
|
34
|
+
raise UserError(f"{path} has no `questions:` list.", "Format:\n" + EXAMPLE)
|
|
35
|
+
by_name = {}
|
|
36
|
+
for d in docs:
|
|
37
|
+
by_name[d["id"]] = d["id"]
|
|
38
|
+
by_name[d["file"]] = d["id"]
|
|
39
|
+
by_name[Path(d["file"]).stem] = d["id"]
|
|
40
|
+
out: list[Question] = []
|
|
41
|
+
for i, item in enumerate(items, start=1):
|
|
42
|
+
if not isinstance(item, dict):
|
|
43
|
+
raise UserError(f"Question {i} in {path} is not a mapping.", "Format:\n" + EXAMPLE)
|
|
44
|
+
doc = str(item.get("doc", ""))
|
|
45
|
+
if doc not in by_name:
|
|
46
|
+
raise UserError(
|
|
47
|
+
f"Question {i} refers to unknown doc {doc!r}.",
|
|
48
|
+
"Use the file name of one of the PDFs passed to run: " + ", ".join(d["file"] for d in docs),
|
|
49
|
+
)
|
|
50
|
+
answers = item.get("answers") or ([item["answer"]] if "answer" in item else [])
|
|
51
|
+
answers = [str(a) for a in answers if str(a).strip()]
|
|
52
|
+
if not item.get("q") or not answers:
|
|
53
|
+
raise UserError(f"Question {i} in {path} needs `q` and `answer` (or `answers`).")
|
|
54
|
+
out.append(
|
|
55
|
+
Question(id=str(item.get("id") or f"q{i}"), doc=by_name[doc], q=str(item["q"]), answers=answers)
|
|
56
|
+
)
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def dump_questions(questions: list[Question], docs: list[dict], path: Path, header: str = "") -> None:
|
|
61
|
+
file_of = {d["id"]: d["file"] for d in docs}
|
|
62
|
+
data = {
|
|
63
|
+
"questions": [
|
|
64
|
+
{"id": q.id, "doc": file_of.get(q.doc, q.doc), "q": q.q, "answers": q.answers} for q in questions
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
text = yaml.safe_dump(data, sort_keys=False, allow_unicode=True, width=100)
|
|
68
|
+
path.write_text((header + "\n" if header else "") + text, encoding="utf-8")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
SYSTEM = (
|
|
72
|
+
"You write retrieval test questions for a document. Each question must be answerable from a "
|
|
73
|
+
"single passage, and its answer must be a SHORT string (a number, a name, a few words) copied "
|
|
74
|
+
"verbatim from the text. Reply with JSON only."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
PROMPT = """Text of the document "{name}" (may be truncated):
|
|
78
|
+
<<<
|
|
79
|
+
{text}
|
|
80
|
+
>>>
|
|
81
|
+
Write {n} questions about specific facts in this text (numbers in tables, definitions, names).
|
|
82
|
+
Return a JSON list like [{{"q": "...", "answer": "..."}}].
|
|
83
|
+
The answer must appear verbatim in the text above."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_json_list(reply: str) -> list[dict]:
|
|
87
|
+
reply = re.sub(r"<think>.*?</think>", "", reply, flags=re.S)
|
|
88
|
+
m = re.search(r"\[.*\]", reply, flags=re.S)
|
|
89
|
+
if not m:
|
|
90
|
+
return []
|
|
91
|
+
try:
|
|
92
|
+
data = json.loads(m.group(0))
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
return []
|
|
95
|
+
return [d for d in data if isinstance(d, dict)]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def generate_questions(llm, docs: list[dict], texts: dict[str, str], per_doc: int = 5, log=print):
|
|
99
|
+
"""Draft questions per document; keep only those whose answer is in the source text.
|
|
100
|
+
|
|
101
|
+
Returns (questions, skipped_docs, budget_error). Stops early, keeping what it
|
|
102
|
+
has, if the next call would exceed the budget.
|
|
103
|
+
"""
|
|
104
|
+
questions: list[Question] = []
|
|
105
|
+
skipped: list[str] = []
|
|
106
|
+
for d in docs:
|
|
107
|
+
text = texts.get(d["id"], "")
|
|
108
|
+
if len(text.strip()) < 200:
|
|
109
|
+
skipped.append(d["file"])
|
|
110
|
+
log(f"Skipping {d['file']}: no usable text layer to draft questions from (scanned?).")
|
|
111
|
+
continue
|
|
112
|
+
prompt = PROMPT.format(name=d["file"], text=text[:12000], n=per_doc)
|
|
113
|
+
try:
|
|
114
|
+
reply = llm.complete(SYSTEM, prompt, max_tokens=4000)
|
|
115
|
+
except BudgetExceeded as exc:
|
|
116
|
+
return questions, skipped, exc
|
|
117
|
+
norm = normalize(text)
|
|
118
|
+
kept = 0
|
|
119
|
+
for item in _parse_json_list(reply):
|
|
120
|
+
q, a = str(item.get("q", "")).strip(), str(item.get("answer", "")).strip()
|
|
121
|
+
if q and a and len(a) <= 80 and contains(norm, a):
|
|
122
|
+
kept += 1
|
|
123
|
+
questions.append(Question(id=f"{d['id']}-g{kept}", doc=d["id"], q=q, answers=[a]))
|
|
124
|
+
log(f"{d['file']}: kept {kept} questions")
|
|
125
|
+
return questions, skipped, None
|
parsearena/render.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Render PDF pages to images for the viewer (pypdfium2, no external tools)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pypdfium2 as pdfium
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render_pages(pdf_path: Path, out_dir: Path, width: int = 900) -> list[str]:
|
|
11
|
+
"""Write one WebP per page and return their file names (relative to out_dir)."""
|
|
12
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
names = []
|
|
14
|
+
pdf = pdfium.PdfDocument(str(pdf_path))
|
|
15
|
+
try:
|
|
16
|
+
for i in range(len(pdf)):
|
|
17
|
+
page = pdf[i]
|
|
18
|
+
scale = width / max(page.get_width(), 1)
|
|
19
|
+
image = page.render(scale=scale).to_pil().convert("RGB")
|
|
20
|
+
name = f"{i + 1:03d}.webp"
|
|
21
|
+
image.save(out_dir / name, "WEBP", quality=80, method=4)
|
|
22
|
+
names.append(name)
|
|
23
|
+
page.close()
|
|
24
|
+
finally:
|
|
25
|
+
pdf.close()
|
|
26
|
+
return names
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def page_text(pdf_path: Path) -> list[str]:
|
|
30
|
+
"""Text layer of every page (used only to draft questions)."""
|
|
31
|
+
pdf = pdfium.PdfDocument(str(pdf_path))
|
|
32
|
+
try:
|
|
33
|
+
out = []
|
|
34
|
+
for i in range(len(pdf)):
|
|
35
|
+
tp = pdf[i].get_textpage()
|
|
36
|
+
out.append(tp.get_text_range())
|
|
37
|
+
tp.close()
|
|
38
|
+
return out
|
|
39
|
+
finally:
|
|
40
|
+
pdf.close()
|
parsearena/report.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Static HTML viewer: one index.html plus rendered page images."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .render import render_pages
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_report(
|
|
15
|
+
out_dir: Path,
|
|
16
|
+
recorded: dict,
|
|
17
|
+
pdf_paths: dict[str, Path],
|
|
18
|
+
questions: list,
|
|
19
|
+
evaluation: dict,
|
|
20
|
+
timings: dict,
|
|
21
|
+
is_recorded: bool,
|
|
22
|
+
doc_info: dict[str, dict] | None = None,
|
|
23
|
+
) -> Path:
|
|
24
|
+
"""Write ``out_dir/index.html`` and ``out_dir/pages/<doc>/NNN.webp``."""
|
|
25
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
pages_root = out_dir / "pages"
|
|
27
|
+
if pages_root.exists():
|
|
28
|
+
shutil.rmtree(pages_root)
|
|
29
|
+
docs = []
|
|
30
|
+
for d in recorded["docs"]:
|
|
31
|
+
names = render_pages(pdf_paths[d["id"]], pages_root / d["id"])
|
|
32
|
+
info = (doc_info or {}).get(d["id"], {})
|
|
33
|
+
docs.append({**d, **info, "images": [f"pages/{d['id']}/{n}" for n in names]})
|
|
34
|
+
evaluation = {**evaluation, "computed": datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")}
|
|
35
|
+
data = {
|
|
36
|
+
"meta": {**recorded["meta"], "recorded": is_recorded},
|
|
37
|
+
"parsers": recorded["parsers"],
|
|
38
|
+
"docs": docs,
|
|
39
|
+
"outputs": {
|
|
40
|
+
p: {d: {"pages": o["pages"], "error": o["error"]} for d, o in per.items()}
|
|
41
|
+
for p, per in recorded["outputs"].items()
|
|
42
|
+
},
|
|
43
|
+
"questions": [{"id": q.id, "doc": q.doc, "q": q.q, "answers": q.answers} for q in questions],
|
|
44
|
+
"evaluation": evaluation,
|
|
45
|
+
"timings": timings,
|
|
46
|
+
}
|
|
47
|
+
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
|
|
48
|
+
template = (resources.files("parsearena") / "report_template.html").read_text(encoding="utf-8")
|
|
49
|
+
index = out_dir / "index.html"
|
|
50
|
+
index.write_text(template.replace("__DATA__", payload), encoding="utf-8")
|
|
51
|
+
(out_dir / "results.json").write_text(
|
|
52
|
+
json.dumps(
|
|
53
|
+
{k: data[k] for k in ("meta", "parsers", "evaluation", "timings")}, indent=1, ensure_ascii=False
|
|
54
|
+
),
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
)
|
|
57
|
+
return index
|