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 ADDED
@@ -0,0 +1,3 @@
1
+ """parseoff - compare PDF parsers side by side on your own documents."""
2
+
3
+ __version__ = "0.2.1"
@@ -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))