pro-ledin-ocr 0.1.0__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.
@@ -0,0 +1,52 @@
1
+ """pro.ledin.ocr — layered OCR workhorse.
2
+
3
+ Public library API (import as ``from pro.ledin import ocr``):
4
+
5
+ pages = ocr.recognize("scan.pdf", ocr.RecognizeOptions(engine="tesseract"))
6
+ markdown = ocr.to_markdown(pages, "scan.pdf")
7
+
8
+ Catch ``ocr.OcrError`` for recoverable failures (missing binaries/packages,
9
+ unsupported input, vision-api config). The library never calls ``sys.exit()``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .core import (
15
+ Cache,
16
+ Caps,
17
+ DEFAULT_MIN_CONF,
18
+ DEFAULT_PSM,
19
+ EXIT_BAD_ARGS,
20
+ EXIT_MISSING_BINARY,
21
+ EXIT_OK,
22
+ EXIT_UNSUPPORTED,
23
+ OcrError,
24
+ RecognizeOptions,
25
+ __version__,
26
+ probe_script_path,
27
+ process_file,
28
+ recognize,
29
+ to_json,
30
+ to_markdown,
31
+ to_text,
32
+ )
33
+
34
+ __all__ = [
35
+ "Cache",
36
+ "Caps",
37
+ "DEFAULT_MIN_CONF",
38
+ "DEFAULT_PSM",
39
+ "EXIT_BAD_ARGS",
40
+ "EXIT_MISSING_BINARY",
41
+ "EXIT_OK",
42
+ "EXIT_UNSUPPORTED",
43
+ "OcrError",
44
+ "RecognizeOptions",
45
+ "__version__",
46
+ "probe_script_path",
47
+ "process_file",
48
+ "recognize",
49
+ "to_json",
50
+ "to_markdown",
51
+ "to_text",
52
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
pro/ledin/ocr/cli.py ADDED
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env python3
2
+ """Command-line interface for the `pro.ledin.ocr` package (console script: ocr).
3
+
4
+ The recognition logic lives in `core`; this module owns only argument parsing
5
+ and output writing (formats, files, searchable PDF, quality report).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ from pathlib import Path
16
+
17
+ from .core import (
18
+ Cache,
19
+ Caps,
20
+ DEFAULT_MIN_CONF,
21
+ DEFAULT_PSM,
22
+ OcrError,
23
+ RecognizeOptions,
24
+ _log,
25
+ process_file,
26
+ to_json,
27
+ to_markdown,
28
+ to_text,
29
+ )
30
+
31
+ # ── output writing ────────────────────────────────────────────────────────────
32
+
33
+ caps_global: Caps # set in main()
34
+
35
+
36
+ def write_outputs(
37
+ pages_data: list[dict],
38
+ input_path: str,
39
+ args: argparse.Namespace,
40
+ lang: str,
41
+ dpi: int,
42
+ ) -> None:
43
+ filename = os.path.basename(input_path)
44
+ meta = {
45
+ "file": filename,
46
+ "engine": args.engine,
47
+ "lang": lang,
48
+ "dpi": dpi,
49
+ "preprocess": args.preprocess,
50
+ "min_conf": args.min_conf,
51
+ }
52
+
53
+ formats = args.format.split(",") if "," in args.format else [args.format]
54
+ if "all" in formats:
55
+ formats = ["md", "txt", "json"]
56
+
57
+ for fmt in formats:
58
+ if fmt == "md":
59
+ content = to_markdown(pages_data, filename)
60
+ elif fmt == "txt":
61
+ content = to_text(pages_data)
62
+ elif fmt == "json":
63
+ content = to_json(pages_data, meta)
64
+ else:
65
+ continue
66
+
67
+ if args.out:
68
+ out_path = args.out
69
+ if os.path.isdir(out_path) or "all" in [args.format] or len(formats) > 1:
70
+ os.makedirs(out_path, exist_ok=True)
71
+ stem = Path(input_path).stem
72
+ out_file = os.path.join(out_path, f"{stem}.{fmt}")
73
+ else:
74
+ out_file = out_path
75
+ with open(out_file, "w", encoding="utf-8") as f:
76
+ f.write(content)
77
+ print(f"[ocr] wrote {fmt} → {out_file}", file=sys.stderr)
78
+ else:
79
+ if len(formats) > 1:
80
+ print(f"\n{'='*60}\n[{fmt.upper()}]\n{'='*60}", flush=True)
81
+ print(content, flush=True)
82
+
83
+ # Optional JSON report
84
+ if args.json_report:
85
+ report_content = to_json(pages_data, meta)
86
+ with open(args.json_report, "w", encoding="utf-8") as f:
87
+ f.write(report_content)
88
+ print(f"[ocr] quality report → {args.json_report}", file=sys.stderr)
89
+
90
+ # Searchable PDF
91
+ if args.searchable_pdf:
92
+ if not caps_global.bin_ocrmypdf:
93
+ print("[ocr] WARNING: ocrmypdf not found. Install: brew install ocrmypdf", file=sys.stderr)
94
+ else:
95
+ cmd = [caps_global.bin_ocrmypdf, "-l", lang,
96
+ "--rotate-pages", "--deskew", "--force-ocr",
97
+ input_path, args.searchable_pdf]
98
+ try:
99
+ import subprocess
100
+ subprocess.run(cmd, check=True)
101
+ print(f"[ocr] searchable PDF → {args.searchable_pdf}", file=sys.stderr)
102
+ except Exception as e:
103
+ print(f"[ocr] ocrmypdf failed: {e}", file=sys.stderr)
104
+
105
+ # Print quality report summary
106
+ flagged = [p for p in pages_data if p.get("flag")]
107
+ if flagged:
108
+ print(f"\n[ocr] Quality report: {len(flagged)} page(s) flagged for review", file=sys.stderr)
109
+ for p in flagged:
110
+ print(f" Page {p['n']}: conf={p.get('mean_conf', '?')}, flag={p['flag']}", file=sys.stderr)
111
+ print("[ocr] Suggestion: re-run with --engine vision --pages "
112
+ + ",".join(str(p["n"]) for p in flagged), file=sys.stderr)
113
+
114
+
115
+ # ── argparse ──────────────────────────────────────────────────────────────────
116
+
117
+ def build_parser() -> argparse.ArgumentParser:
118
+ p = argparse.ArgumentParser(
119
+ prog="ocr",
120
+ description="Extract text from scanned PDFs and images using layered OCR.",
121
+ formatter_class=argparse.RawDescriptionHelpFormatter,
122
+ epilog="""
123
+ Examples:
124
+ ocr scan.pdf --format all
125
+ ocr photo.jpg --format md
126
+ ocr doc.pdf --lang rus+eng --preprocess full --format all
127
+ ocr slides.pdf --engine vision --pages 9,12
128
+ ocr *.pdf --cache cache.json --format txt
129
+ """,
130
+ )
131
+ p.add_argument("inputs", nargs="+", metavar="INPUT", help="PDF or image file(s)")
132
+ p.add_argument("--engine", default="auto",
133
+ choices=["auto", "tesseract", "easyocr", "paddleocr", "vision", "vision-api"],
134
+ help="OCR engine (default: auto)")
135
+ p.add_argument("--lang", default="auto",
136
+ help="Tesseract language code(s), e.g. rus+eng (default: auto via OSD)")
137
+ p.add_argument("--format", default="md",
138
+ help="Output format: md|txt|json|all (default: md)")
139
+ p.add_argument("--out", default="",
140
+ help="Output file or directory (default: stdout)")
141
+ p.add_argument("--dpi", type=int, default=0,
142
+ help="Rendering DPI (default: auto — 300 A4, 150 wide canvas)")
143
+ p.add_argument("--preprocess", default="auto",
144
+ choices=["none", "basic", "enhanced", "full", "auto"],
145
+ help="Image preprocessing level (default: auto)")
146
+ p.add_argument("--pages", default="",
147
+ help="Page range to process, e.g. 1-3,5 (default: all)")
148
+ p.add_argument("--max-pages", type=int, default=0,
149
+ help="Maximum pages per file (default: all)")
150
+ p.add_argument("--psm", type=int, default=DEFAULT_PSM,
151
+ help=f"Tesseract PSM (default: {DEFAULT_PSM}; 6 for single-block)")
152
+ p.add_argument("--min-conf", type=float, default=DEFAULT_MIN_CONF,
153
+ help=f"Confidence threshold for flagging pages (default: {DEFAULT_MIN_CONF})")
154
+ p.add_argument("--cache", default="",
155
+ help="Cache file path (JSON) for skipping already-processed files")
156
+ p.add_argument("--force", action="store_true",
157
+ help="Ignore cache and re-process all files")
158
+ p.add_argument("--skip-ocr", action="store_true",
159
+ help="Only process files with a real text layer; skip OCR")
160
+ p.add_argument("--no-cleanup", action="store_true",
161
+ help="Skip whitespace / ligature cleanup of OCR output")
162
+ p.add_argument("--vision-api-url", default="",
163
+ help="OpenAI-compatible base URL for --engine vision-api")
164
+ p.add_argument("--vision-api-key", default="",
165
+ help="API key for --engine vision-api (required; env vars are not read)")
166
+ p.add_argument("--vision-model", default="",
167
+ help="Model name for --engine vision-api (required; no default)")
168
+ p.add_argument("--searchable-pdf", default="",
169
+ help="Path for searchable PDF output (requires ocrmypdf)")
170
+ p.add_argument("--json-report", default="",
171
+ help="Path to write JSON quality report")
172
+ p.add_argument("--verbose", "-v", action="store_true",
173
+ help="Verbose logging to stderr")
174
+ return p
175
+
176
+
177
+ # ── entry point ───────────────────────────────────────────────────────────────
178
+
179
+ def run(argv: list[str] | None = None) -> None:
180
+ global caps_global
181
+ parser = build_parser()
182
+ args = parser.parse_args(argv)
183
+
184
+ options = RecognizeOptions(
185
+ engine=args.engine,
186
+ lang=args.lang,
187
+ dpi=args.dpi,
188
+ preprocess=args.preprocess,
189
+ pages=args.pages,
190
+ max_pages=args.max_pages,
191
+ psm=args.psm,
192
+ min_conf=args.min_conf,
193
+ no_cleanup=args.no_cleanup,
194
+ force=args.force,
195
+ vision_api_url=args.vision_api_url,
196
+ vision_api_key=args.vision_api_key,
197
+ vision_model=args.vision_model,
198
+ verbose=args.verbose,
199
+ )
200
+
201
+ caps = Caps(verbose=args.verbose)
202
+ caps_global = caps
203
+
204
+ # Validate binary requirements for the chosen engine
205
+ if args.engine in ("auto", "tesseract"):
206
+ caps.require_ocr()
207
+
208
+ cache = Cache(args.cache if args.cache else None)
209
+
210
+ # Vision handoff must persist rendered PNGs after this process exits so the
211
+ # calling agent (Claude, GPT, or any multimodal model) can read them.
212
+ # Other engines use a throwaway temp dir.
213
+ if args.engine == "vision":
214
+ tmpdir = tempfile.mkdtemp(prefix="ocr_skill_vision_")
215
+ print(f"[ocr] vision PNGs will persist in: {tmpdir}", file=sys.stderr)
216
+ cleanup_tmp = False
217
+ else:
218
+ tmp_ctx = tempfile.TemporaryDirectory(prefix="ocr_skill_")
219
+ tmpdir = tmp_ctx.name
220
+ cleanup_tmp = True
221
+
222
+ try:
223
+ for input_path in args.inputs:
224
+ if not os.path.exists(input_path):
225
+ print(f"[ocr] WARNING: file not found: {input_path}", file=sys.stderr)
226
+ continue
227
+
228
+ _log(f"processing: {input_path}", args.verbose)
229
+ t_start = time.time()
230
+
231
+ pages_data = process_file(input_path, options, caps, cache, tmpdir)
232
+
233
+ # Resolve effective lang for output meta (might have been auto-detected)
234
+ effective_lang = args.lang
235
+ if effective_lang == "auto" and pages_data:
236
+ # Best we can do without re-running OSD
237
+ effective_lang = "auto-detected"
238
+
239
+ effective_dpi = args.dpi or 0
240
+
241
+ write_outputs(pages_data, input_path, args, effective_lang, effective_dpi)
242
+
243
+ elapsed = time.time() - t_start
244
+ total_chars = sum(len(p.get("text", "")) for p in pages_data)
245
+ _log(f"done: {len(pages_data)} pages, {total_chars} chars, {elapsed:.1f}s total",
246
+ args.verbose)
247
+ finally:
248
+ if cleanup_tmp:
249
+ tmp_ctx.cleanup()
250
+
251
+
252
+ def main(argv: list[str] | None = None) -> int:
253
+ """Console-script entry point. Returns a process exit code."""
254
+ try:
255
+ run(argv)
256
+ except OcrError as exc:
257
+ print(f"[ocr] ERROR: {exc}", file=sys.stderr)
258
+ return exc.code
259
+ return 0
260
+
261
+
262
+ def probe_main(argv: list[str] | None = None) -> int:
263
+ """Console-script entry point (`ocr-probe`): run the bundled probe.sh helper.
264
+
265
+ Triages a single file for OCR need and prints one-line JSON to stdout.
266
+ """
267
+ import subprocess
268
+
269
+ from .core import probe_script_path
270
+
271
+ args = list(sys.argv[1:] if argv is None else argv)
272
+ return subprocess.run(["bash", probe_script_path(), *args]).returncode
273
+
274
+
275
+ if __name__ == "__main__":
276
+ sys.exit(main())