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/i18n.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Screen language: one message table per language, plus the rule that picks the language.
|
|
2
|
+
|
|
3
|
+
Language order: ``--lang en|ko`` > ``PARSEOFF_LANG`` > ``LC_ALL`` / ``LC_MESSAGES`` / ``LANG``
|
|
4
|
+
(the first one that is set and not empty). A value that starts with ``ko`` means Korean, anything
|
|
5
|
+
else means English.
|
|
6
|
+
|
|
7
|
+
Only text meant for people is translated. ``summary.json``, finding codes, levels, parser names,
|
|
8
|
+
exit codes and the text that parsers or PDF libraries produce stay as they are.
|
|
9
|
+
Korean sentences never put a fixed particle (i/ga/eul/reul ...) after a value, because the right
|
|
10
|
+
particle depends on the value: the value goes at the end of the sentence or is followed by a unit.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import contextlib
|
|
17
|
+
import os
|
|
18
|
+
from collections.abc import Iterator, Mapping
|
|
19
|
+
|
|
20
|
+
LANGS = ("en", "ko")
|
|
21
|
+
DEFAULT_LANG = "en"
|
|
22
|
+
ENV_VAR = "PARSEOFF_LANG"
|
|
23
|
+
|
|
24
|
+
_EN: dict[str, str] = {
|
|
25
|
+
# --- command line: help ------------------------------------------------------------------
|
|
26
|
+
"cli.description": "Compare PDF parsers side by side on your own documents.",
|
|
27
|
+
"cli.help.lang": "screen language: en or ko (default: PARSEOFF_LANG, then LC_ALL / LC_MESSAGES / LANG)",
|
|
28
|
+
"cli.help.version": "show the version and exit",
|
|
29
|
+
"cli.help.doctor": "show installed parsers, versions, licences and warnings",
|
|
30
|
+
"cli.help.run": "compare parsers on a PDF file or a folder of PDFs",
|
|
31
|
+
"cli.help.demo": "generate 3 practice PDFs and compare the default parsers on them",
|
|
32
|
+
"cli.help.input": "a .pdf file or a folder (searched recursively)",
|
|
33
|
+
"cli.help.out_run": "output folder (default parseoff-out)",
|
|
34
|
+
"cli.help.out_demo": "output folder (default demo-out)",
|
|
35
|
+
"cli.help.parsers": "comma separated parsers to run (default: every installed one except docling)",
|
|
36
|
+
"cli.help.max_pages": "analyse only the first N pages of each PDF",
|
|
37
|
+
"cli.help.timeout": "per parser and document (default 120)",
|
|
38
|
+
"cli.help.custom": "add your own parser function (repeatable)",
|
|
39
|
+
"cli.help.min_coverage": "LOW_COVERAGE threshold (default 0.8)",
|
|
40
|
+
"cli.help.min_agreement": "LOW_AGREEMENT threshold (default 0.6)",
|
|
41
|
+
"cli.help.strict": "exit 1 if any FAIL finding exists (for CI)",
|
|
42
|
+
"cli.help.fresh": "ignore cached parser results",
|
|
43
|
+
"cli.help.no_render": "do not write page images",
|
|
44
|
+
# --- command line: errors and progress ----------------------------------------------------
|
|
45
|
+
"cli.error_prefix": "error: ",
|
|
46
|
+
"arg.not_fraction": "{text!r} is not a number between 0 and 1",
|
|
47
|
+
"arg.out_of_range": "{text} is out of range: use a value between 0 and 1",
|
|
48
|
+
"arg.not_number": "{text!r} is not a number",
|
|
49
|
+
"arg.not_positive": "{text} must be greater than 0",
|
|
50
|
+
"err.max_pages": "--max-pages must be 1 or greater",
|
|
51
|
+
"demo.banner": "parseoff demo - practice mode: 3 synthetic PDFs, real parsers",
|
|
52
|
+
"run.summary": "{count} documents, parsers: {parsers}",
|
|
53
|
+
"run.none": "(none)",
|
|
54
|
+
"run.skipped": "skipped {name}: {reason}",
|
|
55
|
+
"run.counts": "FAIL {fail}, WARN {warn}, INFO {info} - exit {code}{hint}",
|
|
56
|
+
"run.strict_hint": " (use --strict to fail)",
|
|
57
|
+
"run.report": "report: {path}",
|
|
58
|
+
# --- custom parser option ----------------------------------------------------------------
|
|
59
|
+
"custom.bad_value": "invalid --custom value {text!r}: expected NAME=module:function "
|
|
60
|
+
"(for example: --custom mine=mypkg.parse:to_pages)",
|
|
61
|
+
"custom.bad_name": "invalid custom parser name {name!r}: use letters, digits, - or _ (max 32)",
|
|
62
|
+
"custom.taken": "custom parser name {name!r} is taken by a built-in parser; pick another name",
|
|
63
|
+
"custom.bad_target": "invalid --custom target {target!r}: expected module.path:function_name",
|
|
64
|
+
# --- runner ------------------------------------------------------------------------------
|
|
65
|
+
"usage.no_such": "{path}: no such file or folder. Pass a PDF file or a folder that contains PDFs.",
|
|
66
|
+
"usage.not_pdf": "{path}: not a .pdf file. Pass a PDF file or a folder that contains PDFs.",
|
|
67
|
+
"usage.no_pdfs": "{path}: no PDF files found. Pass a folder that contains .pdf files.",
|
|
68
|
+
"usage.unknown_parsers": "unknown parser(s): {unknown}. Known parsers: {known}",
|
|
69
|
+
"skip.opt_in": "opt-in only: add {name} to --parsers (model download on first run)",
|
|
70
|
+
"skip.not_installed": "not installed ({hint})",
|
|
71
|
+
"skip.install_first": "install it first",
|
|
72
|
+
"err.timeout": "timed out after {seconds:g}s (raise --timeout or use --max-pages)",
|
|
73
|
+
"err.worker_exit": "worker exited with code {code} without a result",
|
|
74
|
+
"progress.cannot_open": " {doc}: cannot open ({error})",
|
|
75
|
+
"progress.images_skipped": " {doc}: page images skipped ({error})",
|
|
76
|
+
"progress.parser": " {doc}: {parser} {status} {seconds:.2f}s{cached}",
|
|
77
|
+
"progress.cached": " (cached)",
|
|
78
|
+
# --- doctor ------------------------------------------------------------------------------
|
|
79
|
+
"doctor.h.parser": "parser",
|
|
80
|
+
"doctor.h.installed": "installed",
|
|
81
|
+
"doctor.h.version": "version",
|
|
82
|
+
"doctor.h.license": "license",
|
|
83
|
+
"doctor.h.size": "install size*",
|
|
84
|
+
"doctor.h.disk": "on disk",
|
|
85
|
+
"doctor.h.notes": "notes",
|
|
86
|
+
"doctor.yes": "yes",
|
|
87
|
+
"doctor.no": "no",
|
|
88
|
+
"doctor.copyleft": "WARNING copyleft licence",
|
|
89
|
+
"doctor.model_download": "WARNING model download on first run (local models: {found}); opt-in via --parsers",
|
|
90
|
+
"doctor.found": "found",
|
|
91
|
+
"doctor.not_found": "not found",
|
|
92
|
+
"doctor.install": "install: {hint}",
|
|
93
|
+
"doctor.foot1": "* install size: {survey} (docling not measured). Versions and 'on disk' are read from your environment.",
|
|
94
|
+
"doctor.foot2": "Licence and size columns are survey values, not legal advice: verify before you ship.",
|
|
95
|
+
# --- registry facts shown on screen --------------------------------------------------------
|
|
96
|
+
"survey": "surveyed 2026-11-10, macOS arm64, Python 3.12, isolated install",
|
|
97
|
+
"size.pdfminer": "part of pdfplumber",
|
|
98
|
+
"size.docling": "not measured (large, torch)",
|
|
99
|
+
"license.custom": "unknown (custom)",
|
|
100
|
+
"hint.pymupdf4llm": "uv pip install pymupdf4llm; AGPL-3.0, check its terms",
|
|
101
|
+
"note.pypdf": "pure Python; text in content-stream order, no structure",
|
|
102
|
+
"note.pdfplumber": "text by position + tables as GFM (includes pypdfium2, pdfminer.six)",
|
|
103
|
+
"note.pdfminer": "layout-box text; markitdown's PDF path is pdfminer text extraction",
|
|
104
|
+
"note.markitdown": "most of the size is onnxruntime; page-split (one conversion per page)",
|
|
105
|
+
"note.pymupdf4llm": "detected only if you installed it; not an extra of parseoff",
|
|
106
|
+
"note.docling": "experimental",
|
|
107
|
+
# --- findings ----------------------------------------------------------------------------
|
|
108
|
+
"finding.cannot_open": "cannot open the PDF: {error}",
|
|
109
|
+
"finding.empty_layer": "page has no text layer (scan or image only): OCR is needed, text parsers return nothing",
|
|
110
|
+
"finding.low_coverage": "only {coverage:.0%} of the text-layer words appear (threshold {threshold:.0%})",
|
|
111
|
+
"finding.low_agreement": "{a} and {b} agree on only {value:.2f} of the word order "
|
|
112
|
+
"(threshold {threshold:.2f}): look at this page yourself",
|
|
113
|
+
"finding.boilerplate": "lines repeat on many pages (e.g. \"{example}\"; share of lines: {share}): "
|
|
114
|
+
"headers/footers will pollute RAG chunks",
|
|
115
|
+
"finding.copyleft": "{license}: copyleft licence, check it fits how you ship your software",
|
|
116
|
+
"finding.weights": "model weights have commercial-use limits",
|
|
117
|
+
"finding.model_download": "downloads models on first run unless present ({state}); "
|
|
118
|
+
"set DOCLING_ARTIFACTS_PATH for offline use",
|
|
119
|
+
"finding.models_found": "models found locally",
|
|
120
|
+
"finding.models_missing": "models NOT found locally",
|
|
121
|
+
"finding.heavy": "install size about {size} (surveyed, may differ on your system)",
|
|
122
|
+
"finding.no_item": "no {label} (but {best} found {count})",
|
|
123
|
+
"finding.and": " and ",
|
|
124
|
+
"finding.flattened": "{items}: structure may be flattened",
|
|
125
|
+
"label.tables": "tables",
|
|
126
|
+
"label.headings": "headings",
|
|
127
|
+
# --- HTML report and summary.md ------------------------------------------------------------
|
|
128
|
+
"notice": "This output contains text from your documents. parseoff sends nothing over the network.",
|
|
129
|
+
"notice.demo": "Practice (mock) mode: synthetic sample PDFs, but the parsers are real.",
|
|
130
|
+
"tbl.parser": "parser",
|
|
131
|
+
"tbl.version": "version",
|
|
132
|
+
"tbl.license": "license",
|
|
133
|
+
"tbl.status": "status",
|
|
134
|
+
"tbl.document": "document",
|
|
135
|
+
"tbl.seconds": "seconds",
|
|
136
|
+
"tbl.coverage": "mean coverage",
|
|
137
|
+
"tbl.tables": "tables",
|
|
138
|
+
"tbl.headings": "headings",
|
|
139
|
+
"tbl.tokens": "est. tokens",
|
|
140
|
+
"html.title": "parseoff report",
|
|
141
|
+
"html.parsers": "Parsers",
|
|
142
|
+
"html.size_head": "install size ({survey})",
|
|
143
|
+
"html.ran": "ran",
|
|
144
|
+
"html.skipped": "skipped: {reason}",
|
|
145
|
+
"html.docs": "Documents x parsers",
|
|
146
|
+
"html.cannot_open_row": "cannot open: {error}",
|
|
147
|
+
"html.tokens_note": "Token counts are estimates (characters / 4).",
|
|
148
|
+
"html.agreement": "Agreement between parsers (mean word-order similarity)",
|
|
149
|
+
"html.findings": "Findings",
|
|
150
|
+
"html.no_findings": "No findings.",
|
|
151
|
+
"html.back": "back to overview",
|
|
152
|
+
"html.cannot_open": "Cannot open this PDF: {error}",
|
|
153
|
+
"html.docinfo": "sha256 {sha}..., {total} page(s), {analysed} analysed",
|
|
154
|
+
"html.no_stderr": "(no stderr)",
|
|
155
|
+
"html.page": "Page {page}",
|
|
156
|
+
"html.page_alt": "page {page}",
|
|
157
|
+
"html.layer": "text layer: {chars} chars",
|
|
158
|
+
"html.agreement_row": "agreement: {pairs}",
|
|
159
|
+
"html.original": "original",
|
|
160
|
+
"html.page_metrics": "chars {chars}, coverage {coverage}, tables {tables}, headings {headings}",
|
|
161
|
+
"where.page": " page {page}",
|
|
162
|
+
"md.title": "parseoff summary",
|
|
163
|
+
"md.parsers": "Parsers",
|
|
164
|
+
"md.size_head": "install size (surveyed)",
|
|
165
|
+
"md.ran": "ran",
|
|
166
|
+
"md.skipped": "skipped - {reason}",
|
|
167
|
+
"md.documents": "Documents",
|
|
168
|
+
"md.findings": "Findings",
|
|
169
|
+
"md.no_findings": "No findings.",
|
|
170
|
+
"md.foot": "Token counts are estimates (characters / 4). Install sizes: {survey}.",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
_KO: dict[str, str] = {
|
|
174
|
+
"cli.description": "내 문서로 PDF 파서를 나란히 비교합니다.",
|
|
175
|
+
"cli.help.lang": "화면 언어: en 또는 ko (기본값: PARSEOFF_LANG, 그다음 LC_ALL / LC_MESSAGES / LANG)",
|
|
176
|
+
"cli.help.version": "버전을 보여 주고 끝냅니다",
|
|
177
|
+
"cli.help.doctor": "설치된 파서와 버전, 라이선스, 경고를 보여 줍니다",
|
|
178
|
+
"cli.help.run": "PDF 파일 또는 PDF 폴더에서 파서를 비교합니다",
|
|
179
|
+
"cli.help.demo": "연습용 PDF 3개를 만들고 기본 파서로 비교합니다",
|
|
180
|
+
"cli.help.input": ".pdf 파일 또는 폴더 (하위 폴더까지 찾습니다)",
|
|
181
|
+
"cli.help.out_run": "출력 폴더 (기본값 parseoff-out)",
|
|
182
|
+
"cli.help.out_demo": "출력 폴더 (기본값 demo-out)",
|
|
183
|
+
"cli.help.parsers": "실행할 파서를 쉼표로 구분해 지정합니다 (기본값: docling 을 뺀 설치된 모든 파서)",
|
|
184
|
+
"cli.help.max_pages": "각 PDF 의 앞쪽 N쪽만 분석합니다",
|
|
185
|
+
"cli.help.timeout": "파서·문서마다 적용되는 제한 시간(초) (기본값 120)",
|
|
186
|
+
"cli.help.custom": "직접 만든 파서 함수를 추가합니다 (여러 번 지정 가능)",
|
|
187
|
+
"cli.help.min_coverage": "LOW_COVERAGE 기준값 (기본값 0.8)",
|
|
188
|
+
"cli.help.min_agreement": "LOW_AGREEMENT 기준값 (기본값 0.6)",
|
|
189
|
+
"cli.help.strict": "FAIL 결과가 하나라도 있으면 종료 코드 1 로 끝냅니다 (CI 용)",
|
|
190
|
+
"cli.help.fresh": "저장된 파서 결과를 무시합니다",
|
|
191
|
+
"cli.help.no_render": "쪽 이미지를 만들지 않습니다",
|
|
192
|
+
"cli.error_prefix": "오류: ",
|
|
193
|
+
"arg.not_fraction": "0과 1 사이의 숫자가 아닙니다: {text!r}",
|
|
194
|
+
"arg.out_of_range": "범위를 벗어났습니다. 0과 1 사이의 값을 쓰십시오: {text}",
|
|
195
|
+
"arg.not_number": "숫자가 아닙니다: {text!r}",
|
|
196
|
+
"arg.not_positive": "0보다 커야 합니다: {text}",
|
|
197
|
+
"err.max_pages": "--max-pages 는 1 이상이어야 합니다",
|
|
198
|
+
"demo.banner": "parseoff demo - 연습 모드: 합성 PDF 3개, 실제 파서",
|
|
199
|
+
"run.summary": "문서 {count}개, 파서: {parsers}",
|
|
200
|
+
"run.none": "(없음)",
|
|
201
|
+
"run.skipped": "건너뜀 {name}: {reason}",
|
|
202
|
+
"run.counts": "FAIL {fail}, WARN {warn}, INFO {info} - 종료 코드 {code}{hint}",
|
|
203
|
+
"run.strict_hint": " (실패로 처리하려면 --strict 사용)",
|
|
204
|
+
"run.report": "리포트: {path}",
|
|
205
|
+
"custom.bad_value": "--custom 값이 올바르지 않습니다. NAME=module:function 형식으로 쓰십시오 "
|
|
206
|
+
"(예: --custom mine=mypkg.parse:to_pages): {text!r}",
|
|
207
|
+
"custom.bad_name": "사용자 파서 이름이 올바르지 않습니다. 영문자, 숫자, - 또는 _ 만 쓰십시오 (최대 32자): {name!r}",
|
|
208
|
+
"custom.taken": "내장 파서가 쓰고 있는 이름이라 사용자 파서 이름으로 쓸 수 없습니다. 다른 이름을 고르십시오: {name!r}",
|
|
209
|
+
"custom.bad_target": "--custom 대상이 올바르지 않습니다. module.path:function_name 형식으로 쓰십시오: {target!r}",
|
|
210
|
+
"usage.no_such": "해당 파일이나 폴더가 없습니다. PDF 파일 또는 PDF 가 들어 있는 폴더를 지정하십시오: {path}",
|
|
211
|
+
"usage.not_pdf": ".pdf 파일이 아닙니다. PDF 파일 또는 PDF 가 들어 있는 폴더를 지정하십시오: {path}",
|
|
212
|
+
"usage.no_pdfs": "PDF 파일이 없습니다. .pdf 파일이 들어 있는 폴더를 지정하십시오: {path}",
|
|
213
|
+
"usage.unknown_parsers": "알 수 없는 파서가 있습니다. 사용 가능한 파서: {known} / 입력한 값: {unknown}",
|
|
214
|
+
"skip.opt_in": "직접 지정할 때만 실행합니다 (처음 실행할 때 모델을 내려받습니다). --parsers 에 추가할 이름: {name}",
|
|
215
|
+
"skip.not_installed": "설치되지 않음 ({hint})",
|
|
216
|
+
"skip.install_first": "먼저 설치하십시오",
|
|
217
|
+
"err.timeout": "{seconds:g}초 만에 시간 초과 (--timeout 을 늘리거나 --max-pages 를 쓰십시오)",
|
|
218
|
+
"err.worker_exit": "작업 프로세스가 결과 없이 종료되었습니다. 종료 코드: {code}",
|
|
219
|
+
"progress.cannot_open": " {doc}: 열 수 없음 ({error})",
|
|
220
|
+
"progress.images_skipped": " {doc}: 쪽 이미지를 건너뜀 ({error})",
|
|
221
|
+
"progress.parser": " {doc}: {parser} {status} {seconds:.2f}초{cached}",
|
|
222
|
+
"progress.cached": " (캐시)",
|
|
223
|
+
"doctor.h.parser": "파서",
|
|
224
|
+
"doctor.h.installed": "설치됨",
|
|
225
|
+
"doctor.h.version": "버전",
|
|
226
|
+
"doctor.h.license": "라이선스",
|
|
227
|
+
"doctor.h.size": "설치 크기*",
|
|
228
|
+
"doctor.h.disk": "디스크 사용",
|
|
229
|
+
"doctor.h.notes": "비고",
|
|
230
|
+
"doctor.yes": "예",
|
|
231
|
+
"doctor.no": "아니오",
|
|
232
|
+
"doctor.copyleft": "경고: 카피레프트 라이선스",
|
|
233
|
+
"doctor.model_download": "경고: 처음 실행할 때 모델을 내려받습니다 (로컬 모델: {found}). --parsers 로 직접 지정할 때만 실행",
|
|
234
|
+
"doctor.found": "있음",
|
|
235
|
+
"doctor.not_found": "없음",
|
|
236
|
+
"doctor.install": "설치: {hint}",
|
|
237
|
+
"doctor.foot1": "* 설치 크기: {survey} (docling 은 측정하지 않았습니다). 버전과 '디스크 사용'은 현재 환경에서 읽은 값입니다.",
|
|
238
|
+
"doctor.foot2": "라이선스와 크기 열은 조사한 값이며 법률 자문이 아닙니다. 배포 전에 직접 확인하십시오.",
|
|
239
|
+
"survey": "2026-11-10 조사, macOS arm64, Python 3.12, 격리 설치",
|
|
240
|
+
"size.pdfminer": "pdfplumber 에 포함",
|
|
241
|
+
"size.docling": "측정하지 않음 (용량이 큼, torch)",
|
|
242
|
+
"license.custom": "알 수 없음 (사용자 파서)",
|
|
243
|
+
"hint.pymupdf4llm": "uv pip install pymupdf4llm; AGPL-3.0 이므로 조건을 확인하십시오",
|
|
244
|
+
"note.pypdf": "순수 Python. 콘텐츠 스트림 순서대로 텍스트를 뽑고 구조는 없음",
|
|
245
|
+
"note.pdfplumber": "위치 기준 텍스트 + GFM 표 (pypdfium2, pdfminer.six 포함)",
|
|
246
|
+
"note.pdfminer": "레이아웃 상자 단위 텍스트. markitdown 의 PDF 경로는 pdfminer 텍스트 추출",
|
|
247
|
+
"note.markitdown": "용량 대부분은 onnxruntime. 쪽 단위로 변환 (쪽마다 한 번)",
|
|
248
|
+
"note.pymupdf4llm": "직접 설치했을 때만 감지. parseoff 의 extra 가 아님",
|
|
249
|
+
"note.docling": "실험적",
|
|
250
|
+
"finding.cannot_open": "PDF 를 열 수 없습니다: {error}",
|
|
251
|
+
"finding.empty_layer": "이 쪽에는 텍스트 레이어가 없습니다 (스캔 또는 이미지뿐). OCR 이 필요하며 텍스트 파서는 아무것도 반환하지 않습니다",
|
|
252
|
+
"finding.low_coverage": "텍스트 레이어 단어 중 {coverage:.0%}만 나타납니다 (기준 {threshold:.0%})",
|
|
253
|
+
"finding.low_agreement": "파서 사이의 단어 순서 일치도가 낮습니다: {a} / {b}, 일치도 {value:.2f} (기준 {threshold:.2f}). 이 쪽을 직접 확인하십시오",
|
|
254
|
+
"finding.boilerplate": "여러 쪽에 같은 줄이 반복됩니다 (예: \"{example}\", 줄 비율: {share}). "
|
|
255
|
+
"머리글·바닥글이 RAG 청크를 오염시킵니다",
|
|
256
|
+
"finding.copyleft": "카피레프트 라이선스입니다. 소프트웨어 배포 방식에 맞는지 확인하십시오: {license}",
|
|
257
|
+
"finding.weights": "모델 가중치에 상업적 사용 제한이 있습니다",
|
|
258
|
+
"finding.model_download": "모델이 없으면 처음 실행할 때 내려받습니다 ({state}). "
|
|
259
|
+
"오프라인에서는 DOCLING_ARTIFACTS_PATH 를 지정하십시오",
|
|
260
|
+
"finding.models_found": "로컬에 모델 있음",
|
|
261
|
+
"finding.models_missing": "로컬에 모델 없음",
|
|
262
|
+
"finding.heavy": "설치 크기 약 {size} (조사 값이며 환경에 따라 다를 수 있음)",
|
|
263
|
+
"finding.no_item": "{label} 없음 ({best} 에서는 {count}개 찾음)",
|
|
264
|
+
"finding.and": ", ",
|
|
265
|
+
"finding.flattened": "{items}. 구조가 평탄화되었을 수 있습니다",
|
|
266
|
+
"label.tables": "표",
|
|
267
|
+
"label.headings": "제목",
|
|
268
|
+
"notice": "이 출력에는 문서에서 가져온 텍스트가 들어 있습니다. parseoff 는 네트워크로 아무것도 보내지 않습니다.",
|
|
269
|
+
"notice.demo": "연습(목) 모드: 샘플 PDF 는 합성이지만 파서는 실제입니다.",
|
|
270
|
+
"tbl.parser": "파서",
|
|
271
|
+
"tbl.version": "버전",
|
|
272
|
+
"tbl.license": "라이선스",
|
|
273
|
+
"tbl.status": "상태",
|
|
274
|
+
"tbl.document": "문서",
|
|
275
|
+
"tbl.seconds": "초",
|
|
276
|
+
"tbl.coverage": "평균 커버리지",
|
|
277
|
+
"tbl.tables": "표",
|
|
278
|
+
"tbl.headings": "제목",
|
|
279
|
+
"tbl.tokens": "추정 토큰",
|
|
280
|
+
"html.title": "parseoff 리포트",
|
|
281
|
+
"html.parsers": "파서",
|
|
282
|
+
"html.size_head": "설치 크기 ({survey})",
|
|
283
|
+
"html.ran": "실행됨",
|
|
284
|
+
"html.skipped": "건너뜀: {reason}",
|
|
285
|
+
"html.docs": "문서 x 파서",
|
|
286
|
+
"html.cannot_open_row": "열 수 없음: {error}",
|
|
287
|
+
"html.tokens_note": "토큰 수는 추정값입니다 (글자 수 / 4).",
|
|
288
|
+
"html.agreement": "파서 간 일치도 (단어 순서 유사도 평균)",
|
|
289
|
+
"html.findings": "발견 사항",
|
|
290
|
+
"html.no_findings": "발견 사항이 없습니다.",
|
|
291
|
+
"html.back": "개요로 돌아가기",
|
|
292
|
+
"html.cannot_open": "이 PDF 를 열 수 없습니다: {error}",
|
|
293
|
+
"html.docinfo": "sha256 {sha}..., 전체 {total}쪽, 분석 {analysed}쪽",
|
|
294
|
+
"html.no_stderr": "(stderr 없음)",
|
|
295
|
+
"html.page": "{page}쪽",
|
|
296
|
+
"html.page_alt": "{page}쪽",
|
|
297
|
+
"html.layer": "텍스트 레이어: {chars}자",
|
|
298
|
+
"html.agreement_row": "일치도: {pairs}",
|
|
299
|
+
"html.original": "원본",
|
|
300
|
+
"html.page_metrics": "글자 {chars}, 커버리지 {coverage}, 표 {tables}, 제목 {headings}",
|
|
301
|
+
"where.page": " {page}쪽",
|
|
302
|
+
"md.title": "parseoff 요약",
|
|
303
|
+
"md.parsers": "파서",
|
|
304
|
+
"md.size_head": "설치 크기 (조사 값)",
|
|
305
|
+
"md.ran": "실행됨",
|
|
306
|
+
"md.skipped": "건너뜀 - {reason}",
|
|
307
|
+
"md.documents": "문서",
|
|
308
|
+
"md.findings": "발견 사항",
|
|
309
|
+
"md.no_findings": "발견 사항이 없습니다.",
|
|
310
|
+
"md.foot": "토큰 수는 추정값입니다 (글자 수 / 4). 설치 크기: {survey}.",
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
# argparse prints these fixed sentences itself (Python's gettext catalogue has no Korean entry for
|
|
314
|
+
# parseoff's tone), so they are mapped here by their English text.
|
|
315
|
+
_ARGPARSE_KO: dict[str, str] = {
|
|
316
|
+
"usage: ": "사용법: ",
|
|
317
|
+
"positional arguments": "위치 인자",
|
|
318
|
+
"options": "옵션",
|
|
319
|
+
"subcommands": "하위 명령",
|
|
320
|
+
"show this help message and exit": "이 도움말을 보여 주고 끝냅니다",
|
|
321
|
+
"show program's version number and exit": "버전을 보여 주고 끝냅니다",
|
|
322
|
+
"%(prog)s: error: %(message)s\n": "%(prog)s: 오류: %(message)s\n",
|
|
323
|
+
"the following arguments are required: %s": "다음 인자가 필요합니다: %s",
|
|
324
|
+
"unrecognized arguments: %s": "알 수 없는 인자입니다: %s",
|
|
325
|
+
"invalid choice: %(value)r (choose from %(choices)s)": "선택할 수 없는 값입니다: %(value)r (선택지: %(choices)s)",
|
|
326
|
+
"unknown parser %(parser_name)r (choices: %(choices)s)": "알 수 없는 명령입니다: %(parser_name)r (선택지: %(choices)s)",
|
|
327
|
+
"argument %(argument_name)s: %(message)s": "인자 %(argument_name)s: %(message)s",
|
|
328
|
+
"expected one argument": "값이 하나 필요합니다",
|
|
329
|
+
"expected at least one argument": "값이 하나 이상 필요합니다",
|
|
330
|
+
"expected at most one argument": "값은 최대 하나만 쓸 수 있습니다",
|
|
331
|
+
"invalid %(type)s value: %(value)r": "%(type)s 값이 올바르지 않습니다: %(value)r",
|
|
332
|
+
"ambiguous option: %(option)s could match %(matches)s": "모호한 옵션입니다: %(option)s (후보: %(matches)s)",
|
|
333
|
+
"ignored explicit argument %r": "값을 받지 않는 옵션에 값이 지정되었습니다: %r",
|
|
334
|
+
"not allowed with argument %s": "다음 인자와 함께 쓸 수 없습니다: %s",
|
|
335
|
+
"one of the arguments %s is required": "다음 인자 중 하나가 필요합니다: %s",
|
|
336
|
+
"unexpected option string: %s": "예상하지 못한 옵션입니다: %s",
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
MESSAGES: dict[str, dict[str, str]] = {
|
|
340
|
+
"en": {**_EN, **{f"argparse:{k}": k for k in _ARGPARSE_KO}},
|
|
341
|
+
"ko": {**_KO, **{f"argparse:{k}": v for k, v in _ARGPARSE_KO.items()}},
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def normalize(value: str | None) -> str | None:
|
|
346
|
+
"""Map a raw value (``ko``, ``ko_KR.UTF-8``, ``en_US`` ...) to ``ko``/``en``; None if empty."""
|
|
347
|
+
if value is None or not value.strip():
|
|
348
|
+
return None
|
|
349
|
+
return "ko" if value.strip().lower().startswith("ko") else "en"
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def resolve_lang(cli_value: str | None = None, env: Mapping[str, str] | None = None) -> str:
|
|
353
|
+
"""Pick the screen language: --lang, then PARSEOFF_LANG, then the first set locale variable."""
|
|
354
|
+
env = os.environ if env is None else env
|
|
355
|
+
if cli_value in LANGS:
|
|
356
|
+
return str(cli_value)
|
|
357
|
+
for name in (ENV_VAR, "LC_ALL", "LC_MESSAGES", "LANG"):
|
|
358
|
+
found = normalize(env.get(name))
|
|
359
|
+
if found:
|
|
360
|
+
return found
|
|
361
|
+
return DEFAULT_LANG
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def t(key: str, lang: str = DEFAULT_LANG, **params: object) -> str:
|
|
365
|
+
"""Look up a message and fill its placeholders. Unknown language -> English."""
|
|
366
|
+
table = MESSAGES.get(lang, MESSAGES[DEFAULT_LANG])
|
|
367
|
+
text = table.get(key)
|
|
368
|
+
if text is None:
|
|
369
|
+
text = MESSAGES[DEFAULT_LANG][key]
|
|
370
|
+
return text.format(**params) if params else text
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@contextlib.contextmanager
|
|
374
|
+
def argparse_language(lang: str) -> Iterator[None]:
|
|
375
|
+
"""Make argparse's own fixed sentences (usage:, options, error ...) use `lang` while active."""
|
|
376
|
+
if lang == DEFAULT_LANG:
|
|
377
|
+
yield
|
|
378
|
+
return
|
|
379
|
+
table = MESSAGES[lang]
|
|
380
|
+
original = argparse._ # type: ignore[attr-defined]
|
|
381
|
+
|
|
382
|
+
def translate(msgid: str) -> str:
|
|
383
|
+
return table.get(f"argparse:{msgid}", original(msgid))
|
|
384
|
+
|
|
385
|
+
argparse._ = translate # type: ignore[attr-defined]
|
|
386
|
+
try:
|
|
387
|
+
yield
|
|
388
|
+
finally:
|
|
389
|
+
argparse._ = original # type: ignore[attr-defined]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class LocalizedError(Exception):
|
|
393
|
+
"""An error that keeps its message key and parameters so it can be shown in any language.
|
|
394
|
+
|
|
395
|
+
``str(error)`` is always English; call ``error.text(lang)`` for the screen language.
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
def __init__(self, key: str, **params: object) -> None:
|
|
399
|
+
self.key = key
|
|
400
|
+
self.params = params
|
|
401
|
+
super().__init__(t(key, DEFAULT_LANG, **params))
|
|
402
|
+
|
|
403
|
+
def text(self, lang: str = DEFAULT_LANG) -> str:
|
|
404
|
+
return t(self.key, lang, **self.params)
|
parseoff/metrics.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Ground-truth-free metrics: coverage vs the PDF text layer, pairwise agreement, structure counts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import itertools
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
import unicodedata
|
|
10
|
+
from collections import Counter
|
|
11
|
+
|
|
12
|
+
from .model import DocMetrics, PageMetrics, ParserMetrics
|
|
13
|
+
|
|
14
|
+
# SequenceMatcher is quadratic in the worst case; very long pages are compared on a prefix.
|
|
15
|
+
MAX_AGREEMENT_WORDS = 3000
|
|
16
|
+
BOILERPLATE_MIN_PAGES = 3
|
|
17
|
+
BOILERPLATE_MIN_RATIO = 0.02
|
|
18
|
+
|
|
19
|
+
_WORD = re.compile(r"\w+", re.UNICODE)
|
|
20
|
+
_TABLE_SEPARATOR = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$")
|
|
21
|
+
_HEADING = re.compile(r"^\s{0,3}#{1,6}\s+\S")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def words(text: str) -> list[str]:
|
|
25
|
+
"""NFKC-normalised, case-folded word list. Markdown punctuation (| # * -) is dropped."""
|
|
26
|
+
return _WORD.findall(unicodedata.normalize("NFKC", text).casefold())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def coverage(reference: str, output: str) -> float | None:
|
|
30
|
+
"""Share of the text layer's words (multiset) found in the parser output, or None if empty."""
|
|
31
|
+
ref = Counter(words(reference))
|
|
32
|
+
total = sum(ref.values())
|
|
33
|
+
if total == 0:
|
|
34
|
+
return None
|
|
35
|
+
got = Counter(words(output))
|
|
36
|
+
hit = sum(min(count, got[word]) for word, count in ref.items())
|
|
37
|
+
return hit / total
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def agreement(a: str, b: str) -> float | None:
|
|
41
|
+
"""Word-order similarity in [0, 1] (difflib ratio); None if both are empty."""
|
|
42
|
+
wa, wb = words(a)[:MAX_AGREEMENT_WORDS], words(b)[:MAX_AGREEMENT_WORDS]
|
|
43
|
+
if not wa and not wb:
|
|
44
|
+
return None
|
|
45
|
+
if not wa or not wb:
|
|
46
|
+
return 0.0
|
|
47
|
+
return difflib.SequenceMatcher(None, wa, wb, autojunk=False).ratio()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def count_tables(markdown: str) -> int:
|
|
51
|
+
"""Number of GFM tables: runs of `|` lines that contain a `| --- | --- |` separator row."""
|
|
52
|
+
tables, in_table, has_sep = 0, False, False
|
|
53
|
+
for line in markdown.splitlines() + [""]:
|
|
54
|
+
if line.lstrip().startswith("|"):
|
|
55
|
+
in_table = True
|
|
56
|
+
has_sep = has_sep or bool(_TABLE_SEPARATOR.match(line))
|
|
57
|
+
else:
|
|
58
|
+
if in_table and has_sep:
|
|
59
|
+
tables += 1
|
|
60
|
+
in_table, has_sep = False, False
|
|
61
|
+
return tables
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def count_headings(markdown: str) -> int:
|
|
65
|
+
return sum(1 for line in markdown.splitlines() if _HEADING.match(line))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def estimate_tokens(text: str) -> int:
|
|
69
|
+
"""Rough token estimate (characters / 4). Always an estimate, never a tokenizer result."""
|
|
70
|
+
return math.ceil(len(text) / 4)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _normalise_line(line: str) -> str:
|
|
74
|
+
line = unicodedata.normalize("NFKC", line).casefold()
|
|
75
|
+
line = re.sub(r"\d+", "#", line)
|
|
76
|
+
return re.sub(r"\s+", " ", line).strip()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def repeated_line_ratio(pages: list[str]) -> tuple[float, str]:
|
|
80
|
+
"""Share of lines that repeat on many pages (running headers/footers, page numbers).
|
|
81
|
+
|
|
82
|
+
Digits are normalised so "Page 1 of 3" and "Page 2 of 3" count as the same line.
|
|
83
|
+
Returns (ratio, an example of a repeated line, or "").
|
|
84
|
+
"""
|
|
85
|
+
if len(pages) < BOILERPLATE_MIN_PAGES:
|
|
86
|
+
return 0.0, ""
|
|
87
|
+
per_page: list[list[str]] = []
|
|
88
|
+
for page in pages:
|
|
89
|
+
lines = [_normalise_line(ln) for ln in page.splitlines()]
|
|
90
|
+
per_page.append([ln for ln in lines if len(ln) >= 4 and not _TABLE_SEPARATOR.match(ln)])
|
|
91
|
+
presence: Counter[str] = Counter()
|
|
92
|
+
for lines in per_page:
|
|
93
|
+
presence.update(set(lines))
|
|
94
|
+
need = max(BOILERPLATE_MIN_PAGES, math.ceil(len(pages) / 2))
|
|
95
|
+
repeated = {ln for ln, n in presence.items() if n >= need}
|
|
96
|
+
total = sum(len(lines) for lines in per_page)
|
|
97
|
+
if total == 0 or not repeated:
|
|
98
|
+
return 0.0, ""
|
|
99
|
+
hits = sum(1 for lines in per_page for ln in lines if ln in repeated)
|
|
100
|
+
example = max(sorted(repeated), key=lambda ln: presence[ln])
|
|
101
|
+
return hits / total, example
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def compute_doc_metrics(
|
|
105
|
+
pages: list[int], layer_texts: list[str], outputs: dict[str, list[str]]
|
|
106
|
+
) -> DocMetrics:
|
|
107
|
+
"""Metrics for one document. `outputs` maps parser -> page texts aligned with `pages`."""
|
|
108
|
+
parsers: dict[str, ParserMetrics] = {}
|
|
109
|
+
for name, texts in outputs.items():
|
|
110
|
+
page_metrics = [
|
|
111
|
+
PageMetrics(
|
|
112
|
+
chars=len(text),
|
|
113
|
+
tokens_est=estimate_tokens(text),
|
|
114
|
+
coverage=coverage(layer, text),
|
|
115
|
+
tables=count_tables(text),
|
|
116
|
+
headings=count_headings(text),
|
|
117
|
+
)
|
|
118
|
+
for layer, text in zip(layer_texts, texts)
|
|
119
|
+
]
|
|
120
|
+
covs = [m.coverage for m in page_metrics if m.coverage is not None]
|
|
121
|
+
ratio, example = repeated_line_ratio(texts)
|
|
122
|
+
parsers[name] = ParserMetrics(
|
|
123
|
+
pages=page_metrics,
|
|
124
|
+
mean_coverage=sum(covs) / len(covs) if covs else None,
|
|
125
|
+
boilerplate_ratio=ratio,
|
|
126
|
+
boilerplate_example=example,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
per_page: dict[int, dict[tuple[str, str], float | None]] = {}
|
|
130
|
+
sums: dict[tuple[str, str], list[float]] = {}
|
|
131
|
+
for idx, page in enumerate(pages):
|
|
132
|
+
per_page[page] = {}
|
|
133
|
+
for a, b in itertools.combinations(outputs, 2):
|
|
134
|
+
value = agreement(outputs[a][idx], outputs[b][idx])
|
|
135
|
+
per_page[page][(a, b)] = value
|
|
136
|
+
if value is not None:
|
|
137
|
+
sums.setdefault((a, b), []).append(value)
|
|
138
|
+
mean = {
|
|
139
|
+
pair: (sum(sums[pair]) / len(sums[pair]) if pair in sums else None)
|
|
140
|
+
for pair in itertools.combinations(outputs, 2)
|
|
141
|
+
}
|
|
142
|
+
return DocMetrics(parsers=parsers, agreement=per_page, mean_agreement=mean)
|
parseoff/model.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Plain data classes shared by the runner, metrics, findings and report writers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
FAIL = "FAIL"
|
|
8
|
+
WARN = "WARN"
|
|
9
|
+
INFO = "INFO"
|
|
10
|
+
LEVELS = (FAIL, WARN, INFO)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ParserOutput:
|
|
15
|
+
"""Result of one parser on one document (pages are Markdown/plain text, one string per page)."""
|
|
16
|
+
|
|
17
|
+
parser: str
|
|
18
|
+
status: str # "ok" | "error" | "timeout"
|
|
19
|
+
version: str = ""
|
|
20
|
+
seconds: float = 0.0
|
|
21
|
+
pages: list[str] = field(default_factory=list)
|
|
22
|
+
error: str = ""
|
|
23
|
+
stderr: str = ""
|
|
24
|
+
cached: bool = False
|
|
25
|
+
# message key + parameters for errors written by parseoff itself (shown in the screen language)
|
|
26
|
+
error_key: str = ""
|
|
27
|
+
error_params: dict = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class PageMetrics:
|
|
32
|
+
chars: int
|
|
33
|
+
tokens_est: int
|
|
34
|
+
coverage: float | None # None when the text layer has no words (never computed as 0)
|
|
35
|
+
tables: int
|
|
36
|
+
headings: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ParserMetrics:
|
|
41
|
+
pages: list[PageMetrics]
|
|
42
|
+
mean_coverage: float | None
|
|
43
|
+
boilerplate_ratio: float
|
|
44
|
+
boilerplate_example: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class DocMetrics:
|
|
49
|
+
parsers: dict[str, ParserMetrics]
|
|
50
|
+
# page number -> {(parser_a, parser_b): agreement or None}
|
|
51
|
+
agreement: dict[int, dict[tuple[str, str], float | None]]
|
|
52
|
+
# (parser_a, parser_b) -> mean over pages where it could be computed
|
|
53
|
+
mean_agreement: dict[tuple[str, str], float | None]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class DocReport:
|
|
58
|
+
doc_id: str # path relative to the input folder, posix style
|
|
59
|
+
slug: str
|
|
60
|
+
sha256: str = ""
|
|
61
|
+
total_pages: int = 0
|
|
62
|
+
pages: list[int] = field(default_factory=list) # analysed 1-based page numbers
|
|
63
|
+
layer_chars: list[int] = field(default_factory=list)
|
|
64
|
+
outputs: dict[str, ParserOutput] = field(default_factory=dict)
|
|
65
|
+
metrics: DocMetrics | None = None
|
|
66
|
+
open_error: str = ""
|
|
67
|
+
rendered: bool = False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Skipped:
|
|
72
|
+
name: str
|
|
73
|
+
reason: str # English, as written to summary.json
|
|
74
|
+
key: str = "" # message key + parameters, used to show the reason in the screen language
|
|
75
|
+
params: dict = field(default_factory=dict, compare=False)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Finding:
|
|
80
|
+
code: str
|
|
81
|
+
level: str
|
|
82
|
+
message: str
|
|
83
|
+
doc: str | None = None
|
|
84
|
+
page: int | None = None
|
|
85
|
+
parser: str | None = None
|
|
86
|
+
key: str = "" # message key + parameters, used to show the message in the screen language
|
|
87
|
+
params: dict = field(default_factory=dict, compare=False)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class Thresholds:
|
|
92
|
+
min_coverage: float = 0.8
|
|
93
|
+
min_agreement: float = 0.6
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class RunReport:
|
|
98
|
+
docs: list[DocReport]
|
|
99
|
+
parsers: list[str] # parsers that were executed, in display order
|
|
100
|
+
skipped: list[Skipped]
|
|
101
|
+
thresholds: Thresholds
|
|
102
|
+
demo: bool = False
|
|
103
|
+
findings: list[Finding] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
def counts(self) -> dict[str, int]:
|
|
106
|
+
return {lvl: sum(1 for f in self.findings if f.level == lvl) for lvl in LEVELS}
|
parseoff/py.typed
ADDED
|
File without changes
|