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