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 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