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/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Parser adapters.
|
|
2
|
+
|
|
3
|
+
An adapter is a function ``convert(pdf_path: str, pages: list[int]) -> list[str]`` that returns one
|
|
4
|
+
Markdown/plain-text string per requested page (``pages`` are 1-based). Adapters import their parser
|
|
5
|
+
lazily so that a missing optional parser never breaks the others.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
from .builtin import pdfminer_convert, pdfplumber_convert, pypdf_convert
|
|
13
|
+
from .optional import docling_convert, markitdown_convert, pymupdf4llm_convert
|
|
14
|
+
|
|
15
|
+
Adapter = Callable[[str, list[int]], list[str]]
|
|
16
|
+
|
|
17
|
+
ADAPTERS: dict[str, Adapter] = {
|
|
18
|
+
"pypdf": pypdf_convert,
|
|
19
|
+
"pdfplumber": pdfplumber_convert,
|
|
20
|
+
"pdfminer": pdfminer_convert,
|
|
21
|
+
"markitdown": markitdown_convert,
|
|
22
|
+
"pymupdf4llm": pymupdf4llm_convert,
|
|
23
|
+
"docling": docling_convert,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_adapter(name: str) -> Adapter:
|
|
28
|
+
try:
|
|
29
|
+
return ADAPTERS[name]
|
|
30
|
+
except KeyError:
|
|
31
|
+
known = ", ".join(sorted(ADAPTERS))
|
|
32
|
+
raise ValueError(f"unknown parser {name!r}; known parsers: {known}") from None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Helpers shared by the adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def check_pages(pages: list[int], total: int) -> None:
|
|
7
|
+
"""Raise a helpful error if a requested page is outside the document."""
|
|
8
|
+
bad = [p for p in pages if p < 1 or p > total]
|
|
9
|
+
if bad:
|
|
10
|
+
raise ValueError(f"page(s) {bad} out of range: the PDF has {total} page(s) (pages are 1-based)")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Adapters for the parsers installed together with parseoff: pypdf, pdfplumber, pdfminer.six."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._common import check_pages
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def pypdf_convert(pdf_path: str, pages: list[int]) -> list[str]:
|
|
9
|
+
"""Plain text in content-stream order (pypdf's default). No structure is reconstructed."""
|
|
10
|
+
from pypdf import PdfReader
|
|
11
|
+
|
|
12
|
+
reader = PdfReader(pdf_path)
|
|
13
|
+
check_pages(pages, len(reader.pages))
|
|
14
|
+
return [(reader.pages[p - 1].extract_text() or "").strip() for p in pages]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _gfm_table(rows: list[list[str | None]]) -> str:
|
|
18
|
+
"""Render extracted table rows as a GitHub-flavoured Markdown table (first row = header)."""
|
|
19
|
+
|
|
20
|
+
def cell(value: str | None) -> str:
|
|
21
|
+
return " ".join((value or "").split()).replace("|", "\\|")
|
|
22
|
+
|
|
23
|
+
clean = [[cell(c) for c in row] for row in rows if row]
|
|
24
|
+
width = max(len(r) for r in clean)
|
|
25
|
+
clean = [r + [""] * (width - len(r)) for r in clean]
|
|
26
|
+
lines = ["| " + " | ".join(clean[0]) + " |", "| " + " | ".join(["---"] * width) + " |"]
|
|
27
|
+
lines += ["| " + " | ".join(r) + " |" for r in clean[1:]]
|
|
28
|
+
return "\n".join(lines)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def pdfplumber_convert(pdf_path: str, pages: list[int]) -> list[str]:
|
|
32
|
+
"""Text by position, with detected tables rendered as GFM tables at their vertical position.
|
|
33
|
+
|
|
34
|
+
Characters inside a table are removed from the running text so cell values appear once.
|
|
35
|
+
"""
|
|
36
|
+
import pdfplumber
|
|
37
|
+
|
|
38
|
+
out: list[str] = []
|
|
39
|
+
with pdfplumber.open(pdf_path) as pdf:
|
|
40
|
+
check_pages(pages, len(pdf.pages))
|
|
41
|
+
for number in pages:
|
|
42
|
+
page = pdf.pages[number - 1]
|
|
43
|
+
tables = page.find_tables()
|
|
44
|
+
boxes = [t.bbox for t in tables]
|
|
45
|
+
|
|
46
|
+
def outside(obj: dict, boxes=boxes) -> bool:
|
|
47
|
+
if obj.get("object_type") != "char":
|
|
48
|
+
return True
|
|
49
|
+
cx, cy = (obj["x0"] + obj["x1"]) / 2, (obj["top"] + obj["bottom"]) / 2
|
|
50
|
+
return not any(x0 <= cx <= x1 and top <= cy <= bottom for x0, top, x1, bottom in boxes)
|
|
51
|
+
|
|
52
|
+
body = page.filter(outside) if boxes else page
|
|
53
|
+
blocks = [(ln["top"], ln["text"]) for ln in body.extract_text_lines(return_chars=False)]
|
|
54
|
+
blocks += [(t.bbox[1], _gfm_table(t.extract())) for t in tables if t.extract()]
|
|
55
|
+
out.append("\n".join(text for _, text in sorted(blocks, key=lambda b: b[0])))
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pdfminer_convert(pdf_path: str, pages: list[int]) -> list[str]:
|
|
60
|
+
"""Layout-box text from pdfminer.six (the engine behind markitdown's PDF path)."""
|
|
61
|
+
from pdfminer.high_level import extract_pages
|
|
62
|
+
from pdfminer.layout import LTTextContainer
|
|
63
|
+
from pypdf import PdfReader
|
|
64
|
+
|
|
65
|
+
check_pages(pages, len(PdfReader(pdf_path).pages))
|
|
66
|
+
by_page: dict[int, str] = {}
|
|
67
|
+
for number, layout in zip(sorted(set(pages)), extract_pages(pdf_path, page_numbers=[p - 1 for p in sorted(set(pages))])):
|
|
68
|
+
boxes = [el.get_text().strip() for el in layout if isinstance(el, LTTextContainer)]
|
|
69
|
+
by_page[number] = "\n\n".join(b for b in boxes if b)
|
|
70
|
+
return [by_page.get(p, "") for p in pages]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""User-supplied parsers: ``--custom NAME=module:function``.
|
|
2
|
+
|
|
3
|
+
The function receives ``(pdf_path: str, page_numbers: list[int])`` (1-based pages) and must return
|
|
4
|
+
one string per requested page. It runs inside the worker subprocess, never in the main process.
|
|
5
|
+
Only the module you name is imported - make sure you trust it, it can run any code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from ..i18n import LocalizedError
|
|
18
|
+
from ..registry import BUILTIN_NAMES
|
|
19
|
+
|
|
20
|
+
_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,31}$")
|
|
21
|
+
_DOTTED = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class CustomSpec:
|
|
26
|
+
name: str
|
|
27
|
+
module: str
|
|
28
|
+
function: str
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def spec(self) -> str:
|
|
32
|
+
return f"{self.name}={self.module}:{self.function}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SpecError(LocalizedError, ValueError):
|
|
36
|
+
"""A malformed ``--custom`` value. The message is English; ``text(lang)`` translates it."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_spec(text: str) -> CustomSpec:
|
|
40
|
+
"""Parse ``NAME=module:function``; raise SpecError (a ValueError) with the fix on a malformed value."""
|
|
41
|
+
name, sep, target = text.partition("=")
|
|
42
|
+
module, colon, function = target.partition(":")
|
|
43
|
+
if not (sep and colon and name and module and function):
|
|
44
|
+
raise SpecError("custom.bad_value", text=text)
|
|
45
|
+
if not _NAME.match(name):
|
|
46
|
+
raise SpecError("custom.bad_name", name=name)
|
|
47
|
+
if name in BUILTIN_NAMES:
|
|
48
|
+
raise SpecError("custom.taken", name=name)
|
|
49
|
+
if not _DOTTED.match(module) or not function.isidentifier():
|
|
50
|
+
raise SpecError("custom.bad_target", target=target)
|
|
51
|
+
return CustomSpec(name, module, function)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load(spec: CustomSpec) -> Callable[[str, list[int]], list[str]]:
|
|
55
|
+
"""Import the user's module (current directory and PARSEOFF_PATH are searched) and get the function."""
|
|
56
|
+
for extra in [os.getcwd(), *os.environ.get("PARSEOFF_PATH", "").split(os.pathsep)]:
|
|
57
|
+
if extra and extra not in sys.path:
|
|
58
|
+
sys.path.append(extra)
|
|
59
|
+
try:
|
|
60
|
+
module = importlib.import_module(spec.module)
|
|
61
|
+
except ImportError as exc:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
f"cannot import module {spec.module!r} for custom parser {spec.name!r}: {exc}. "
|
|
64
|
+
"Run parseoff from the folder that contains it or install the package."
|
|
65
|
+
) from exc
|
|
66
|
+
func = getattr(module, spec.function, None)
|
|
67
|
+
if not callable(func):
|
|
68
|
+
raise RuntimeError(f"module {spec.module!r} has no callable {spec.function!r}")
|
|
69
|
+
return func
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Adapters for parsers that are NOT part of parseoff's own dependencies.
|
|
2
|
+
|
|
3
|
+
Nothing from these packages is bundled; each adapter imports its parser inside the function and
|
|
4
|
+
only runs when the user installed it. An import failure is reported as a clear error message.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ._common import check_pages
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _require(module: str, hint: str):
|
|
16
|
+
import importlib
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
return importlib.import_module(module)
|
|
20
|
+
except ImportError as exc:
|
|
21
|
+
raise RuntimeError(f"{module} is not installed ({hint})") from exc
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def markitdown_convert(pdf_path: str, pages: list[int]) -> list[str]:
|
|
25
|
+
"""markitdown only returns one string per file, so each page is split out and converted alone."""
|
|
26
|
+
mod = _require("markitdown", 'uv sync --extra markitdown')
|
|
27
|
+
from pypdf import PdfReader, PdfWriter
|
|
28
|
+
|
|
29
|
+
reader = PdfReader(pdf_path)
|
|
30
|
+
check_pages(pages, len(reader.pages))
|
|
31
|
+
converter = mod.MarkItDown(enable_plugins=False)
|
|
32
|
+
out: list[str] = []
|
|
33
|
+
with tempfile.TemporaryDirectory(prefix="parseoff-") as tmp:
|
|
34
|
+
for number in pages:
|
|
35
|
+
writer = PdfWriter()
|
|
36
|
+
writer.add_page(reader.pages[number - 1])
|
|
37
|
+
single = Path(tmp) / f"page-{number}.pdf"
|
|
38
|
+
with single.open("wb") as fh:
|
|
39
|
+
writer.write(fh)
|
|
40
|
+
out.append((converter.convert(str(single)).text_content or "").strip())
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def pymupdf4llm_convert(pdf_path: str, pages: list[int]) -> list[str]:
|
|
45
|
+
"""pymupdf4llm (AGPL-3.0, user-installed). `page_chunks=True` returns one dict per page."""
|
|
46
|
+
mod = _require("pymupdf4llm", "uv pip install pymupdf4llm - AGPL-3.0, check the licence terms")
|
|
47
|
+
chunks = mod.to_markdown(pdf_path, pages=[p - 1 for p in pages], page_chunks=True)
|
|
48
|
+
if len(chunks) != len(pages):
|
|
49
|
+
raise RuntimeError(f"pymupdf4llm returned {len(chunks)} page chunk(s) for {len(pages)} page(s)")
|
|
50
|
+
return [str(chunk.get("text", "")).strip() for chunk in chunks]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def docling_convert(pdf_path: str, pages: list[int]) -> list[str]:
|
|
54
|
+
"""docling (experimental, opt-in). Downloads models on first run unless they are present."""
|
|
55
|
+
mod = _require("docling.document_converter", 'uv sync --extra docling')
|
|
56
|
+
converter = mod.DocumentConverter()
|
|
57
|
+
document = converter.convert(pdf_path).document
|
|
58
|
+
return [document.export_to_markdown(page_no=p).strip() for p in pages]
|
parseoff/cli.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Command line interface: `parseoff doctor | run | demo`.
|
|
2
|
+
|
|
3
|
+
Screen language: ``--lang en|ko`` > ``PARSEOFF_LANG`` > ``LC_ALL`` / ``LC_MESSAGES`` / ``LANG`` (see i18n.py).
|
|
4
|
+
The language is read from the command line *before* the real parser is built, so even the
|
|
5
|
+
argparse sentences (usage, options, errors) come out in the chosen language.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .adapters.custom import CustomSpec, SpecError, parse_spec
|
|
17
|
+
from .doctor import render_doctor
|
|
18
|
+
from .findings import skip_reason
|
|
19
|
+
from .i18n import LANGS, argparse_language, resolve_lang, t
|
|
20
|
+
from .model import Thresholds
|
|
21
|
+
from .report_html import render_doc, render_index
|
|
22
|
+
from .report_text import summary_json, summary_markdown
|
|
23
|
+
from .runner import RunOptions, UsageError, run_pipeline
|
|
24
|
+
from .samples import create_samples
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read_lang_option(argv: list[str]) -> str | None:
|
|
28
|
+
"""Find ``--lang X`` / ``--lang=X`` anywhere in argv (before the real parser exists)."""
|
|
29
|
+
for i, arg in enumerate(argv):
|
|
30
|
+
if arg == "--":
|
|
31
|
+
break
|
|
32
|
+
if arg == "--lang":
|
|
33
|
+
return argv[i + 1] if i + 1 < len(argv) else None
|
|
34
|
+
if arg.startswith("--lang="):
|
|
35
|
+
return arg.partition("=")[2]
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _fraction_type(lang: str) -> Callable[[str], float]:
|
|
40
|
+
def convert(text: str) -> float:
|
|
41
|
+
try:
|
|
42
|
+
value = float(text)
|
|
43
|
+
except ValueError:
|
|
44
|
+
raise argparse.ArgumentTypeError(t("arg.not_fraction", lang, text=text)) from None
|
|
45
|
+
if not 0.0 <= value <= 1.0:
|
|
46
|
+
raise argparse.ArgumentTypeError(t("arg.out_of_range", lang, text=text))
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
return convert
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _positive_type(lang: str) -> Callable[[str], float]:
|
|
53
|
+
def convert(text: str) -> float:
|
|
54
|
+
try:
|
|
55
|
+
value = float(text)
|
|
56
|
+
except ValueError:
|
|
57
|
+
raise argparse.ArgumentTypeError(t("arg.not_number", lang, text=text)) from None
|
|
58
|
+
if value <= 0:
|
|
59
|
+
raise argparse.ArgumentTypeError(t("arg.not_positive", lang, text=text))
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
return convert
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _custom_type(lang: str) -> Callable[[str], CustomSpec]:
|
|
66
|
+
def convert(text: str) -> CustomSpec:
|
|
67
|
+
try:
|
|
68
|
+
return parse_spec(text)
|
|
69
|
+
except SpecError as exc:
|
|
70
|
+
raise argparse.ArgumentTypeError(exc.text(lang)) from None
|
|
71
|
+
|
|
72
|
+
return convert
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _add_lang_option(p: argparse.ArgumentParser, lang: str) -> None:
|
|
76
|
+
# SUPPRESS: the value was already read by read_lang_option; this entry is for --help and validation
|
|
77
|
+
p.add_argument("--lang", choices=LANGS, default=argparse.SUPPRESS, metavar="{en,ko}", help=t("cli.help.lang", lang))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _add_run_options(p: argparse.ArgumentParser, lang: str) -> None:
|
|
81
|
+
fraction, positive = _fraction_type(lang), _positive_type(lang)
|
|
82
|
+
p.add_argument("--parsers", help=t("cli.help.parsers", lang))
|
|
83
|
+
p.add_argument("--max-pages", type=int, metavar="N", help=t("cli.help.max_pages", lang))
|
|
84
|
+
p.add_argument("--timeout", type=positive, default=120.0, metavar="SEC", help=t("cli.help.timeout", lang))
|
|
85
|
+
p.add_argument("--custom", action="append", default=[], type=_custom_type(lang), metavar="NAME=module:function",
|
|
86
|
+
help=t("cli.help.custom", lang))
|
|
87
|
+
p.add_argument("--min-coverage", type=fraction, default=0.8, metavar="X", help=t("cli.help.min_coverage", lang))
|
|
88
|
+
p.add_argument("--min-agreement", type=fraction, default=0.6, metavar="X", help=t("cli.help.min_agreement", lang))
|
|
89
|
+
p.add_argument("--strict", action="store_true", help=t("cli.help.strict", lang))
|
|
90
|
+
p.add_argument("--fresh", action="store_true", help=t("cli.help.fresh", lang))
|
|
91
|
+
p.add_argument("--no-render", action="store_true", help=t("cli.help.no_render", lang))
|
|
92
|
+
_add_lang_option(p, lang)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_parser(lang: str = "en") -> argparse.ArgumentParser:
|
|
96
|
+
ap = argparse.ArgumentParser(prog="parseoff", description=t("cli.description", lang))
|
|
97
|
+
ap.add_argument("--version", action="version", version=f"parseoff {__version__}", help=t("cli.help.version", lang))
|
|
98
|
+
_add_lang_option(ap, lang)
|
|
99
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
100
|
+
doctor = sub.add_parser("doctor", help=t("cli.help.doctor", lang))
|
|
101
|
+
_add_lang_option(doctor, lang)
|
|
102
|
+
run = sub.add_parser("run", help=t("cli.help.run", lang))
|
|
103
|
+
run.add_argument("input", type=Path, help=t("cli.help.input", lang))
|
|
104
|
+
run.add_argument("--out", type=Path, default=Path("parseoff-out"), help=t("cli.help.out_run", lang))
|
|
105
|
+
_add_run_options(run, lang)
|
|
106
|
+
demo = sub.add_parser("demo", help=t("cli.help.demo", lang))
|
|
107
|
+
demo.add_argument("--out", type=Path, default=Path("demo-out"), help=t("cli.help.out_demo", lang))
|
|
108
|
+
_add_run_options(demo, lang)
|
|
109
|
+
return ap
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _write_outputs(out: Path, report, lang: str) -> Path:
|
|
113
|
+
"""Write summary.json, summary.md and report/*.html; return the index.html path."""
|
|
114
|
+
report_dir = out / "report"
|
|
115
|
+
report_dir.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
(out / "summary.json").write_text(summary_json(report), encoding="utf-8") # same bytes in every language
|
|
117
|
+
(out / "summary.md").write_text(summary_markdown(report, lang), encoding="utf-8")
|
|
118
|
+
(report_dir / "index.html").write_text(render_index(report, lang), encoding="utf-8")
|
|
119
|
+
for doc in report.docs:
|
|
120
|
+
(report_dir / f"doc-{doc.slug}.html").write_text(render_doc(report, doc, lang), encoding="utf-8")
|
|
121
|
+
return report_dir / "index.html"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _fail(message: str, lang: str) -> int:
|
|
125
|
+
print(t("cli.error_prefix", lang) + message, file=sys.stderr)
|
|
126
|
+
return 2
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _execute(args: argparse.Namespace, input_path: Path, demo: bool, lang: str) -> int:
|
|
130
|
+
if args.max_pages is not None and args.max_pages < 1:
|
|
131
|
+
return _fail(t("err.max_pages", lang), lang)
|
|
132
|
+
requested = [p.strip() for p in args.parsers.split(",") if p.strip()] if args.parsers else None
|
|
133
|
+
options = RunOptions(
|
|
134
|
+
input_path=input_path, out_dir=args.out, parsers=requested, customs=args.custom,
|
|
135
|
+
max_pages=args.max_pages, timeout=args.timeout, fresh=args.fresh, render=not args.no_render,
|
|
136
|
+
thresholds=Thresholds(args.min_coverage, args.min_agreement), demo=demo, lang=lang,
|
|
137
|
+
)
|
|
138
|
+
try:
|
|
139
|
+
report = run_pipeline(options, progress=print)
|
|
140
|
+
except UsageError as exc:
|
|
141
|
+
return _fail(exc.text(lang), lang)
|
|
142
|
+
index = _write_outputs(args.out, report, lang)
|
|
143
|
+
|
|
144
|
+
counts = report.counts()
|
|
145
|
+
failed = counts["FAIL"] > 0
|
|
146
|
+
code = 1 if (args.strict and failed) else 0
|
|
147
|
+
print()
|
|
148
|
+
print(t("run.summary", lang, count=len(report.docs), parsers=", ".join(report.parsers) or t("run.none", lang)))
|
|
149
|
+
for skip in report.skipped:
|
|
150
|
+
print(t("run.skipped", lang, name=skip.name, reason=skip_reason(skip, lang)))
|
|
151
|
+
hint = "" if code else t("run.strict_hint", lang)
|
|
152
|
+
print(t("run.counts", lang, fail=counts["FAIL"], warn=counts["WARN"], info=counts["INFO"], code=code, hint=hint))
|
|
153
|
+
print(t("run.report", lang, path=index))
|
|
154
|
+
return code
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _tolerate_unencodable_output() -> None:
|
|
158
|
+
"""A terminal whose encoding cannot show Korean should print '?' instead of crashing."""
|
|
159
|
+
for stream in (sys.stdout, sys.stderr):
|
|
160
|
+
try:
|
|
161
|
+
stream.reconfigure(errors="replace") # type: ignore[union-attr]
|
|
162
|
+
except (AttributeError, ValueError, OSError):
|
|
163
|
+
pass # not a real text stream (for example a test capture): nothing to do
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main(argv: list[str] | None = None) -> int:
|
|
167
|
+
args_in = list(sys.argv[1:] if argv is None else argv)
|
|
168
|
+
lang = resolve_lang(read_lang_option(args_in))
|
|
169
|
+
_tolerate_unencodable_output()
|
|
170
|
+
with argparse_language(lang):
|
|
171
|
+
args = build_parser(lang).parse_args(args_in)
|
|
172
|
+
if args.command == "doctor":
|
|
173
|
+
print(render_doctor(lang))
|
|
174
|
+
return 0
|
|
175
|
+
if args.command == "demo":
|
|
176
|
+
print(t("demo.banner", lang))
|
|
177
|
+
samples = create_samples(args.out / "samples")
|
|
178
|
+
return _execute(args, samples[0].parent, True, lang)
|
|
179
|
+
return _execute(args, args.input, False, lang)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
sys.exit(main())
|
parseoff/doctor.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""`parseoff doctor`: which parsers are installed, with licence, size and warnings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import unicodedata
|
|
6
|
+
|
|
7
|
+
from .i18n import DEFAULT_LANG, t
|
|
8
|
+
from .registry import (
|
|
9
|
+
REGISTRY,
|
|
10
|
+
disk_size_mb,
|
|
11
|
+
docling_models_present,
|
|
12
|
+
install_hint,
|
|
13
|
+
installed_version,
|
|
14
|
+
is_installed,
|
|
15
|
+
size_text,
|
|
16
|
+
survey_note,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_HEADER_KEYS = ("parser", "installed", "version", "license", "size", "disk", "notes")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _width(text: str) -> int:
|
|
23
|
+
"""Columns the text takes in a terminal (Korean letters are two columns wide)."""
|
|
24
|
+
return sum(2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 for ch in text)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _pad(text: str, width: int) -> str:
|
|
28
|
+
return text + " " * (width - _width(text))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _notes(name: str, lang: str) -> str:
|
|
32
|
+
info = REGISTRY[name]
|
|
33
|
+
notes = [t(f"note.{name}", lang)]
|
|
34
|
+
if info.copyleft:
|
|
35
|
+
notes.append(t("doctor.copyleft", lang))
|
|
36
|
+
if info.model_download:
|
|
37
|
+
found = t("doctor.found" if docling_models_present() else "doctor.not_found", lang)
|
|
38
|
+
notes.append(t("doctor.model_download", lang, found=found))
|
|
39
|
+
if not info.core:
|
|
40
|
+
notes.append(t("doctor.install", lang, hint=install_hint(name, lang)))
|
|
41
|
+
return "; ".join(n for n in notes if n)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def render_doctor(lang: str = DEFAULT_LANG) -> str:
|
|
45
|
+
header = tuple(t(f"doctor.h.{k}", lang) for k in _HEADER_KEYS)
|
|
46
|
+
rows = [header]
|
|
47
|
+
for name, info in REGISTRY.items():
|
|
48
|
+
installed = is_installed(name)
|
|
49
|
+
disk = disk_size_mb(name) if installed else None
|
|
50
|
+
rows.append((
|
|
51
|
+
name,
|
|
52
|
+
t("doctor.yes" if installed else "doctor.no", lang),
|
|
53
|
+
(installed_version(name) or "-") if installed else "-",
|
|
54
|
+
info.license,
|
|
55
|
+
size_text(name, lang),
|
|
56
|
+
f"{disk:.1f} MB" if disk is not None else "-",
|
|
57
|
+
_notes(name, lang),
|
|
58
|
+
))
|
|
59
|
+
widths = [max(_width(r[i]) for r in rows) for i in range(len(header) - 1)]
|
|
60
|
+
lines = []
|
|
61
|
+
for r in rows:
|
|
62
|
+
lines.append(" ".join(_pad(r[i], widths[i]) for i in range(len(widths))) + " " + r[-1])
|
|
63
|
+
lines += ["", t("doctor.foot1", lang, survey=survey_note(lang)), t("doctor.foot2", lang)]
|
|
64
|
+
return "\n".join(lines)
|
parseoff/findings.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Turn metrics, parser status and registry facts into a flat list of findings.
|
|
2
|
+
|
|
3
|
+
Levels: FAIL = something is broken or unusable, WARN = look at it before choosing, INFO = good to know.
|
|
4
|
+
Rules (code -> level):
|
|
5
|
+
FAIL PARSER_ERROR, TIMEOUT, EMPTY_TEXT_LAYER, LOW_COVERAGE
|
|
6
|
+
WARN LOW_AGREEMENT, BOILERPLATE, LICENSE_COPYLEFT, LICENSE_WEIGHTS, MODEL_DOWNLOAD, HEAVY_INSTALL
|
|
7
|
+
INFO NO_STRUCTURE, SKIPPED
|
|
8
|
+
|
|
9
|
+
Every finding keeps an English ``message`` (this is what summary.json stores, so it never depends on
|
|
10
|
+
the screen language) plus a message ``key`` and ``params``. ``message_for(finding, lang)`` builds the
|
|
11
|
+
same sentence in the screen language for the terminal, HTML report and summary.md.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .i18n import DEFAULT_LANG, t
|
|
17
|
+
from .metrics import BOILERPLATE_MIN_RATIO
|
|
18
|
+
from .model import FAIL, INFO, LEVELS, WARN, DocReport, Finding, RunReport, Skipped
|
|
19
|
+
from .registry import HEAVY_INSTALL_MB, REGISTRY, docling_models_present, install_hint, size_text
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _render(key: str, params: dict, lang: str) -> str:
|
|
23
|
+
"""Build one finding sentence. Language independent params, language dependent words."""
|
|
24
|
+
if key == "finding.no_item" and "parts" in params: # NO_STRUCTURE: one or two parts
|
|
25
|
+
items = t("finding.and", lang).join(
|
|
26
|
+
t("finding.no_item", lang, label=t(f"label.{label}", lang), best=best, count=count)
|
|
27
|
+
for label, best, count in params["parts"]
|
|
28
|
+
)
|
|
29
|
+
return t("finding.flattened", lang, items=items)
|
|
30
|
+
if key == "finding.model_download":
|
|
31
|
+
state = t("finding.models_found" if params["found"] else "finding.models_missing", lang)
|
|
32
|
+
return t(key, lang, state=state)
|
|
33
|
+
if key == "skip.not_installed":
|
|
34
|
+
hint = install_hint(params["name"], lang) or t("skip.install_first", lang)
|
|
35
|
+
return t(key, lang, hint=hint)
|
|
36
|
+
if key == "finding.heavy":
|
|
37
|
+
return t(key, lang, size=size_text(params["name"], lang))
|
|
38
|
+
return t(key, lang, **params)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def message_for(finding: Finding, lang: str = DEFAULT_LANG) -> str:
|
|
42
|
+
"""The finding's message in `lang`. Messages without a key (text from a parser) stay as they are."""
|
|
43
|
+
if lang == DEFAULT_LANG or not finding.key:
|
|
44
|
+
return finding.message
|
|
45
|
+
return _render(finding.key, finding.params, lang)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def skip_reason(skip: Skipped, lang: str = DEFAULT_LANG) -> str:
|
|
49
|
+
"""Why a parser was skipped, in `lang` (the English text is `skip.reason`)."""
|
|
50
|
+
if lang == DEFAULT_LANG or not skip.key:
|
|
51
|
+
return skip.reason
|
|
52
|
+
return _render(skip.key, skip.params, lang)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _make(code: str, level: str, key: str, params: dict, doc: str | None = None,
|
|
56
|
+
page: int | None = None, parser: str | None = None) -> Finding:
|
|
57
|
+
return Finding(code, level, _render(key, params, DEFAULT_LANG), doc, page, parser, key, params)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _structure_findings(doc: DocReport) -> list[Finding]:
|
|
61
|
+
"""NO_STRUCTURE: a parser found no tables/headings although another parser did."""
|
|
62
|
+
out: list[Finding] = []
|
|
63
|
+
if not doc.metrics:
|
|
64
|
+
return out
|
|
65
|
+
totals = {
|
|
66
|
+
name: (sum(p.tables for p in pm.pages), sum(p.headings for p in pm.pages))
|
|
67
|
+
for name, pm in doc.metrics.parsers.items()
|
|
68
|
+
}
|
|
69
|
+
for name in totals:
|
|
70
|
+
missing = []
|
|
71
|
+
for idx, label in ((0, "tables"), (1, "headings")):
|
|
72
|
+
donors = {n: t_[idx] for n, t_ in totals.items() if n != name and t_[idx] > 0}
|
|
73
|
+
if totals[name][idx] == 0 and donors:
|
|
74
|
+
best = max(sorted(donors), key=lambda n: donors[n])
|
|
75
|
+
missing.append((label, best, donors[best]))
|
|
76
|
+
if missing:
|
|
77
|
+
out.append(_make("NO_STRUCTURE", INFO, "finding.no_item", {"parts": missing}, doc.doc_id, None, name))
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_findings(report: RunReport) -> list[Finding]:
|
|
82
|
+
th = report.thresholds
|
|
83
|
+
found: list[Finding] = []
|
|
84
|
+
|
|
85
|
+
for doc in report.docs:
|
|
86
|
+
if doc.open_error:
|
|
87
|
+
found.append(_make("PARSER_ERROR", FAIL, "finding.cannot_open", {"error": doc.open_error}, doc.doc_id))
|
|
88
|
+
continue
|
|
89
|
+
for page, chars in zip(doc.pages, doc.layer_chars):
|
|
90
|
+
if chars == 0:
|
|
91
|
+
found.append(_make("EMPTY_TEXT_LAYER", FAIL, "finding.empty_layer", {}, doc.doc_id, page))
|
|
92
|
+
for name, out in doc.outputs.items():
|
|
93
|
+
if out.status in ("timeout", "error"):
|
|
94
|
+
code = "TIMEOUT" if out.status == "timeout" else "PARSER_ERROR"
|
|
95
|
+
# the text comes from the parser or from parseoff itself; only our own is translatable
|
|
96
|
+
found.append(Finding(code, FAIL, out.error, doc.doc_id, None, name, out.error_key, dict(out.error_params)))
|
|
97
|
+
if not doc.metrics:
|
|
98
|
+
continue
|
|
99
|
+
for name, pm in doc.metrics.parsers.items():
|
|
100
|
+
for page, m in zip(doc.pages, pm.pages):
|
|
101
|
+
if m.coverage is not None and m.coverage < th.min_coverage:
|
|
102
|
+
found.append(_make(
|
|
103
|
+
"LOW_COVERAGE", FAIL, "finding.low_coverage",
|
|
104
|
+
{"coverage": m.coverage, "threshold": th.min_coverage}, doc.doc_id, page, name,
|
|
105
|
+
))
|
|
106
|
+
for page, pairs in doc.metrics.agreement.items():
|
|
107
|
+
values = {pair: v for pair, v in pairs.items() if v is not None}
|
|
108
|
+
if values:
|
|
109
|
+
pair = min(sorted(values), key=lambda p: values[p])
|
|
110
|
+
if values[pair] < th.min_agreement:
|
|
111
|
+
found.append(_make(
|
|
112
|
+
"LOW_AGREEMENT", WARN, "finding.low_agreement",
|
|
113
|
+
{"a": pair[0], "b": pair[1], "value": values[pair], "threshold": th.min_agreement},
|
|
114
|
+
doc.doc_id, page,
|
|
115
|
+
))
|
|
116
|
+
repeated = {
|
|
117
|
+
n: pm for n, pm in doc.metrics.parsers.items() if pm.boilerplate_ratio >= BOILERPLATE_MIN_RATIO
|
|
118
|
+
}
|
|
119
|
+
if repeated:
|
|
120
|
+
top = max(sorted(repeated), key=lambda n: repeated[n].boilerplate_ratio)
|
|
121
|
+
share = ", ".join(f"{n} {pm.boilerplate_ratio:.0%}" for n, pm in sorted(repeated.items()))
|
|
122
|
+
found.append(_make(
|
|
123
|
+
"BOILERPLATE", WARN, "finding.boilerplate",
|
|
124
|
+
{"example": repeated[top].boilerplate_example, "share": share}, doc.doc_id,
|
|
125
|
+
))
|
|
126
|
+
found += _structure_findings(doc)
|
|
127
|
+
|
|
128
|
+
for name in report.parsers:
|
|
129
|
+
info = REGISTRY.get(name)
|
|
130
|
+
if info is None: # custom parser: nothing is known about it
|
|
131
|
+
continue
|
|
132
|
+
if info.copyleft:
|
|
133
|
+
found.append(_make("LICENSE_COPYLEFT", WARN, "finding.copyleft", {"license": info.license}, parser=name))
|
|
134
|
+
if info.weights_restricted:
|
|
135
|
+
found.append(_make("LICENSE_WEIGHTS", WARN, "finding.weights", {}, parser=name))
|
|
136
|
+
if info.model_download:
|
|
137
|
+
found.append(_make(
|
|
138
|
+
"MODEL_DOWNLOAD", WARN, "finding.model_download", {"found": docling_models_present()}, parser=name,
|
|
139
|
+
))
|
|
140
|
+
if info.size_mb is not None and info.size_mb >= HEAVY_INSTALL_MB:
|
|
141
|
+
found.append(_make("HEAVY_INSTALL", WARN, "finding.heavy", {"name": name}, parser=name))
|
|
142
|
+
for skip in report.skipped:
|
|
143
|
+
found.append(Finding("SKIPPED", INFO, skip.reason, None, None, skip.name, skip.key, dict(skip.params)))
|
|
144
|
+
|
|
145
|
+
rank = {lvl: i for i, lvl in enumerate(LEVELS)}
|
|
146
|
+
return sorted(found, key=lambda f: (rank[f.level], f.code, f.doc or "", f.page or 0, f.parser or "", f.message))
|