parseoff 0.2.1__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.
- parseoff/__init__.py +3 -0
- parseoff/adapters/__init__.py +32 -0
- parseoff/adapters/_common.py +10 -0
- parseoff/adapters/builtin.py +70 -0
- parseoff/adapters/custom.py +69 -0
- parseoff/adapters/optional.py +58 -0
- parseoff/cli.py +183 -0
- parseoff/doctor.py +64 -0
- parseoff/findings.py +146 -0
- parseoff/i18n.py +404 -0
- parseoff/metrics.py +142 -0
- parseoff/model.py +106 -0
- parseoff/py.typed +0 -0
- parseoff/registry.py +149 -0
- parseoff/report_html.py +173 -0
- parseoff/report_text.py +139 -0
- parseoff/runner.py +263 -0
- parseoff/samples.py +164 -0
- parseoff/textlayer.py +46 -0
- parseoff/worker.py +63 -0
- parseoff-0.2.1.dist-info/METADATA +323 -0
- parseoff-0.2.1.dist-info/RECORD +25 -0
- parseoff-0.2.1.dist-info/WHEEL +4 -0
- parseoff-0.2.1.dist-info/entry_points.txt +2 -0
- parseoff-0.2.1.dist-info/licenses/LICENSE +21 -0
parseoff/runner.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Find PDFs, run every selected parser in a worker subprocess, cache results, compute metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import findings, metrics, textlayer
|
|
16
|
+
from .adapters.custom import CustomSpec
|
|
17
|
+
from .i18n import LocalizedError, t
|
|
18
|
+
from .model import DocReport, ParserOutput, RunReport, Skipped, Thresholds
|
|
19
|
+
from .registry import REGISTRY, installed_version, is_installed
|
|
20
|
+
|
|
21
|
+
CACHE_SCHEMA = 1
|
|
22
|
+
STDERR_LIMIT = 2000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UsageError(LocalizedError):
|
|
26
|
+
"""Bad input from the user (exit code 2). The message says how to fix it.
|
|
27
|
+
|
|
28
|
+
``str(error)`` is English. The command line shows ``error.text(lang)`` in the screen language.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ParserSpec:
|
|
34
|
+
name: str
|
|
35
|
+
custom: CustomSpec | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class RunOptions:
|
|
40
|
+
input_path: Path
|
|
41
|
+
out_dir: Path
|
|
42
|
+
parsers: list[str] | None = None # None = automatic selection
|
|
43
|
+
customs: list[CustomSpec] = field(default_factory=list)
|
|
44
|
+
max_pages: int | None = None
|
|
45
|
+
timeout: float = 120.0
|
|
46
|
+
fresh: bool = False
|
|
47
|
+
render: bool = True
|
|
48
|
+
thresholds: Thresholds = field(default_factory=Thresholds)
|
|
49
|
+
demo: bool = False
|
|
50
|
+
lang: str = "en" # language of the progress lines only; results never depend on it
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- document discovery -------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def find_pdfs(path: Path) -> list[tuple[str, Path]]:
|
|
57
|
+
"""Return (doc_id, file) pairs sorted by doc_id. doc_id is relative to the input folder."""
|
|
58
|
+
if not path.exists():
|
|
59
|
+
raise UsageError("usage.no_such", path=path)
|
|
60
|
+
if path.is_file():
|
|
61
|
+
if path.suffix.lower() != ".pdf":
|
|
62
|
+
raise UsageError("usage.not_pdf", path=path)
|
|
63
|
+
return [(path.name, path)]
|
|
64
|
+
found = sorted(
|
|
65
|
+
((p.relative_to(path).as_posix(), p) for p in path.rglob("*") if p.is_file() and p.suffix.lower() == ".pdf"),
|
|
66
|
+
key=lambda pair: pair[0],
|
|
67
|
+
)
|
|
68
|
+
if not found:
|
|
69
|
+
raise UsageError("usage.no_pdfs", path=path)
|
|
70
|
+
return found
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def make_slugs(doc_ids: list[str]) -> list[str]:
|
|
74
|
+
"""Filesystem- and URL-safe names derived from the relative path; duplicates get -2, -3 ..."""
|
|
75
|
+
slugs: list[str] = []
|
|
76
|
+
seen: dict[str, int] = {}
|
|
77
|
+
for doc_id in doc_ids:
|
|
78
|
+
base = re.sub(r"[^A-Za-z0-9._-]+", "_", re.sub(r"\.pdf$", "", doc_id, flags=re.I)).strip("._") or "doc"
|
|
79
|
+
seen[base] = seen.get(base, 0) + 1
|
|
80
|
+
slugs.append(base if seen[base] == 1 else f"{base}-{seen[base]}")
|
|
81
|
+
return slugs
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def file_sha256(path: Path) -> str:
|
|
85
|
+
digest = hashlib.sha256()
|
|
86
|
+
with path.open("rb") as fh:
|
|
87
|
+
for block in iter(lambda: fh.read(1 << 20), b""):
|
|
88
|
+
digest.update(block)
|
|
89
|
+
return digest.hexdigest()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --- parser selection ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def select_parsers(
|
|
96
|
+
requested: list[str] | None, customs: list[CustomSpec]
|
|
97
|
+
) -> tuple[list[ParserSpec], list[Skipped]]:
|
|
98
|
+
"""Decide which parsers run. Missing optional parsers are skipped (with an install hint)."""
|
|
99
|
+
names = list(dict.fromkeys(requested)) if requested else [n for n, i in REGISTRY.items() if i.auto]
|
|
100
|
+
unknown = [n for n in names if n not in REGISTRY]
|
|
101
|
+
if unknown:
|
|
102
|
+
raise UsageError("usage.unknown_parsers", unknown=", ".join(unknown), known=", ".join(REGISTRY))
|
|
103
|
+
selected: list[ParserSpec] = []
|
|
104
|
+
skipped: list[Skipped] = []
|
|
105
|
+
for name in REGISTRY: # registry order, so reports always list parsers in the same order
|
|
106
|
+
info = REGISTRY[name]
|
|
107
|
+
if name not in names:
|
|
108
|
+
if not requested and not info.auto:
|
|
109
|
+
skipped.append(Skipped(name, t("skip.opt_in", name=name), "skip.opt_in", {"name": name}))
|
|
110
|
+
continue
|
|
111
|
+
if is_installed(name):
|
|
112
|
+
selected.append(ParserSpec(name))
|
|
113
|
+
else:
|
|
114
|
+
skipped.append(Skipped(
|
|
115
|
+
name, t("skip.not_installed", hint=info.install_hint or t("skip.install_first")),
|
|
116
|
+
"skip.not_installed", {"name": name},
|
|
117
|
+
))
|
|
118
|
+
selected += [ParserSpec(c.name, c) for c in customs]
|
|
119
|
+
return selected, skipped
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --- running one parser -------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _launch(cmd: list[str], timeout: float, env: dict[str, str]) -> tuple[int | None, bytes]:
|
|
126
|
+
"""Run a worker. Returns (exit code or None on timeout, stderr bytes). Kills it on timeout."""
|
|
127
|
+
try:
|
|
128
|
+
done = subprocess.run(cmd, capture_output=True, timeout=timeout, env=env, check=False)
|
|
129
|
+
return done.returncode, done.stderr
|
|
130
|
+
except subprocess.TimeoutExpired as exc:
|
|
131
|
+
return None, exc.stderr or b""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _scrub(text: str, roots: dict[str, str]) -> str:
|
|
135
|
+
"""Replace absolute paths with placeholders so reports can be shared and compared."""
|
|
136
|
+
for root in sorted(roots, key=len, reverse=True):
|
|
137
|
+
if root:
|
|
138
|
+
text = text.replace(root, roots[root])
|
|
139
|
+
return text
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _cache_key(sha: str, spec: ParserSpec, version: str, pages: list[int]) -> dict:
|
|
143
|
+
return {"schema": CACHE_SCHEMA, "sha256": sha, "parser": spec.name, "version": version, "pages": pages}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run_parser(
|
|
147
|
+
spec: ParserSpec,
|
|
148
|
+
pdf: Path,
|
|
149
|
+
sha: str,
|
|
150
|
+
pages: list[int],
|
|
151
|
+
raw_dir: Path,
|
|
152
|
+
options: RunOptions,
|
|
153
|
+
roots: dict[str, str],
|
|
154
|
+
) -> ParserOutput:
|
|
155
|
+
raw_dir.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
final = raw_dir / f"{spec.name}.json"
|
|
157
|
+
version = "custom" if spec.custom else (installed_version(spec.name) or "")
|
|
158
|
+
key = _cache_key(sha, spec, version, pages)
|
|
159
|
+
# custom parsers are never cached: their code can change without any version change
|
|
160
|
+
cacheable = not spec.custom and bool(version)
|
|
161
|
+
|
|
162
|
+
if cacheable and not options.fresh and final.is_file():
|
|
163
|
+
try:
|
|
164
|
+
stored = json.loads(final.read_text(encoding="utf-8"))
|
|
165
|
+
if stored.get("key") == key and stored["output"]["status"] == "ok":
|
|
166
|
+
return ParserOutput(**{**stored["output"], "cached": True})
|
|
167
|
+
except (ValueError, KeyError, TypeError):
|
|
168
|
+
pass # unreadable cache entry: just run the parser again
|
|
169
|
+
|
|
170
|
+
tmp = raw_dir / f"{spec.name}.running.json"
|
|
171
|
+
tmp.unlink(missing_ok=True)
|
|
172
|
+
cmd = [sys.executable, "-m", "parseoff.worker", "--pdf", str(pdf), "--pages", ",".join(map(str, pages)), "--out", str(tmp)]
|
|
173
|
+
cmd += ["--custom", spec.custom.spec] if spec.custom else ["--parser", spec.name]
|
|
174
|
+
env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PARSEOFF_PATH": os.pathsep.join(p for p in [os.environ.get("PARSEOFF_PATH", ""), *sys.path] if p)}
|
|
175
|
+
code, raw_err = _launch(cmd, options.timeout, env)
|
|
176
|
+
stderr = _scrub(raw_err.decode("utf-8", "replace"), roots)[-STDERR_LIMIT:]
|
|
177
|
+
|
|
178
|
+
if code is None:
|
|
179
|
+
output = ParserOutput(
|
|
180
|
+
spec.name, "timeout", version, float(options.timeout), [],
|
|
181
|
+
t("err.timeout", seconds=options.timeout), stderr,
|
|
182
|
+
error_key="err.timeout", error_params={"seconds": options.timeout},
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
try:
|
|
186
|
+
data = json.loads(tmp.read_text(encoding="utf-8"))
|
|
187
|
+
error = _scrub(str(data.get("error", "")), roots)
|
|
188
|
+
output = ParserOutput(
|
|
189
|
+
spec.name, "error" if error else "ok", data.get("version") or version,
|
|
190
|
+
float(data.get("seconds", 0.0)), list(data.get("pages", [])), error, stderr,
|
|
191
|
+
)
|
|
192
|
+
except (OSError, ValueError):
|
|
193
|
+
output = ParserOutput(
|
|
194
|
+
spec.name, "error", version, 0.0, [],
|
|
195
|
+
t("err.worker_exit", code=code), stderr,
|
|
196
|
+
error_key="err.worker_exit", error_params={"code": code},
|
|
197
|
+
)
|
|
198
|
+
tmp.unlink(missing_ok=True)
|
|
199
|
+
stored = {"key": key, "output": {k: v for k, v in output.__dict__.items() if k != "cached"}}
|
|
200
|
+
final.write_text(json.dumps(stored, ensure_ascii=True, indent=1, sort_keys=True), encoding="utf-8")
|
|
201
|
+
return output
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --- the whole pipeline -------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def run_pipeline(options: RunOptions, progress: Callable[[str], None] = lambda _msg: None) -> RunReport:
|
|
208
|
+
"""Run every selected parser over every PDF and return a report with metrics and findings."""
|
|
209
|
+
lang = options.lang
|
|
210
|
+
docs = find_pdfs(options.input_path)
|
|
211
|
+
specs, skipped = select_parsers(options.parsers, options.customs)
|
|
212
|
+
slugs = make_slugs([doc_id for doc_id, _ in docs])
|
|
213
|
+
root = options.input_path.resolve()
|
|
214
|
+
roots = {
|
|
215
|
+
str(root): "<input>", str(options.out_dir.resolve()): "<out>", os.getcwd(): "<cwd>",
|
|
216
|
+
str(Path.home()): "~", sys.prefix: "<python>", sys.base_prefix: "<python>",
|
|
217
|
+
}
|
|
218
|
+
img_dir = options.out_dir / "report" / "img"
|
|
219
|
+
reports: list[DocReport] = []
|
|
220
|
+
|
|
221
|
+
for (doc_id, pdf), slug in zip(docs, slugs):
|
|
222
|
+
doc = DocReport(doc_id=doc_id, slug=slug)
|
|
223
|
+
reports.append(doc)
|
|
224
|
+
try:
|
|
225
|
+
doc.sha256 = file_sha256(pdf)
|
|
226
|
+
doc.total_pages = textlayer.page_count(pdf)
|
|
227
|
+
if doc.total_pages == 0:
|
|
228
|
+
raise ValueError("the PDF has no pages")
|
|
229
|
+
last = min(doc.total_pages, options.max_pages or doc.total_pages)
|
|
230
|
+
doc.pages = list(range(1, last + 1))
|
|
231
|
+
layer = textlayer.layer_texts(pdf, doc.pages)
|
|
232
|
+
except Exception as exc: # noqa: BLE001 - one unreadable file must not stop the run
|
|
233
|
+
doc.open_error = _scrub(f"{type(exc).__name__}: {exc}", roots)
|
|
234
|
+
doc.pages = []
|
|
235
|
+
progress(t("progress.cannot_open", lang, doc=doc_id, error=doc.open_error))
|
|
236
|
+
continue
|
|
237
|
+
doc.layer_chars = [len(t.strip()) for t in layer]
|
|
238
|
+
if options.render:
|
|
239
|
+
try:
|
|
240
|
+
textlayer.render_pages(pdf, doc.pages, img_dir, slug)
|
|
241
|
+
doc.rendered = True
|
|
242
|
+
except Exception as exc: # noqa: BLE001 - images are a convenience, not required
|
|
243
|
+
progress(t("progress.images_skipped", lang, doc=doc_id, error=type(exc).__name__))
|
|
244
|
+
for spec in specs:
|
|
245
|
+
out = run_parser(spec, pdf, doc.sha256, doc.pages, options.out_dir / "raw" / slug, options, roots)
|
|
246
|
+
doc.outputs[spec.name] = out
|
|
247
|
+
note = t("progress.cached", lang) if out.cached else ""
|
|
248
|
+
progress(t("progress.parser", lang, doc=doc_id, parser=spec.name, status=out.status,
|
|
249
|
+
seconds=out.seconds, cached=note))
|
|
250
|
+
if out.status != "ok" and out.error.strip():
|
|
251
|
+
# show the cause in the terminal too, not only in summary.md
|
|
252
|
+
# (our own messages are translated; text from a parser or library stays as it is)
|
|
253
|
+
cause = t(out.error_key, lang, **out.error_params) if out.error_key else out.error.strip()
|
|
254
|
+
progress(f" {cause.splitlines()[0][:160]}")
|
|
255
|
+
ok = {n: o.pages for n, o in doc.outputs.items() if o.status == "ok"}
|
|
256
|
+
doc.metrics = metrics.compute_doc_metrics(doc.pages, layer, ok)
|
|
257
|
+
|
|
258
|
+
report = RunReport(
|
|
259
|
+
docs=reports, parsers=[s.name for s in specs], skipped=skipped,
|
|
260
|
+
thresholds=options.thresholds, demo=options.demo,
|
|
261
|
+
)
|
|
262
|
+
report.findings = findings.build_findings(report)
|
|
263
|
+
return report
|
parseoff/samples.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Deterministic synthetic PDFs used by `parseoff demo`.
|
|
2
|
+
|
|
3
|
+
They are practice documents written for this tool (original English sentences, no real data).
|
|
4
|
+
Each one reproduces one well-known parser pitfall, so the demo shows real parsers misbehaving
|
|
5
|
+
on purpose-built input rather than replaying fake output:
|
|
6
|
+
|
|
7
|
+
* report-table.pdf - a grid table plus a running header/footer on every page.
|
|
8
|
+
* two-column.pdf - two text columns whose right column is drawn first in the content stream.
|
|
9
|
+
* scanned-like.pdf - a text page followed by a page that holds only shapes and a raster image.
|
|
10
|
+
|
|
11
|
+
reportlab's `invariant=1` fixes creation dates and document ids, so the bytes are reproducible.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import textwrap
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from PIL import Image
|
|
20
|
+
from reportlab.lib.pagesizes import A4
|
|
21
|
+
from reportlab.lib.utils import ImageReader
|
|
22
|
+
from reportlab.pdfgen.canvas import Canvas
|
|
23
|
+
|
|
24
|
+
SAMPLE_NAMES = ("report-table.pdf", "two-column.pdf", "scanned-like.pdf")
|
|
25
|
+
PAGE_COUNTS = {"report-table.pdf": 3, "two-column.pdf": 2, "scanned-like.pdf": 2}
|
|
26
|
+
|
|
27
|
+
_W, _H = A4
|
|
28
|
+
_LEFT = 56
|
|
29
|
+
_BODY = "Helvetica"
|
|
30
|
+
|
|
31
|
+
_PARAGRAPH = (
|
|
32
|
+
"This practice document was written for the parseoff demo. It contains plain English "
|
|
33
|
+
"sentences so that every parser has something to read, and a few structures that "
|
|
34
|
+
"parsers handle differently from one another."
|
|
35
|
+
)
|
|
36
|
+
_NOTES = (
|
|
37
|
+
"Section notes follow here. Parsers that read the content stream in drawing order, "
|
|
38
|
+
"parsers that sort text by position and parsers that build layout boxes can disagree "
|
|
39
|
+
"about the order of the same words."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _canvas(path: Path) -> Canvas:
|
|
44
|
+
# invariant=1 -> no timestamps or random ids in the file, so the bytes are reproducible
|
|
45
|
+
return Canvas(str(path), pagesize=A4, invariant=1, pageCompression=0)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _lines(c: Canvas, x: float, y: float, text: str, width: int = 88, size: int = 10) -> float:
|
|
49
|
+
"""Draw wrapped text from (x, y) downwards; return the next free y."""
|
|
50
|
+
c.setFont(_BODY, size)
|
|
51
|
+
for line in textwrap.wrap(text, width=width):
|
|
52
|
+
c.drawString(x, y, line)
|
|
53
|
+
y -= size + 4
|
|
54
|
+
return y
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _frame(c: Canvas, page: int, total: int) -> None:
|
|
58
|
+
"""Running header and page-number footer, identical on every page (boilerplate lesson)."""
|
|
59
|
+
c.setFont(_BODY, 9)
|
|
60
|
+
c.drawString(_LEFT, _H - 36, "ACME Handbook - draft")
|
|
61
|
+
c.drawString(_LEFT, 30, f"Page {page} of {total}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _report_table(path: Path) -> None:
|
|
65
|
+
c = _canvas(path)
|
|
66
|
+
total = 3
|
|
67
|
+
# page 1: title, headings, paragraph, a 3 x 4 grid table
|
|
68
|
+
_frame(c, 1, total)
|
|
69
|
+
c.setFont("Helvetica-Bold", 20)
|
|
70
|
+
c.drawString(_LEFT, _H - 90, "Quarterly Report")
|
|
71
|
+
c.setFont("Helvetica-Bold", 14)
|
|
72
|
+
c.drawString(_LEFT, _H - 125, "1. Overview")
|
|
73
|
+
y = _lines(c, _LEFT, _H - 145, _PARAGRAPH)
|
|
74
|
+
c.setFont("Helvetica-Bold", 14)
|
|
75
|
+
c.drawString(_LEFT, y - 16, "2. Numbers")
|
|
76
|
+
rows = [
|
|
77
|
+
["Region", "Q1", "Q2"],
|
|
78
|
+
["North", "120", "135"],
|
|
79
|
+
["South", "98", "101"],
|
|
80
|
+
["East", "77", "90"],
|
|
81
|
+
]
|
|
82
|
+
col_w, row_h, top = 110, 24, y - 40
|
|
83
|
+
c.setLineWidth(0.8)
|
|
84
|
+
for r, row in enumerate(rows):
|
|
85
|
+
for k, cell in enumerate(row):
|
|
86
|
+
x = _LEFT + k * col_w
|
|
87
|
+
c.rect(x, top - (r + 1) * row_h, col_w, row_h) # grid lines -> a ruled table
|
|
88
|
+
c.setFont(_BODY, 10)
|
|
89
|
+
c.drawString(x + 8, top - (r + 1) * row_h + 8, cell)
|
|
90
|
+
c.showPage()
|
|
91
|
+
# pages 2 and 3: a heading and a paragraph each
|
|
92
|
+
for page, heading in ((2, "3. Method"), (3, "4. Notes")):
|
|
93
|
+
_frame(c, page, total)
|
|
94
|
+
c.setFont("Helvetica-Bold", 14)
|
|
95
|
+
c.drawString(_LEFT, _H - 90, heading)
|
|
96
|
+
_lines(c, _LEFT, _H - 112, _NOTES if page == 3 else _PARAGRAPH + " " + _NOTES)
|
|
97
|
+
c.showPage()
|
|
98
|
+
c.save()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_WORDS = (
|
|
102
|
+
"river stone lantern copper meadow signal harbor velvet orchid timber anchor ember "
|
|
103
|
+
"falcon quartz willow marble thistle cobalt saffron juniper pebble zephyr mosaic"
|
|
104
|
+
).split()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _column_line(tag: str, i: int) -> str:
|
|
108
|
+
# three distinct words per line so reordering is visible to a word-order comparison
|
|
109
|
+
w = [_WORDS[(i * 3 + k) % len(_WORDS)] for k in range(3)]
|
|
110
|
+
return f"{tag}{i:02d} {w[0]} {w[1]} {w[2]}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _two_column(path: Path) -> None:
|
|
114
|
+
c = _canvas(path)
|
|
115
|
+
left_x, right_x, top, step = _LEFT, 320, _H - 100, 18
|
|
116
|
+
c.setFont("Helvetica-Bold", 14)
|
|
117
|
+
# page 1: two columns; the RIGHT column is drawn first, so stream order is R01..R12, L01..L12
|
|
118
|
+
for tag, x in (("R", right_x), ("L", left_x)):
|
|
119
|
+
c.setFont(_BODY, 10)
|
|
120
|
+
for i in range(1, 13):
|
|
121
|
+
c.drawString(x, top - (i - 1) * step, _column_line(tag, i))
|
|
122
|
+
c.showPage()
|
|
123
|
+
# page 2: an ordinary single column, where every parser should agree
|
|
124
|
+
c.setFont("Helvetica-Bold", 14)
|
|
125
|
+
c.drawString(_LEFT, _H - 90, "Single column page")
|
|
126
|
+
_lines(c, _LEFT, _H - 112, (_PARAGRAPH + " " + _NOTES) * 2)
|
|
127
|
+
c.showPage()
|
|
128
|
+
c.save()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _scanned_like(path: Path) -> None:
|
|
132
|
+
c = _canvas(path)
|
|
133
|
+
c.setFont("Helvetica-Bold", 14)
|
|
134
|
+
c.drawString(_LEFT, _H - 90, "Scanned appendix")
|
|
135
|
+
_lines(c, _LEFT, _H - 112, "This first page has a real text layer. " + _PARAGRAPH)
|
|
136
|
+
c.showPage()
|
|
137
|
+
# page 2: no text at all - a raster image of a "scan" and some shapes
|
|
138
|
+
img = Image.new("L", (160, 220), 255)
|
|
139
|
+
pix = img.load()
|
|
140
|
+
assert pix is not None
|
|
141
|
+
for yy in range(220):
|
|
142
|
+
for xx in range(160):
|
|
143
|
+
if (yy // 20) % 2 == 0 and 12 < xx < 148: # horizontal "text bars"
|
|
144
|
+
pix[xx, yy] = 40 if (xx // 6 + yy // 4) % 3 else 140
|
|
145
|
+
c.drawImage(ImageReader(img), _LEFT, _H - 330, width=320, height=440)
|
|
146
|
+
c.rect(_LEFT - 8, _H - 340, 340, 460)
|
|
147
|
+
c.showPage()
|
|
148
|
+
c.save()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def create_samples(out_dir: Path) -> list[Path]:
|
|
152
|
+
"""Write the three sample PDFs into `out_dir` and return their paths (sorted by name)."""
|
|
153
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
builders = {
|
|
155
|
+
"report-table.pdf": _report_table,
|
|
156
|
+
"two-column.pdf": _two_column,
|
|
157
|
+
"scanned-like.pdf": _scanned_like,
|
|
158
|
+
}
|
|
159
|
+
paths = []
|
|
160
|
+
for name in SAMPLE_NAMES:
|
|
161
|
+
path = out_dir / name
|
|
162
|
+
builders[name](path)
|
|
163
|
+
paths.append(path)
|
|
164
|
+
return sorted(paths)
|
parseoff/textlayer.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""PDF text-layer baseline and page images via pypdfium2 (BSD-3 / Apache-2.0)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def page_count(pdf_path: Path) -> int:
|
|
9
|
+
"""Number of pages; raises an exception with a readable message if the PDF cannot be opened."""
|
|
10
|
+
import pypdfium2 as pdfium
|
|
11
|
+
|
|
12
|
+
pdf = pdfium.PdfDocument(str(pdf_path))
|
|
13
|
+
try:
|
|
14
|
+
return len(pdf)
|
|
15
|
+
finally:
|
|
16
|
+
pdf.close()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def layer_texts(pdf_path: Path, pages: list[int]) -> list[str]:
|
|
20
|
+
"""Text layer per requested page. An empty string means the page has no extractable text."""
|
|
21
|
+
import pypdfium2 as pdfium
|
|
22
|
+
|
|
23
|
+
pdf = pdfium.PdfDocument(str(pdf_path))
|
|
24
|
+
try:
|
|
25
|
+
out = []
|
|
26
|
+
for number in pages:
|
|
27
|
+
textpage = pdf[number - 1].get_textpage()
|
|
28
|
+
out.append(textpage.get_text_range())
|
|
29
|
+
textpage.close()
|
|
30
|
+
return out
|
|
31
|
+
finally:
|
|
32
|
+
pdf.close()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def render_pages(pdf_path: Path, pages: list[int], out_dir: Path, prefix: str, scale: float = 1.0) -> None:
|
|
36
|
+
"""Write ``<prefix>-p<N>.png`` for each page (low resolution on purpose: reports stay small)."""
|
|
37
|
+
import pypdfium2 as pdfium
|
|
38
|
+
|
|
39
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
pdf = pdfium.PdfDocument(str(pdf_path))
|
|
41
|
+
try:
|
|
42
|
+
for number in pages:
|
|
43
|
+
image = pdf[number - 1].render(scale=scale).to_pil()
|
|
44
|
+
image.save(out_dir / f"{prefix}-p{number}.png")
|
|
45
|
+
finally:
|
|
46
|
+
pdf.close()
|
parseoff/worker.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Subprocess entry point: ``python -m parseoff.worker`` runs ONE parser on ONE PDF.
|
|
2
|
+
|
|
3
|
+
Running each (document, parser) pair in its own process isolates crashes, native-library noise on
|
|
4
|
+
stderr and hangs - the parent enforces a timeout and simply kills the process.
|
|
5
|
+
The result is written as JSON to ``--out``; a parser exception is captured into the JSON, not raised.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _version(parser: str, custom: bool) -> str:
|
|
18
|
+
if custom:
|
|
19
|
+
return "custom"
|
|
20
|
+
from .registry import installed_version
|
|
21
|
+
|
|
22
|
+
return installed_version(parser) or ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: list[str] | None = None) -> int:
|
|
26
|
+
ap = argparse.ArgumentParser(prog="python -m parseoff.worker")
|
|
27
|
+
ap.add_argument("--pdf", required=True)
|
|
28
|
+
ap.add_argument("--pages", required=True, help="comma separated 1-based page numbers")
|
|
29
|
+
ap.add_argument("--out", required=True)
|
|
30
|
+
group = ap.add_mutually_exclusive_group(required=True)
|
|
31
|
+
group.add_argument("--parser")
|
|
32
|
+
group.add_argument("--custom", help="NAME=module:function")
|
|
33
|
+
args = ap.parse_args(argv)
|
|
34
|
+
|
|
35
|
+
pages = [int(p) for p in args.pages.split(",") if p]
|
|
36
|
+
result: dict = {"pages": [], "seconds": 0.0, "error": "", "version": ""}
|
|
37
|
+
started = time.perf_counter()
|
|
38
|
+
try:
|
|
39
|
+
if args.custom:
|
|
40
|
+
from .adapters.custom import load, parse_spec
|
|
41
|
+
|
|
42
|
+
spec = parse_spec(args.custom)
|
|
43
|
+
convert, name = load(spec), spec.name
|
|
44
|
+
else:
|
|
45
|
+
from .adapters import get_adapter
|
|
46
|
+
|
|
47
|
+
convert, name = get_adapter(args.parser), args.parser
|
|
48
|
+
result["version"] = _version(name, bool(args.custom))
|
|
49
|
+
texts = convert(args.pdf, pages)
|
|
50
|
+
if not isinstance(texts, list) or not all(isinstance(t, str) for t in texts):
|
|
51
|
+
raise TypeError("a parser must return a list of strings, one per requested page")
|
|
52
|
+
if len(texts) != len(pages):
|
|
53
|
+
raise ValueError(f"parser returned {len(texts)} page(s) but {len(pages)} were requested")
|
|
54
|
+
result["pages"] = texts
|
|
55
|
+
except Exception as exc: # noqa: BLE001 - every parser failure must be reported, not raised
|
|
56
|
+
result["error"] = f"{type(exc).__name__}: {exc}"
|
|
57
|
+
result["seconds"] = round(time.perf_counter() - started, 3)
|
|
58
|
+
Path(args.out).write_text(json.dumps(result, ensure_ascii=True), encoding="utf-8")
|
|
59
|
+
return 1 if result["error"] else 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
sys.exit(main())
|