roascan 2.0.1__py3-none-win_amd64.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.
- roascan/__init__.py +85 -0
- roascan/__main__.py +342 -0
- roascan/accent.py +143 -0
- roascan/api.py +223 -0
- roascan/desktop/__init__.py +60 -0
- roascan/desktop/__main__.py +9 -0
- roascan/desktop/capture.py +165 -0
- roascan/desktop/clipboard.py +136 -0
- roascan/desktop/config.py +174 -0
- roascan/desktop/config_cli.py +92 -0
- roascan/desktop/hotkey.py +97 -0
- roascan/desktop/i18n.py +123 -0
- roascan/desktop/result_panel.py +383 -0
- roascan/desktop/tray_app.py +364 -0
- roascan/docout.py +442 -0
- roascan/engine.py +1001 -0
- roascan/mcp_server.py +482 -0
- roascan/models/en_dict.txt +95 -0
- roascan/models/es_ES.dic +58222 -0
- roascan/models/ppocrv5_latin_dict.txt +502 -0
- roascan/models/roascan_rec_en.onnx +0 -0
- roascan/models/roascan_rec_latin_v5.onnx +0 -0
- roascan/native.py +196 -0
- roascan/prepro.py +77 -0
- roascan/recognizer.py +326 -0
- roascan/roascan_core.dll +0 -0
- roascan-2.0.1.dist-info/METADATA +288 -0
- roascan-2.0.1.dist-info/RECORD +34 -0
- roascan-2.0.1.dist-info/WHEEL +5 -0
- roascan-2.0.1.dist-info/entry_points.txt +5 -0
- roascan-2.0.1.dist-info/licenses/LICENSE +201 -0
- roascan-2.0.1.dist-info/licenses/NOTICE +76 -0
- roascan-2.0.1.dist-info/licenses/THIRD_PARTY_LICENSES.txt +695 -0
- roascan-2.0.1.dist-info/top_level.txt +1 -0
roascan/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RoaScan (roascan): Ultra-Lightweight C++17 & ONNX Multi-Page OCR Engine.
|
|
3
|
+
|
|
4
|
+
Built for AI Agents and Enterprise Document Pipelines:
|
|
5
|
+
- Converts multi-page scanned PDFs/TIFFs/images into:
|
|
6
|
+
1. Restored, background-whitened, compressed Searchable PDFs (.pdf)
|
|
7
|
+
2. Structured Semantic Markdown (.md) with headings, key-values, and tables
|
|
8
|
+
- Runs 100% locally on CPU with native C++17 preprocessing and ONNX Runtime.
|
|
9
|
+
|
|
10
|
+
Standard Bridge Contract APIs:
|
|
11
|
+
- `roascan.run_ocr(target, ...)`: Core entrypoint returning `RoaScanResult`.
|
|
12
|
+
- `RoaScanEngine.run(target, ...)`: Documented staticmethod alias to `run_ocr`
|
|
13
|
+
satisfying the bridge contract:
|
|
14
|
+
Input: { image_path | image_bytes | PIL.Image | PDF, lang?, options? }
|
|
15
|
+
Output: RoaScanResult { text, words, pages, markdown, confidences, ... }
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import Optional
|
|
19
|
+
from .engine import RoaScanEngine
|
|
20
|
+
from .prepro import RoaScanPreprocessor
|
|
21
|
+
from .docout import RoaScanDocumentWriter, validate_output_paths, validate_output_format
|
|
22
|
+
from .native import RoaScanNativeDriver, get_native_driver, RoaScanRect
|
|
23
|
+
from .recognizer import RoaScanTextRecognizer
|
|
24
|
+
from .api import RoaScanResult, run_ocr
|
|
25
|
+
|
|
26
|
+
# Bridge Contract API:
|
|
27
|
+
# RoaScanEngine.run is exposed as a staticmethod pointing to `run_ocr`.
|
|
28
|
+
# It provides a consistent, object-oriented entrypoint for callers and desktop hosts:
|
|
29
|
+
# result = RoaScanEngine.run("factura.png", language="es")
|
|
30
|
+
# returning a typed RoaScanResult satisfying { text, words, pages, confidences, ... }.
|
|
31
|
+
RoaScanEngine.run = staticmethod(run_ocr)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def scan_to_markdown(
|
|
35
|
+
input_path: str,
|
|
36
|
+
output_md_path: Optional[str] = None,
|
|
37
|
+
language: str = "es",
|
|
38
|
+
dpi: int = 300,
|
|
39
|
+
) -> str:
|
|
40
|
+
"""One-call helper: convert any scanned PDF/TIFF/image into structured Markdown (.md)."""
|
|
41
|
+
if output_md_path is not None:
|
|
42
|
+
validate_output_paths(input_path, [output_md_path], expected_formats=["md"])
|
|
43
|
+
|
|
44
|
+
res = run_ocr(input_path, language=language, dpi=dpi, output_md_path=output_md_path)
|
|
45
|
+
return res.markdown
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def scan_to_pdf(
|
|
49
|
+
input_path: str,
|
|
50
|
+
output_pdf_path: str,
|
|
51
|
+
language: str = "es",
|
|
52
|
+
dpi: int = 300,
|
|
53
|
+
enhance_quality: bool = True,
|
|
54
|
+
) -> int:
|
|
55
|
+
"""One-call helper: convert a scanned PDF/TIFF/image into a restored Searchable PDF (.pdf)."""
|
|
56
|
+
validate_output_paths(input_path, [output_pdf_path], expected_formats=["pdf"])
|
|
57
|
+
with RoaScanEngine(language=language) as engine:
|
|
58
|
+
rc = engine.process_document(
|
|
59
|
+
input_path=input_path,
|
|
60
|
+
output_path=output_pdf_path,
|
|
61
|
+
dpi=dpi,
|
|
62
|
+
enhance_quality=enhance_quality,
|
|
63
|
+
expected_format="pdf",
|
|
64
|
+
)
|
|
65
|
+
if rc != 0:
|
|
66
|
+
raise RuntimeError(engine.last_error or f"Failed to generate Searchable PDF (code {rc})")
|
|
67
|
+
return rc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__version__ = "2.0.1"
|
|
71
|
+
__all__ = [
|
|
72
|
+
"RoaScanEngine",
|
|
73
|
+
"RoaScanPreprocessor",
|
|
74
|
+
"RoaScanDocumentWriter",
|
|
75
|
+
"RoaScanNativeDriver",
|
|
76
|
+
"RoaScanRect",
|
|
77
|
+
"RoaScanTextRecognizer",
|
|
78
|
+
"RoaScanResult",
|
|
79
|
+
"run_ocr",
|
|
80
|
+
"get_native_driver",
|
|
81
|
+
"scan_to_markdown",
|
|
82
|
+
"scan_to_pdf",
|
|
83
|
+
"validate_output_paths",
|
|
84
|
+
"validate_output_format",
|
|
85
|
+
]
|
roascan/__main__.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""
|
|
2
|
+
roascan.__main__: Agent-First CLI, MCP Server, and Desktop Capture for RoaScan.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
# 1. Start Model Context Protocol (MCP) server (stdio JSON-RPC 2.0):
|
|
6
|
+
roascan --mcp
|
|
7
|
+
roascan-mcp
|
|
8
|
+
|
|
9
|
+
# 2. Run environment & model diagnostics:
|
|
10
|
+
roascan --doctor
|
|
11
|
+
|
|
12
|
+
# 3. Standard file OCR (Markdown, PDF, or clipboard):
|
|
13
|
+
roascan ocr archivo.png --out out.md
|
|
14
|
+
roascan scan.pdf --md output.md --pdf clean.pdf --lang es --copy
|
|
15
|
+
roascan factura.png --copy
|
|
16
|
+
|
|
17
|
+
# 4. Desktop Screen / Region Capture OCR:
|
|
18
|
+
roascan clip
|
|
19
|
+
roascan clip --bbox 100,100,500,400 --out recorte.md
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from typing import List, Optional
|
|
28
|
+
|
|
29
|
+
from . import RoaScanEngine, RoaScanDocumentWriter, validate_output_paths, __version__
|
|
30
|
+
from .api import run_ocr
|
|
31
|
+
from .desktop.clipboard import copy_to_clipboard
|
|
32
|
+
from .desktop.capture import capture_region_and_ocr
|
|
33
|
+
from .mcp_server import run_mcp_server
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _emit_error(message: str, as_json: bool, code: int = 1) -> int:
|
|
37
|
+
err = {"status": "error", "code": code, "message": message}
|
|
38
|
+
if as_json:
|
|
39
|
+
print(json.dumps(err, ensure_ascii=False))
|
|
40
|
+
else:
|
|
41
|
+
print(f"[ERROR] {message}", file=sys.stderr)
|
|
42
|
+
return code
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_bbox(bbox_str: Optional[str]):
|
|
46
|
+
if not bbox_str:
|
|
47
|
+
return None
|
|
48
|
+
parts = [int(p.strip()) for p in bbox_str.split(",")]
|
|
49
|
+
if len(parts) != 4:
|
|
50
|
+
raise ValueError("Bbox must be 4 comma-separated integers: left,top,right,bottom")
|
|
51
|
+
return tuple(parts)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
55
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
56
|
+
try:
|
|
57
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
args_list = list(sys.argv[1:] if argv is None else argv)
|
|
62
|
+
|
|
63
|
+
# Normalize subcommands 'ocr', 'clip' / 'capture', 'tray', and 'config'
|
|
64
|
+
subcommand = None
|
|
65
|
+
if args_list and args_list[0] in ("ocr", "clip", "capture", "tray", "config"):
|
|
66
|
+
subcommand = args_list.pop(0)
|
|
67
|
+
if subcommand == "capture":
|
|
68
|
+
subcommand = "clip"
|
|
69
|
+
|
|
70
|
+
if subcommand == "tray":
|
|
71
|
+
from .desktop.tray_app import main as tray_main
|
|
72
|
+
return tray_main(args_list)
|
|
73
|
+
|
|
74
|
+
if subcommand == "config":
|
|
75
|
+
from .desktop.config_cli import run_config_cli
|
|
76
|
+
return run_config_cli(args_list)
|
|
77
|
+
|
|
78
|
+
parser = argparse.ArgumentParser(
|
|
79
|
+
prog=f"roascan {subcommand}" if subcommand else "roascan",
|
|
80
|
+
description=(
|
|
81
|
+
f"RoaScan v{__version__} — C++17 & ONNX local OCR: "
|
|
82
|
+
"Scanned PDF/TIFF/Image/Screen -> Searchable PDF & Structured Markdown."
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if subcommand == "clip":
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--bbox",
|
|
89
|
+
help="Coordinates left,top,right,bottom (e.g. 100,100,600,400). If omitted, interactive overlay opens.",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"-o",
|
|
93
|
+
"--out",
|
|
94
|
+
help="Optional output path (.md, .pdf, .json, .txt)",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--no-copy",
|
|
98
|
+
action="store_true",
|
|
99
|
+
help="Do not copy recognized text to system clipboard",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--panel",
|
|
103
|
+
action="store_true",
|
|
104
|
+
help="Display interactive result panel after recognition",
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"input",
|
|
109
|
+
nargs="?",
|
|
110
|
+
help="Input document path (.pdf, .tif, .tiff, .png, .jpg, .bmp). Omit when using --mcp or --doctor.",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
"-o",
|
|
114
|
+
"--output",
|
|
115
|
+
"--out",
|
|
116
|
+
dest="output",
|
|
117
|
+
help="Output path (.pdf, .md, .json, or .txt)",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"--md",
|
|
121
|
+
help="Explicit output path for structured Markdown (.md)",
|
|
122
|
+
)
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--pdf",
|
|
125
|
+
help="Explicit output path for restored Searchable PDF (.pdf)",
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"--copy",
|
|
129
|
+
action="store_true",
|
|
130
|
+
help="Copy recognized plain text directly to the system clipboard",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
parser.add_argument(
|
|
134
|
+
"--mcp",
|
|
135
|
+
action="store_true",
|
|
136
|
+
help="Launch the RoaScan Model Context Protocol (MCP) stdio server for AI agents.",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--doctor",
|
|
140
|
+
action="store_true",
|
|
141
|
+
help="Inspect native C++ core, ONNX Runtime providers, and model/dictionary availability.",
|
|
142
|
+
)
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"-l",
|
|
145
|
+
"--lang",
|
|
146
|
+
default="es",
|
|
147
|
+
choices=["es", "en", "latin"],
|
|
148
|
+
help="Language code: es, en, latin (default: es)",
|
|
149
|
+
)
|
|
150
|
+
parser.add_argument(
|
|
151
|
+
"--dpi",
|
|
152
|
+
type=int,
|
|
153
|
+
default=300,
|
|
154
|
+
help="Rasterization DPI [36..1200] (default: 300)",
|
|
155
|
+
)
|
|
156
|
+
parser.add_argument(
|
|
157
|
+
"--threads",
|
|
158
|
+
type=int,
|
|
159
|
+
default=0,
|
|
160
|
+
help="Maximum worker threads (default: auto)",
|
|
161
|
+
)
|
|
162
|
+
parser.add_argument(
|
|
163
|
+
"--no-enhance",
|
|
164
|
+
action="store_true",
|
|
165
|
+
help="Disable C++ background whitening and ink contrast enhancement",
|
|
166
|
+
)
|
|
167
|
+
parser.add_argument(
|
|
168
|
+
"--json",
|
|
169
|
+
action="store_true",
|
|
170
|
+
help="Emit machine-readable JSON summary to stdout (ideal for AI agents)",
|
|
171
|
+
)
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"-v",
|
|
174
|
+
"--version",
|
|
175
|
+
action="version",
|
|
176
|
+
version=f"RoaScan {__version__}",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
args = parser.parse_args(args_list)
|
|
180
|
+
|
|
181
|
+
if getattr(args, "mcp", False):
|
|
182
|
+
run_mcp_server()
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
if getattr(args, "doctor", False):
|
|
186
|
+
diag = RoaScanEngine.diagnose(language=args.lang)
|
|
187
|
+
print(json.dumps(diag, ensure_ascii=False, indent=2))
|
|
188
|
+
return 0 if diag["status"] == "ok" else 1
|
|
189
|
+
|
|
190
|
+
# --- Subcommand: clip / capture ----------------------------------------
|
|
191
|
+
if subcommand == "clip":
|
|
192
|
+
try:
|
|
193
|
+
target_bbox = _parse_bbox(args.bbox)
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
return _emit_error(str(exc), args.json, code=2)
|
|
196
|
+
|
|
197
|
+
interactive = (target_bbox is None)
|
|
198
|
+
try:
|
|
199
|
+
res = capture_region_and_ocr(
|
|
200
|
+
bbox=target_bbox,
|
|
201
|
+
interactive=interactive,
|
|
202
|
+
copy_clipboard=not args.no_copy,
|
|
203
|
+
language=args.lang,
|
|
204
|
+
dpi=args.dpi,
|
|
205
|
+
)
|
|
206
|
+
if not res.text and interactive:
|
|
207
|
+
if not args.json:
|
|
208
|
+
print("[RoaScan] Captura cancelada por el usuario.")
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
if args.out:
|
|
212
|
+
if args.out.lower().endswith((".md", ".markdown")):
|
|
213
|
+
res.save_markdown(args.out)
|
|
214
|
+
else:
|
|
215
|
+
with RoaScanDocumentWriter() as writer:
|
|
216
|
+
writer.configure_auto(args.out)
|
|
217
|
+
writer._atomic_write_text(args.out, res.text)
|
|
218
|
+
|
|
219
|
+
if args.json:
|
|
220
|
+
print(json.dumps(res.to_dict(), ensure_ascii=False, indent=2))
|
|
221
|
+
else:
|
|
222
|
+
copied_badge = " [Copiado al portapapeles]" if not args.no_copy else ""
|
|
223
|
+
print(f"[RoaScan OK]{copied_badge} {len(res.words)} palabras en {res.ocr_elapsed_ms} ms:\n")
|
|
224
|
+
print(res.text)
|
|
225
|
+
|
|
226
|
+
if getattr(args, "panel", False) and res.text:
|
|
227
|
+
from .desktop.result_panel import show_result_panel
|
|
228
|
+
show_result_panel(res.text, blocking=True)
|
|
229
|
+
return 0
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
return _emit_error(str(exc), args.json, code=1)
|
|
232
|
+
|
|
233
|
+
# --- Standard file OCR -------------------------------------------------
|
|
234
|
+
if not args.input:
|
|
235
|
+
parser.print_help()
|
|
236
|
+
return 1
|
|
237
|
+
|
|
238
|
+
t0 = time.perf_counter()
|
|
239
|
+
if not os.path.exists(args.input):
|
|
240
|
+
return _emit_error(f"Input file not found: {args.input}", args.json, code=1)
|
|
241
|
+
|
|
242
|
+
if args.dpi < 36 or args.dpi > 1200:
|
|
243
|
+
return _emit_error(f"Invalid DPI {args.dpi}: must be between 36 and 1200.", args.json, code=2)
|
|
244
|
+
|
|
245
|
+
# Validate output paths and format contracts upfront BEFORE running OCR
|
|
246
|
+
try:
|
|
247
|
+
validate_output_paths(
|
|
248
|
+
args.input,
|
|
249
|
+
[args.output, args.pdf, args.md],
|
|
250
|
+
expected_formats=["auto", "pdf", "md"],
|
|
251
|
+
)
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
return _emit_error(str(exc), args.json, code=2)
|
|
254
|
+
|
|
255
|
+
enhance = not args.no_enhance
|
|
256
|
+
needs_pdf_images = bool(
|
|
257
|
+
args.pdf or (args.output and args.output.lower().endswith(".pdf"))
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
with RoaScanEngine(language=args.lang) as engine:
|
|
262
|
+
engine.set_resolution(args.dpi, args.dpi)
|
|
263
|
+
engine.set_document_enhancement(enhance)
|
|
264
|
+
engine.set_retain_images(needs_pdf_images)
|
|
265
|
+
if args.threads > 0:
|
|
266
|
+
engine.set_thread_count(args.threads)
|
|
267
|
+
engine.set_image_file(args.input)
|
|
268
|
+
|
|
269
|
+
t_ocr0 = time.perf_counter()
|
|
270
|
+
rc = engine.recognize()
|
|
271
|
+
ocr_elapsed_ms = round((time.perf_counter() - t_ocr0) * 1000.0, 2)
|
|
272
|
+
|
|
273
|
+
if rc != 0:
|
|
274
|
+
msg = engine.last_error or f"Recognition pipeline failed with code {rc}"
|
|
275
|
+
return _emit_error(msg, args.json, code=abs(rc))
|
|
276
|
+
|
|
277
|
+
outputs_written: List[str] = []
|
|
278
|
+
with RoaScanDocumentWriter() as writer:
|
|
279
|
+
if args.output:
|
|
280
|
+
writer.configure_auto(args.output, enhance_quality=enhance)
|
|
281
|
+
w_rc = writer.write(engine)
|
|
282
|
+
if w_rc != 0 or not os.path.exists(args.output):
|
|
283
|
+
return _emit_error(
|
|
284
|
+
writer.last_error or f"Failed to write output file: {args.output}",
|
|
285
|
+
args.json,
|
|
286
|
+
code=2,
|
|
287
|
+
)
|
|
288
|
+
outputs_written.append(os.path.abspath(args.output))
|
|
289
|
+
|
|
290
|
+
if args.pdf:
|
|
291
|
+
writer.configure_pdf(args.pdf, enhance_quality=enhance)
|
|
292
|
+
w_rc = writer.write(engine)
|
|
293
|
+
if w_rc != 0 or not os.path.exists(args.pdf):
|
|
294
|
+
return _emit_error(
|
|
295
|
+
writer.last_error or f"Failed to write PDF file: {args.pdf}",
|
|
296
|
+
args.json,
|
|
297
|
+
code=2,
|
|
298
|
+
)
|
|
299
|
+
outputs_written.append(os.path.abspath(args.pdf))
|
|
300
|
+
|
|
301
|
+
if args.md:
|
|
302
|
+
writer.configure_markdown(args.md)
|
|
303
|
+
w_rc = writer.write(engine)
|
|
304
|
+
if w_rc != 0 or not os.path.exists(args.md):
|
|
305
|
+
return _emit_error(
|
|
306
|
+
writer.last_error or f"Failed to write Markdown file: {args.md}",
|
|
307
|
+
args.json,
|
|
308
|
+
code=2,
|
|
309
|
+
)
|
|
310
|
+
outputs_written.append(os.path.abspath(args.md))
|
|
311
|
+
|
|
312
|
+
if args.copy:
|
|
313
|
+
copy_to_clipboard(engine.get_text())
|
|
314
|
+
|
|
315
|
+
elapsed_ms = round((time.perf_counter() - t0) * 1000.0, 2)
|
|
316
|
+
if args.json:
|
|
317
|
+
summary = engine.to_dict()
|
|
318
|
+
summary["status"] = "ok"
|
|
319
|
+
summary["input"] = os.path.abspath(args.input)
|
|
320
|
+
summary["outputs"] = outputs_written
|
|
321
|
+
summary["ocr_elapsed_ms"] = ocr_elapsed_ms
|
|
322
|
+
summary["elapsed_ms"] = elapsed_ms
|
|
323
|
+
if args.copy:
|
|
324
|
+
summary["clipboard"] = "copied"
|
|
325
|
+
print(json.dumps(summary, ensure_ascii=False))
|
|
326
|
+
elif not outputs_written:
|
|
327
|
+
print(engine.to_markdown())
|
|
328
|
+
else:
|
|
329
|
+
copied_txt = " [Copiado al portapapeles]" if args.copy else ""
|
|
330
|
+
print(
|
|
331
|
+
f"[RoaScan OK]{copied_txt} Processed {engine.page_count} page(s) "
|
|
332
|
+
f"({len(engine.extract_words())} words) in {ocr_elapsed_ms} ms OCR "
|
|
333
|
+
f"({elapsed_ms} ms total) -> {', '.join(outputs_written)}"
|
|
334
|
+
)
|
|
335
|
+
except Exception as exc:
|
|
336
|
+
return _emit_error(str(exc), args.json, code=1)
|
|
337
|
+
|
|
338
|
+
return 0
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__":
|
|
342
|
+
sys.exit(main())
|
roascan/accent.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
roascan.accent: Accent restoration for Spanish/Latin OCR output.
|
|
3
|
+
|
|
4
|
+
OCR models frequently drop diacritics (accents, tildes). This module restores
|
|
5
|
+
them using a Hunspell lexicon with affix expansion (/G feminine, /S plural) and
|
|
6
|
+
process-wide dictionary caching: an unaccented word that is NOT itself a valid
|
|
7
|
+
dictionary word but has a unique accented dictionary form is mapped back to that
|
|
8
|
+
form (e.g. "REPUBLICA" -> "REPÚBLICA", "ELECTRONICA" -> "ELECTRÓNICA").
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from typing import Dict, Optional, Set, Tuple
|
|
13
|
+
|
|
14
|
+
# Spanish accented characters -> unaccented equivalents.
|
|
15
|
+
_STRIP_TABLE = str.maketrans(
|
|
16
|
+
"áéíóúüñÁÉÍÓÚÜÑ",
|
|
17
|
+
"aeiouunAEIOUUN",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AccentRestorer:
|
|
22
|
+
"""Restores Spanish accents on OCR words using a cached Hunspell dictionary."""
|
|
23
|
+
|
|
24
|
+
# Process-wide cache: path -> (valid_words, stripped_to_accented)
|
|
25
|
+
_DICT_CACHE: Dict[str, Tuple[Set[str], Dict[str, Set[str]]]] = {}
|
|
26
|
+
|
|
27
|
+
def __init__(self, dict_path: Optional[str] = None):
|
|
28
|
+
if dict_path is None:
|
|
29
|
+
pkg_dir = os.path.dirname(os.path.abspath(__file__))
|
|
30
|
+
base = os.path.dirname(pkg_dir)
|
|
31
|
+
cand_pkg = os.path.join(pkg_dir, "models", "es_ES.dic")
|
|
32
|
+
dict_path = cand_pkg if os.path.exists(cand_pkg) else os.path.join(base, "models", "es_ES.dic")
|
|
33
|
+
|
|
34
|
+
abs_path = os.path.abspath(dict_path)
|
|
35
|
+
if abs_path in self._DICT_CACHE:
|
|
36
|
+
self.valid_words, self.stripped_to_accented = self._DICT_CACHE[abs_path]
|
|
37
|
+
elif os.path.exists(abs_path):
|
|
38
|
+
self.valid_words = set()
|
|
39
|
+
self.stripped_to_accented = {}
|
|
40
|
+
self._load(abs_path)
|
|
41
|
+
self._DICT_CACHE[abs_path] = (self.valid_words, self.stripped_to_accented)
|
|
42
|
+
else:
|
|
43
|
+
self.valid_words = set()
|
|
44
|
+
self.stripped_to_accented = {}
|
|
45
|
+
|
|
46
|
+
def _register_word(self, w: str):
|
|
47
|
+
if not w or not w.isalpha():
|
|
48
|
+
return
|
|
49
|
+
self.valid_words.add(w)
|
|
50
|
+
stripped = self.strip_accents(w)
|
|
51
|
+
if stripped != w:
|
|
52
|
+
self.stripped_to_accented.setdefault(stripped, set()).add(w)
|
|
53
|
+
|
|
54
|
+
def _load(self, path: str):
|
|
55
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
56
|
+
for line in f:
|
|
57
|
+
parts = line.strip().split("/")
|
|
58
|
+
word = parts[0].strip().lower()
|
|
59
|
+
if not word or not word.isalpha():
|
|
60
|
+
continue
|
|
61
|
+
flags = parts[1] if len(parts) > 1 else ""
|
|
62
|
+
|
|
63
|
+
self._register_word(word)
|
|
64
|
+
|
|
65
|
+
# Expand standard Spanish Hunspell affix flags:
|
|
66
|
+
# 'G' = gender inflection (-o -> -a)
|
|
67
|
+
# 'S' = plural inflection (-s / -es)
|
|
68
|
+
has_g = "G" in flags
|
|
69
|
+
has_s = "S" in flags
|
|
70
|
+
|
|
71
|
+
if has_g and word.endswith("o"):
|
|
72
|
+
fem = word[:-1] + "a"
|
|
73
|
+
self._register_word(fem)
|
|
74
|
+
if has_s:
|
|
75
|
+
self._register_word(fem + "s")
|
|
76
|
+
|
|
77
|
+
if has_s:
|
|
78
|
+
if word.endswith(("a", "e", "i", "o", "u", "á", "é", "ó")):
|
|
79
|
+
self._register_word(word + "s")
|
|
80
|
+
elif word.endswith(("ión", "ion")):
|
|
81
|
+
pass # plural loses accent (e.g. facturaciones)
|
|
82
|
+
else:
|
|
83
|
+
self._register_word(word + "es")
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def strip_accents(s: str) -> str:
|
|
87
|
+
"""Map Spanish accented characters to their unaccented form."""
|
|
88
|
+
return s.translate(_STRIP_TABLE)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def is_available(self) -> bool:
|
|
92
|
+
return bool(self.valid_words)
|
|
93
|
+
|
|
94
|
+
def restore(self, text: str) -> str:
|
|
95
|
+
"""Restore accents across a whole string (word by word)."""
|
|
96
|
+
if not text:
|
|
97
|
+
return ""
|
|
98
|
+
return " ".join(self.restore_word(w) for w in text.split())
|
|
99
|
+
|
|
100
|
+
def restore_word(self, word: str) -> str:
|
|
101
|
+
"""Restore the accent of a single token, preserving surrounding punctuation and casing."""
|
|
102
|
+
if not word:
|
|
103
|
+
return word
|
|
104
|
+
|
|
105
|
+
# Separate leading and trailing non-alphabetic punctuation (e.g. "FACTURACION:")
|
|
106
|
+
start = 0
|
|
107
|
+
end = len(word)
|
|
108
|
+
while start < end and not word[start].isalpha():
|
|
109
|
+
start += 1
|
|
110
|
+
while end > start and not word[end - 1].isalpha():
|
|
111
|
+
end -= 1
|
|
112
|
+
|
|
113
|
+
if start >= end:
|
|
114
|
+
return word
|
|
115
|
+
|
|
116
|
+
prefix = word[:start]
|
|
117
|
+
core = word[start:end]
|
|
118
|
+
suffix = word[end:]
|
|
119
|
+
|
|
120
|
+
if not core.isalpha():
|
|
121
|
+
return word
|
|
122
|
+
|
|
123
|
+
lower = core.lower()
|
|
124
|
+
stripped = self.strip_accents(lower)
|
|
125
|
+
|
|
126
|
+
# If the unaccented form is itself a valid word (e.g. "como", "esta"),
|
|
127
|
+
# leave it alone to avoid wrong disambiguation.
|
|
128
|
+
if stripped in self.valid_words:
|
|
129
|
+
return word
|
|
130
|
+
|
|
131
|
+
candidates = self.stripped_to_accented.get(stripped, set())
|
|
132
|
+
if len(candidates) == 1:
|
|
133
|
+
accented = next(iter(candidates))
|
|
134
|
+
return prefix + self._apply_case(core, accented) + suffix
|
|
135
|
+
return word
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _apply_case(original: str, accented_lower: str) -> str:
|
|
139
|
+
if original.isupper():
|
|
140
|
+
return accented_lower.upper()
|
|
141
|
+
if original[0].isupper() and (len(original) == 1 or original[1:].islower()):
|
|
142
|
+
return accented_lower.capitalize()
|
|
143
|
+
return accented_lower
|