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