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/registry.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Known parsers: licence, surveyed install size, warnings and detection helpers.
|
|
2
|
+
|
|
3
|
+
The surveyed numbers (licence, size) come from a one-off survey and are labelled as such wherever
|
|
4
|
+
they are shown. Installed versions are always read at runtime from the installed distribution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from importlib import metadata
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .i18n import MESSAGES, t
|
|
17
|
+
|
|
18
|
+
SURVEY_NOTE = "surveyed 2026-11-10, macOS arm64, Python 3.12, isolated install"
|
|
19
|
+
HEAVY_INSTALL_MB = 100.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ParserInfo:
|
|
24
|
+
name: str
|
|
25
|
+
dists: tuple[str, ...] # distribution names tried for the installed version
|
|
26
|
+
module: str # top-level module used to detect an installation
|
|
27
|
+
license: str
|
|
28
|
+
size_mb: float | None # surveyed isolated install size; None = not measured
|
|
29
|
+
size_text: str
|
|
30
|
+
auto: bool # run automatically when installed (False = only via --parsers)
|
|
31
|
+
core: bool # installed together with parseoff
|
|
32
|
+
copyleft: bool = False
|
|
33
|
+
weights_restricted: bool = False
|
|
34
|
+
model_download: bool = False
|
|
35
|
+
install_hint: str = ""
|
|
36
|
+
note: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
REGISTRY: dict[str, ParserInfo] = {
|
|
40
|
+
p.name: p
|
|
41
|
+
for p in (
|
|
42
|
+
ParserInfo(
|
|
43
|
+
"pypdf", ("pypdf",), "pypdf", "BSD-3-Clause", 1.8, "1.8 MB", True, True,
|
|
44
|
+
note="pure Python; text in content-stream order, no structure",
|
|
45
|
+
),
|
|
46
|
+
ParserInfo(
|
|
47
|
+
"pdfplumber", ("pdfplumber",), "pdfplumber", "MIT", 41.0, "41 MB", True, True,
|
|
48
|
+
note="text by position + tables as GFM (includes pypdfium2, pdfminer.six)",
|
|
49
|
+
),
|
|
50
|
+
ParserInfo(
|
|
51
|
+
"pdfminer", ("pdfminer.six",), "pdfminer", "MIT", None, "part of pdfplumber", True, True,
|
|
52
|
+
note="layout-box text; markitdown's PDF path is pdfminer text extraction",
|
|
53
|
+
),
|
|
54
|
+
ParserInfo(
|
|
55
|
+
"markitdown", ("markitdown",), "markitdown", "MIT", 147.0, "147 MB", True, False,
|
|
56
|
+
install_hint='uv sync --extra markitdown',
|
|
57
|
+
note="most of the size is onnxruntime; page-split (one conversion per page)",
|
|
58
|
+
),
|
|
59
|
+
ParserInfo(
|
|
60
|
+
"pymupdf4llm", ("pymupdf4llm", "pymupdf"), "pymupdf4llm", "AGPL-3.0 or commercial",
|
|
61
|
+
214.0, "214 MB", True, False, copyleft=True,
|
|
62
|
+
install_hint="uv pip install pymupdf4llm; AGPL-3.0, check its terms",
|
|
63
|
+
note="detected only if you installed it; not an extra of parseoff",
|
|
64
|
+
),
|
|
65
|
+
ParserInfo(
|
|
66
|
+
"docling", ("docling",), "docling", "MIT", None, "not measured (large, torch)",
|
|
67
|
+
False, False, model_download=True,
|
|
68
|
+
install_hint='uv sync --extra docling',
|
|
69
|
+
note="experimental",
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
BUILTIN_NAMES = tuple(REGISTRY)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_importable(module: str) -> bool:
|
|
77
|
+
"""True if `module` can be imported (without importing it). Tolerates fake modules in tests."""
|
|
78
|
+
if module in sys.modules:
|
|
79
|
+
return True
|
|
80
|
+
try:
|
|
81
|
+
return importlib.util.find_spec(module) is not None
|
|
82
|
+
except (ImportError, ValueError):
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def installed_version(name: str) -> str | None:
|
|
87
|
+
"""Installed distribution version for a known parser, or None if not installed."""
|
|
88
|
+
info = REGISTRY[name]
|
|
89
|
+
for dist in info.dists:
|
|
90
|
+
try:
|
|
91
|
+
return metadata.version(dist)
|
|
92
|
+
except metadata.PackageNotFoundError:
|
|
93
|
+
continue
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_installed(name: str) -> bool:
|
|
98
|
+
return is_importable(REGISTRY[name].module)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def docling_models_present() -> bool:
|
|
102
|
+
"""Existence check only: DOCLING_ARTIFACTS_PATH or docling's default cache directory."""
|
|
103
|
+
env = os.environ.get("DOCLING_ARTIFACTS_PATH")
|
|
104
|
+
if env and Path(env).is_dir():
|
|
105
|
+
return True
|
|
106
|
+
return (Path.home() / ".cache" / "docling" / "models").is_dir()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def disk_size_mb(name: str) -> float | None:
|
|
110
|
+
"""Size of the installed distribution's own files (not its dependencies), or None."""
|
|
111
|
+
info = REGISTRY[name]
|
|
112
|
+
for dist in info.dists:
|
|
113
|
+
try:
|
|
114
|
+
files = metadata.distribution(dist).files
|
|
115
|
+
except metadata.PackageNotFoundError:
|
|
116
|
+
continue
|
|
117
|
+
if not files:
|
|
118
|
+
return None
|
|
119
|
+
total = 0
|
|
120
|
+
for f in files:
|
|
121
|
+
try:
|
|
122
|
+
total += f.locate().stat().st_size
|
|
123
|
+
except OSError:
|
|
124
|
+
continue
|
|
125
|
+
return total / 1_000_000
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --- screen text for registry facts (English values stay in REGISTRY, Korean ones live in i18n) ----
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def size_text(name: str, lang: str = "en") -> str:
|
|
133
|
+
"""Surveyed install size as text; the two non-numeric ones ('part of ...', 'not measured') translate."""
|
|
134
|
+
return _facts_text(f"size.{name}", REGISTRY[name].size_text, lang)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def install_hint(name: str, lang: str = "en") -> str:
|
|
138
|
+
"""How to install a parser. Commands stay as they are; only the explanation is translated."""
|
|
139
|
+
return _facts_text(f"hint.{name}", REGISTRY[name].install_hint, lang)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def survey_note(lang: str = "en") -> str:
|
|
143
|
+
return SURVEY_NOTE if lang == "en" else t("survey", lang)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _facts_text(key: str, default: str, lang: str) -> str:
|
|
147
|
+
if lang != "en" and key in MESSAGES.get(lang, {}):
|
|
148
|
+
return t(key, lang)
|
|
149
|
+
return default
|
parseoff/report_html.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Static HTML report: index.html plus one doc-<slug>.html per PDF.
|
|
2
|
+
|
|
3
|
+
Self-contained: inline CSS, no scripts, no external resources. Every piece of text that comes from a
|
|
4
|
+
document or a parser goes through html.escape. Page images are relative links to img/*.png.
|
|
5
|
+
The screen language is fixed when the report is written (``<html lang>`` follows it).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from html import escape
|
|
11
|
+
|
|
12
|
+
from .findings import message_for, skip_reason
|
|
13
|
+
from .i18n import DEFAULT_LANG, t
|
|
14
|
+
from .model import DocReport, Finding, RunReport
|
|
15
|
+
from .registry import REGISTRY, installed_version, size_text, survey_note
|
|
16
|
+
|
|
17
|
+
CSS = """
|
|
18
|
+
body{font-family:system-ui,sans-serif;margin:24px;color:#1b1f24;background:#fff}
|
|
19
|
+
h1,h2{margin:.6em 0 .3em} table{border-collapse:collapse;margin:8px 0}
|
|
20
|
+
th,td{border:1px solid #c9d1d9;padding:4px 8px;text-align:left;font-size:14px;vertical-align:top}
|
|
21
|
+
th{background:#f3f4f6} .notice{background:#fff8c5;border:1px solid #d4a72c;padding:8px 12px;margin:8px 0}
|
|
22
|
+
.badge{display:inline-block;border-radius:4px;padding:0 6px;font-size:12px;font-weight:600;margin-right:4px}
|
|
23
|
+
.FAIL{background:#ffd7d5;color:#82071e}.WARN{background:#fff1c2;color:#6a4b00}.INFO{background:#ddf4ff;color:#0a3069}
|
|
24
|
+
.page{border-top:2px solid #8c959f;margin-top:18px;padding-top:6px}
|
|
25
|
+
.grid{display:flex;gap:12px;align-items:flex-start;overflow-x:auto}
|
|
26
|
+
.col{min-width:260px;flex:1 1 260px;max-width:520px}.img img{max-width:340px;border:1px solid #c9d1d9}
|
|
27
|
+
pre{background:#f6f8fa;border:1px solid #d0d7de;padding:8px;white-space:pre-wrap;word-break:break-word;font-size:12px}
|
|
28
|
+
.muted{color:#57606a;font-size:13px} details{margin:4px 0}
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _e(value: object) -> str:
|
|
33
|
+
return escape(str(value), quote=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _pct(value: float | None) -> str:
|
|
37
|
+
return "-" if value is None else f"{value:.0%}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _badge(f: Finding, lang: str) -> str:
|
|
41
|
+
return f'<span class="badge {f.level}" title="{_e(message_for(f, lang))}">{_e(f.code)}</span>'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _page(title: str, body: str, lang: str) -> str:
|
|
45
|
+
return (
|
|
46
|
+
f'<!doctype html>\n<html lang="{_e(lang)}"><head><meta charset="utf-8">'
|
|
47
|
+
f"<title>{_e(title)}</title><style>{CSS}</style></head><body>\n{body}\n</body></html>\n"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _notice(report: RunReport, lang: str) -> str:
|
|
52
|
+
return f'<div class="notice">{_e(t("notice.demo" if report.demo else "notice", lang))}</div>'
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _finding_li(f: Finding, slug_of: dict[str, str], lang: str) -> str:
|
|
56
|
+
where = []
|
|
57
|
+
if f.doc:
|
|
58
|
+
label = f.doc + (t("where.page", lang, page=f.page) if f.page else "")
|
|
59
|
+
link = f"doc-{slug_of[f.doc]}.html" + (f"#p{f.page}" if f.page else "")
|
|
60
|
+
where.append(f'<a href="{_e(link)}">{_e(label)}</a>')
|
|
61
|
+
if f.parser:
|
|
62
|
+
where.append(_e(f.parser))
|
|
63
|
+
return f"<li>{_badge(f, lang)} {' / '.join(where)} {_e(message_for(f, lang))}</li>"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def render_index(report: RunReport, lang: str = DEFAULT_LANG) -> str:
|
|
67
|
+
c = report.counts()
|
|
68
|
+
slug_of = {d.doc_id: d.slug for d in report.docs}
|
|
69
|
+
parts = [f"<h1>{_e(t('html.title', lang))}</h1>", _notice(report, lang)]
|
|
70
|
+
parts.append(
|
|
71
|
+
f'<p><span class="badge FAIL">FAIL {c["FAIL"]}</span><span class="badge WARN">WARN {c["WARN"]}</span>'
|
|
72
|
+
f'<span class="badge INFO">INFO {c["INFO"]}</span></p>'
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
th = lambda key: f"<th>{_e(t(key, lang))}</th>" # noqa: E731 - tiny local helper
|
|
76
|
+
parts.append(f"<h2>{_e(t('html.parsers', lang))}</h2><table><tr>{th('tbl.parser')}{th('tbl.version')}{th('tbl.license')}"
|
|
77
|
+
f"<th>{_e(t('html.size_head', lang, survey=survey_note(lang)))}</th>{th('tbl.status')}</tr>")
|
|
78
|
+
for name in report.parsers:
|
|
79
|
+
info = REGISTRY.get(name)
|
|
80
|
+
parts.append(
|
|
81
|
+
f"<tr><td>{_e(name)}</td><td>{_e(installed_version(name) if info else 'custom')}</td>"
|
|
82
|
+
f"<td>{_e(info.license if info else t('license.custom', lang))}</td>"
|
|
83
|
+
f"<td>{_e(size_text(name, lang) if info else '-')}</td><td>{_e(t('html.ran', lang))}</td></tr>"
|
|
84
|
+
)
|
|
85
|
+
for s in report.skipped:
|
|
86
|
+
parts.append(f"<tr><td>{_e(s.name)}</td><td>-</td><td>{_e(REGISTRY[s.name].license)}</td>"
|
|
87
|
+
f"<td>{_e(size_text(s.name, lang))}</td>"
|
|
88
|
+
f"<td>{_e(t('html.skipped', lang, reason=skip_reason(s, lang)))}</td></tr>")
|
|
89
|
+
parts.append("</table>")
|
|
90
|
+
|
|
91
|
+
parts.append(f"<h2>{_e(t('html.docs', lang))}</h2><table><tr>"
|
|
92
|
+
+ "".join(th(f"tbl.{k}") for k in ("document", "parser", "status", "seconds", "coverage", "tables", "headings", "tokens"))
|
|
93
|
+
+ "</tr>")
|
|
94
|
+
for doc in report.docs:
|
|
95
|
+
link = f'<a href="doc-{_e(doc.slug)}.html">{_e(doc.doc_id)}</a>'
|
|
96
|
+
if doc.open_error:
|
|
97
|
+
parts.append(f"<tr><td>{link}</td><td colspan=\"7\">{_e(t('html.cannot_open_row', lang, error=doc.open_error))}</td></tr>")
|
|
98
|
+
for name, out in doc.outputs.items():
|
|
99
|
+
pm = doc.metrics.parsers.get(name) if doc.metrics else None
|
|
100
|
+
cells = ("-", "-", "-", "-")
|
|
101
|
+
if pm:
|
|
102
|
+
cells = (_pct(pm.mean_coverage), str(sum(m.tables for m in pm.pages)),
|
|
103
|
+
str(sum(m.headings for m in pm.pages)), str(sum(m.tokens_est for m in pm.pages)))
|
|
104
|
+
parts.append(
|
|
105
|
+
f"<tr><td>{link}</td><td>{_e(name)}</td><td>{_e(out.status)}</td><td>{out.seconds:.2f}</td>"
|
|
106
|
+
+ "".join(f"<td>{_e(x)}</td>" for x in cells) + "</tr>"
|
|
107
|
+
)
|
|
108
|
+
parts.append(f"</table><p class=\"muted\">{_e(t('html.tokens_note', lang))}</p>")
|
|
109
|
+
|
|
110
|
+
parts.append(f"<h2>{_e(t('html.agreement', lang))}</h2>")
|
|
111
|
+
for doc in report.docs:
|
|
112
|
+
if not doc.metrics or not doc.metrics.mean_agreement:
|
|
113
|
+
continue
|
|
114
|
+
names = list(doc.metrics.parsers)
|
|
115
|
+
parts.append(f"<h3>{_e(doc.doc_id)}</h3><table><tr><th></th>" + "".join(f"<th>{_e(n)}</th>" for n in names) + "</tr>")
|
|
116
|
+
for a in names:
|
|
117
|
+
row = []
|
|
118
|
+
for b in names:
|
|
119
|
+
if a == b:
|
|
120
|
+
row.append("1.00")
|
|
121
|
+
else:
|
|
122
|
+
v = doc.metrics.mean_agreement.get((a, b), doc.metrics.mean_agreement.get((b, a)))
|
|
123
|
+
row.append("-" if v is None else f"{v:.2f}")
|
|
124
|
+
parts.append(f"<tr><th>{_e(a)}</th>" + "".join(f"<td>{r}</td>" for r in row) + "</tr>")
|
|
125
|
+
parts.append("</table>")
|
|
126
|
+
|
|
127
|
+
parts.append(f"<h2>{_e(t('html.findings', lang))}</h2>")
|
|
128
|
+
parts.append("<ul>" + "".join(_finding_li(f, slug_of, lang) for f in report.findings) + "</ul>" if report.findings
|
|
129
|
+
else f"<p>{_e(t('html.no_findings', lang))}</p>")
|
|
130
|
+
return _page(t("html.title", lang), "\n".join(parts), lang)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def render_doc(report: RunReport, doc: DocReport, lang: str = DEFAULT_LANG) -> str:
|
|
134
|
+
parts = [f"<h1>{_e(doc.doc_id)}</h1>", f'<p><a href="index.html">{_e(t("html.back", lang))}</a></p>', _notice(report, lang)]
|
|
135
|
+
if doc.open_error:
|
|
136
|
+
parts.append(f"<p>{_e(t('html.cannot_open', lang, error=doc.open_error))}</p>")
|
|
137
|
+
return _page(doc.doc_id, "\n".join(parts), lang)
|
|
138
|
+
doc_findings = [f for f in report.findings if f.doc == doc.doc_id]
|
|
139
|
+
parts.append(f'<p class="muted">{_e(t("html.docinfo", lang, sha=doc.sha256[:16], total=doc.total_pages, analysed=len(doc.pages)))}</p>')
|
|
140
|
+
parts.append("<p>" + " ".join(_badge(f, lang) for f in doc_findings if f.page is None) + "</p>")
|
|
141
|
+
for name, out in doc.outputs.items():
|
|
142
|
+
if out.status != "ok" or out.stderr:
|
|
143
|
+
shown = t(out.error_key, lang, **out.error_params) if out.error_key else out.error
|
|
144
|
+
parts.append(
|
|
145
|
+
f"<details><summary>{_e(name)}: {_e(out.status)} {_e(shown)}</summary>"
|
|
146
|
+
f"<pre>{_e(out.stderr or t('html.no_stderr', lang))}</pre></details>"
|
|
147
|
+
)
|
|
148
|
+
for idx, page in enumerate(doc.pages):
|
|
149
|
+
page_findings = [f for f in doc_findings if f.page == page]
|
|
150
|
+
parts.append(f'<div class="page" id="p{page}"><h2>{_e(t("html.page", lang, page=page))}</h2>')
|
|
151
|
+
parts.append("<p>" + " ".join(_badge(f, lang) for f in page_findings)
|
|
152
|
+
+ f" <span class=\"muted\">{_e(t('html.layer', lang, chars=doc.layer_chars[idx]))}</span></p>")
|
|
153
|
+
if doc.metrics and doc.metrics.agreement.get(page):
|
|
154
|
+
pairs = ", ".join(
|
|
155
|
+
f"{a}/{b} {v:.2f}" for (a, b), v in doc.metrics.agreement[page].items() if v is not None
|
|
156
|
+
)
|
|
157
|
+
if pairs:
|
|
158
|
+
parts.append(f'<p class="muted">{_e(t("html.agreement_row", lang, pairs=pairs))}</p>')
|
|
159
|
+
parts.append('<div class="grid">')
|
|
160
|
+
if doc.rendered:
|
|
161
|
+
parts.append(f'<div class="col img"><b>{_e(t("html.original", lang))}</b><br><img src="img/{_e(doc.slug)}-p{page}.png" alt="{_e(t("html.page_alt", lang, page=page))}"></div>')
|
|
162
|
+
for name, out in doc.outputs.items():
|
|
163
|
+
pm = doc.metrics.parsers.get(name) if doc.metrics else None
|
|
164
|
+
meta = ""
|
|
165
|
+
if pm:
|
|
166
|
+
m = pm.pages[idx]
|
|
167
|
+
meta = ("<div class=\"muted\">"
|
|
168
|
+
+ _e(t("html.page_metrics", lang, chars=m.chars, coverage=_pct(m.coverage), tables=m.tables, headings=m.headings))
|
|
169
|
+
+ "</div>")
|
|
170
|
+
text = out.pages[idx] if out.status == "ok" and idx < len(out.pages) else f"({out.status})"
|
|
171
|
+
parts.append(f'<div class="col"><b>{_e(name)}</b>{meta}<pre>{_e(text)}</pre></div>')
|
|
172
|
+
parts.append("</div></div>")
|
|
173
|
+
return _page(doc.doc_id, "\n".join(parts), lang)
|
parseoff/report_text.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""summary.json and summary.md. No timestamps, no absolute paths, sorted keys: diff-friendly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .findings import message_for, skip_reason
|
|
9
|
+
from .i18n import DEFAULT_LANG, t
|
|
10
|
+
from .model import RunReport
|
|
11
|
+
from .registry import REGISTRY, SURVEY_NOTE, installed_version, size_text, survey_note
|
|
12
|
+
|
|
13
|
+
NOTICE = "This output contains text from your documents. parseoff sends nothing over the network."
|
|
14
|
+
DEMO_NOTICE = "Practice (mock) mode: synthetic sample PDFs, but the parsers are real."
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _r(value: float | None) -> float | None:
|
|
18
|
+
return None if value is None else round(value, 4)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _pair(pair: tuple[str, str]) -> str:
|
|
22
|
+
return f"{pair[0]} | {pair[1]}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_summary(report: RunReport) -> dict:
|
|
26
|
+
parsers = []
|
|
27
|
+
for name in report.parsers:
|
|
28
|
+
info = REGISTRY.get(name)
|
|
29
|
+
parsers.append({
|
|
30
|
+
"name": name, "status": "ran",
|
|
31
|
+
"version": installed_version(name) if info else "custom",
|
|
32
|
+
"license": info.license if info else "unknown (custom)",
|
|
33
|
+
"install_size_surveyed": info.size_text if info else None,
|
|
34
|
+
})
|
|
35
|
+
parsers += [{"name": s.name, "status": "skipped", "reason": s.reason} for s in report.skipped]
|
|
36
|
+
|
|
37
|
+
docs = []
|
|
38
|
+
for doc in report.docs:
|
|
39
|
+
entry: dict = {
|
|
40
|
+
"id": doc.doc_id, "sha256": doc.sha256, "total_pages": doc.total_pages,
|
|
41
|
+
"analyzed_pages": doc.pages, "text_layer_chars": doc.layer_chars,
|
|
42
|
+
}
|
|
43
|
+
if doc.open_error:
|
|
44
|
+
entry["error"] = doc.open_error
|
|
45
|
+
per_parser = {}
|
|
46
|
+
for name, out in doc.outputs.items():
|
|
47
|
+
item: dict = {"status": out.status, "version": out.version, "seconds": round(out.seconds, 3)}
|
|
48
|
+
if out.error:
|
|
49
|
+
item["error"] = out.error
|
|
50
|
+
pm = doc.metrics.parsers.get(name) if doc.metrics else None
|
|
51
|
+
if pm:
|
|
52
|
+
item.update({
|
|
53
|
+
"mean_coverage": _r(pm.mean_coverage),
|
|
54
|
+
"boilerplate_ratio": _r(pm.boilerplate_ratio),
|
|
55
|
+
"pages": [
|
|
56
|
+
{"page": p, "chars": m.chars, "tokens_est": m.tokens_est,
|
|
57
|
+
"coverage": _r(m.coverage), "tables": m.tables, "headings": m.headings}
|
|
58
|
+
for p, m in zip(doc.pages, pm.pages)
|
|
59
|
+
],
|
|
60
|
+
})
|
|
61
|
+
per_parser[name] = item
|
|
62
|
+
entry["parsers"] = per_parser
|
|
63
|
+
if doc.metrics:
|
|
64
|
+
entry["agreement"] = {
|
|
65
|
+
_pair(pair): {
|
|
66
|
+
"mean": _r(mean),
|
|
67
|
+
"pages": {str(p): _r(doc.metrics.agreement[p][pair]) for p in doc.pages},
|
|
68
|
+
}
|
|
69
|
+
for pair, mean in doc.metrics.mean_agreement.items()
|
|
70
|
+
}
|
|
71
|
+
docs.append(entry)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
"parseoff_version": __version__,
|
|
75
|
+
"mode": "demo" if report.demo else "run",
|
|
76
|
+
"notes": {"tokens_est": "characters / 4, an estimate", "install_size": SURVEY_NOTE},
|
|
77
|
+
"thresholds": {"min_coverage": report.thresholds.min_coverage, "min_agreement": report.thresholds.min_agreement},
|
|
78
|
+
"counts": report.counts(),
|
|
79
|
+
"parsers": parsers,
|
|
80
|
+
"documents": docs,
|
|
81
|
+
"findings": [
|
|
82
|
+
{"code": f.code, "level": f.level, "doc": f.doc, "page": f.page, "parser": f.parser, "message": f.message}
|
|
83
|
+
for f in report.findings
|
|
84
|
+
],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def summary_json(report: RunReport) -> str:
|
|
89
|
+
return json.dumps(build_summary(report), indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _md(value: object) -> str:
|
|
93
|
+
"""One-line, pipe-safe text for a Markdown table cell."""
|
|
94
|
+
return " ".join(str(value).split()).replace("|", "\\|")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _cell(value: float | None, pct: bool = False) -> str:
|
|
98
|
+
if value is None:
|
|
99
|
+
return "-"
|
|
100
|
+
return f"{value:.0%}" if pct else f"{value:.2f}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def summary_markdown(report: RunReport, lang: str = DEFAULT_LANG) -> str:
|
|
104
|
+
"""summary.md in the screen language. Numbers, parser names and finding codes never change."""
|
|
105
|
+
c = report.counts()
|
|
106
|
+
lines = [f"# {t('md.title', lang)}", ""]
|
|
107
|
+
lines.append(f"> {t('notice.demo' if report.demo else 'notice', lang)}")
|
|
108
|
+
lines += ["", f"FAIL {c['FAIL']}, WARN {c['WARN']}, INFO {c['INFO']}", "", f"## {t('md.parsers', lang)}", ""]
|
|
109
|
+
heads = [t("tbl.parser", lang), t("tbl.version", lang), t("tbl.license", lang), t("md.size_head", lang), t("tbl.status", lang)]
|
|
110
|
+
lines += ["| " + " | ".join(heads) + " |", "|---|---|---|---|---|"]
|
|
111
|
+
skipped = {s.name: s for s in report.skipped}
|
|
112
|
+
for p in build_summary(report)["parsers"]:
|
|
113
|
+
if p["status"] == "ran":
|
|
114
|
+
status = t("md.ran", lang)
|
|
115
|
+
else:
|
|
116
|
+
status = t("md.skipped", lang, reason=skip_reason(skipped[p["name"]], lang))
|
|
117
|
+
info = REGISTRY.get(p["name"])
|
|
118
|
+
license_ = p.get("license", "-") if info or p["status"] != "ran" else t("license.custom", lang)
|
|
119
|
+
size = (size_text(p["name"], lang) if info and p["status"] == "ran" else None) or "-"
|
|
120
|
+
lines.append(f"| {_md(p['name'])} | {p.get('version') or '-'} | {_md(license_)} | {_md(size)} | {_md(status)} |")
|
|
121
|
+
lines += ["", f"## {t('md.documents', lang)}", ""]
|
|
122
|
+
heads = [t(f"tbl.{k}", lang) for k in ("document", "parser", "status", "seconds", "coverage", "tables", "headings", "tokens")]
|
|
123
|
+
lines += ["| " + " | ".join(heads) + " |", "|---|---|---|---|---|---|---|---|"]
|
|
124
|
+
for doc in report.docs:
|
|
125
|
+
for name, out in doc.outputs.items():
|
|
126
|
+
pm = doc.metrics.parsers.get(name) if doc.metrics else None
|
|
127
|
+
tables = sum(m.tables for m in pm.pages) if pm else "-"
|
|
128
|
+
heads_n = sum(m.headings for m in pm.pages) if pm else "-"
|
|
129
|
+
toks = sum(m.tokens_est for m in pm.pages) if pm else "-"
|
|
130
|
+
cov = _cell(pm.mean_coverage, pct=True) if pm else "-"
|
|
131
|
+
lines.append(f"| {_md(doc.doc_id)} | {_md(name)} | {out.status} | {out.seconds:.2f} | {cov} | {tables} | {heads_n} | {toks} |")
|
|
132
|
+
lines += ["", f"## {t('md.findings', lang)}", ""]
|
|
133
|
+
if not report.findings:
|
|
134
|
+
lines.append(t("md.no_findings", lang))
|
|
135
|
+
for f in report.findings:
|
|
136
|
+
where = " ".join(x for x in (f.doc, f"p{f.page}" if f.page else None, f.parser) if x)
|
|
137
|
+
lines.append(f"- **{f.level}** `{f.code}` {_md(where)}: {_md(message_for(f, lang))}")
|
|
138
|
+
lines += ["", t("md.foot", lang, survey=survey_note(lang)), ""]
|
|
139
|
+
return "\n".join(lines)
|