ocr-software 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.
- ocr_software/__init__.py +6 -0
- ocr_software/__main__.py +5 -0
- ocr_software/advice.py +86 -0
- ocr_software/cli.py +122 -0
- ocr_software/client.py +362 -0
- ocr_software-0.1.0.dist-info/METADATA +88 -0
- ocr_software-0.1.0.dist-info/RECORD +11 -0
- ocr_software-0.1.0.dist-info/WHEEL +5 -0
- ocr_software-0.1.0.dist-info/entry_points.txt +2 -0
- ocr_software-0.1.0.dist-info/licenses/LICENSE +21 -0
- ocr_software-0.1.0.dist-info/top_level.txt +1 -0
ocr_software/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""ocr-software.com: client and command line for the OCR API."""
|
|
2
|
+
from .advice import default_pipeline, price_sentence
|
|
3
|
+
from .client import Client, OcrError, PagesUnread, split_pages, unread_message
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
__all__ = ["Client", "OcrError", "PagesUnread", "split_pages", "unread_message", "default_pipeline", "price_sentence", "__version__"]
|
ocr_software/__main__.py
ADDED
ocr_software/advice.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Words for a human: price in euros, the default pipeline, a short pipeline line.
|
|
2
|
+
|
|
3
|
+
One copy for the command line and the MCP server, so both say the same. Nothing here calls the network.
|
|
4
|
+
Unit: 100,000 millicents = 1 EUR; factor 1 = 100 millicents = EUR 0.001 per page.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
# The price rate lives here and nowhere else: showing pages of the base pipeline instead of euros changes eur() and price_sentence().
|
|
9
|
+
MILLICENTS_PER_EUR = 100000
|
|
10
|
+
MILLICENTS_PER_FACTOR = 100 # factor 1 = the base price per page
|
|
11
|
+
FAIR_USE = "Free use has limits (https://ocr-software.com/legal/fair-usage/); for more volume write to info@ocr-software.com."
|
|
12
|
+
SWITCH_HINTS = {
|
|
13
|
+
"hard_pages": "for hard pages (poor scans, doubtful words) use %s",
|
|
14
|
+
"turned_pages": "for turned pages use %s",
|
|
15
|
+
"no_tables": "without tables %s is cheaper",
|
|
16
|
+
"clean_bulk": "for clean bulk text %s is cheaper",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def eur(millicents):
|
|
21
|
+
"""1 EUR = 100000 millicents; at least two decimals, as many as needed up to four."""
|
|
22
|
+
text = ("%.4f" % (millicents / MILLICENTS_PER_EUR)).rstrip("0")
|
|
23
|
+
return "EUR " + text + "0" * (2 - len(text.partition(".")[2]))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def price_sentence(q, show_list_price=True):
|
|
27
|
+
"""What the human reads before saying yes, from a dry run. Nothing is charged today (billing off or free pages): the price
|
|
28
|
+
is still shown, as information, with the fair-use link and the contact."""
|
|
29
|
+
pages, name = q["pages"], q["pipeline"]
|
|
30
|
+
price, charge, per_page = q.get("price_millicents") or 0, q.get("max_charge_millicents") or 0, q.get("price_per_page_millicents") or 0
|
|
31
|
+
start = "%d page%s with %s" % (pages, "" if pages == 1 else "s", name)
|
|
32
|
+
if not price:
|
|
33
|
+
text = "%s: nothing is charged (this service has no prices set)." % start
|
|
34
|
+
elif not charge:
|
|
35
|
+
text = "%s: you pay %s today." % (start, eur(0))
|
|
36
|
+
if show_list_price:
|
|
37
|
+
text += " List price %s in total (%s per page), shown for information only. %s" % (eur(price), eur(per_page), FAIR_USE)
|
|
38
|
+
else:
|
|
39
|
+
text = "%s: up to %s is charged (%s per page)." % (start, eur(charge), eur(per_page))
|
|
40
|
+
covered = q.get("pages_covered", pages)
|
|
41
|
+
if covered < pages:
|
|
42
|
+
text += " Only %d of %d pages would run." % (covered, pages)
|
|
43
|
+
return text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cost(p):
|
|
47
|
+
"""Price per page in millicents, comparable between pipelines: the catalog price, else factor * 100 (the catalog hides the
|
|
48
|
+
price while billing is off, the factor stays)."""
|
|
49
|
+
price = p.get("price_per_page_millicents")
|
|
50
|
+
if isinstance(price, (int, float)) and price > 0:
|
|
51
|
+
return price
|
|
52
|
+
factor = p.get("factor")
|
|
53
|
+
return factor * MILLICENTS_PER_FACTOR if isinstance(factor, (int, float)) else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def preview(p):
|
|
57
|
+
"""A preview (preview: true, or version @0) has no long-term promise: never a default."""
|
|
58
|
+
return bool(p.get("preview")) or str(p.get("pipeline", "")).endswith("@0")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def usable(p):
|
|
62
|
+
return p.get("available", True) and p.get("orderable", True) is not False and not preview(p)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def default_pipeline(pipelines):
|
|
66
|
+
"""name@version of the pipeline used when the caller names none: the fastest and cheapest usable one (rule in ziel.md).
|
|
67
|
+
Not ready and preview pipelines are out. Order: price, then not autoregressive (faster), then the name, so a tie never
|
|
68
|
+
depends on the order of the catalog. None if nothing is usable. The caller must say which one was chosen."""
|
|
69
|
+
best = min(((cost(p), bool(p.get("autoregressive")), p.get("pipeline", "")) for p in pipelines if usable(p) and cost(p) is not None),
|
|
70
|
+
default=None)
|
|
71
|
+
return best[2] if best else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def summary(p, default=None):
|
|
75
|
+
"""One short line for a pipeline (without its name): what it delivers, what it costs, when to choose another one."""
|
|
76
|
+
feats = p.get("features") or []
|
|
77
|
+
parts = ["%s boxes" % p["level"] if p.get("level") else "", "tables" if "table_cells" in feats else "",
|
|
78
|
+
"layout" if "layout_classes" in feats else "", "slower" if p.get("autoregressive") else ""]
|
|
79
|
+
line = ", ".join(x for x in parts if x) or p.get("description", "")[:80]
|
|
80
|
+
price = cost(p)
|
|
81
|
+
if price is not None:
|
|
82
|
+
line += "; price %s per page" % eur(price)
|
|
83
|
+
if default and p.get("pipeline") == default:
|
|
84
|
+
line += "; the default (fastest and cheapest)"
|
|
85
|
+
hints = [SWITCH_HINTS[s["when"]] % s["pipeline"] for s in p.get("switch") or [] if s.get("when") in SWITCH_HINTS and s.get("pipeline")]
|
|
86
|
+
return line + ("; " + "; ".join(hints) if hints else "")
|
ocr_software/cli.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Command line: ocr-software IN [-o OUT] --pipeline NAME@VERSION [--format md|text|json|hocr|pdf].
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 done, every page read | 1 service or network error | 2 wrong call, unreadable input or output, no key
|
|
4
|
+
| 3 job done but a page failed or did not run (the result is still written, the message says what is missing)
|
|
5
|
+
| 4 the job did not finish within --timeout (it keeps running; the message names the job).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .advice import default_pipeline, price_sentence, summary
|
|
15
|
+
from .client import DEFAULT_WAIT_TIMEOUT_S, FORMATS, Client, OcrError, PagesUnread
|
|
16
|
+
|
|
17
|
+
EXIT_ERROR, EXIT_USAGE, EXIT_UNREAD, EXIT_TIMEOUT = 1, 2, 3, 4
|
|
18
|
+
USAGE_CODES = ("bad_input", "no_key")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parser():
|
|
22
|
+
p = argparse.ArgumentParser(prog="ocr-software", description="Read a PDF, PNG, JPEG or TIFF with the ocr-software.com API.")
|
|
23
|
+
p.add_argument("input", nargs="?", help="the document")
|
|
24
|
+
p.add_argument("-o", "--output", help="output file; without it the result goes to stdout (not for pdf)")
|
|
25
|
+
p.add_argument("--pipeline", help="name@version (default: the fastest and cheapest available one, named on stderr); --list-pipelines shows the choices")
|
|
26
|
+
p.add_argument("--format", default="text", choices=FORMATS, help="default text")
|
|
27
|
+
p.add_argument("--dpi", type=int, choices=(150, 200, 300), help="render resolution of a PDF (default 150)")
|
|
28
|
+
p.add_argument("--job-budget", type=int, help="cap in millicents (100,000 = 1 EUR; one page of the cheapest pipeline = 100). Where billing is off it is ignored")
|
|
29
|
+
p.add_argument("--dry-run", action="store_true", help="show the page count and the price in euros, run nothing, cost nothing")
|
|
30
|
+
p.add_argument("--timeout", type=int, default=DEFAULT_WAIT_TIMEOUT_S, help="seconds to wait for the job (default %(default)s)")
|
|
31
|
+
p.add_argument("--base-url", help="address of the service (default OCR_SOFTWARE_BASE_URL, else the hosted API)")
|
|
32
|
+
p.add_argument("--list-pipelines", action="store_true", help="print the available pipelines (name, tab, what it delivers and costs) and the default one, then exit")
|
|
33
|
+
p.add_argument("--version", action="version", version="ocr-software " + __version__)
|
|
34
|
+
return p
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _write(path, data):
|
|
38
|
+
"""Atomic: a half written output file never exists (a pre-consume hook may pick it up at once)."""
|
|
39
|
+
tmp = "%s.%d.part" % (path, os.getpid())
|
|
40
|
+
try:
|
|
41
|
+
with open(tmp, "xb") as f: # normal file mode, unlike mkstemp
|
|
42
|
+
f.write(data)
|
|
43
|
+
os.replace(tmp, path)
|
|
44
|
+
except BaseException:
|
|
45
|
+
if os.path.exists(tmp):
|
|
46
|
+
os.unlink(tmp)
|
|
47
|
+
raise
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _same_file(a, b):
|
|
51
|
+
try:
|
|
52
|
+
return os.path.exists(b) and os.path.samefile(a, b)
|
|
53
|
+
except OSError: # input missing: the client reports it with the right code
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _emit(data, output):
|
|
58
|
+
if output:
|
|
59
|
+
_write(output, data)
|
|
60
|
+
else:
|
|
61
|
+
sys.stdout.buffer.write(data)
|
|
62
|
+
sys.stdout.buffer.flush()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main(argv=None):
|
|
66
|
+
for stream in (sys.stderr, sys.stdout): # a Windows console must not fail on a file name
|
|
67
|
+
if hasattr(stream, "reconfigure"):
|
|
68
|
+
stream.reconfigure(errors="backslashreplace")
|
|
69
|
+
parser = _parser()
|
|
70
|
+
args = parser.parse_args(argv)
|
|
71
|
+
try:
|
|
72
|
+
client = Client(base_url=args.base_url)
|
|
73
|
+
if args.list_pipelines:
|
|
74
|
+
pipelines = client.catalog().get("pipelines", [])
|
|
75
|
+
default = default_pipeline(pipelines)
|
|
76
|
+
for p in pipelines:
|
|
77
|
+
if p.get("available"):
|
|
78
|
+
print("%s %s" % (p.get("pipeline"), summary(p, default)))
|
|
79
|
+
if default:
|
|
80
|
+
print("ocr-software: without --pipeline the default is %s (the fastest and cheapest available one)" % default, file=sys.stderr)
|
|
81
|
+
return 0
|
|
82
|
+
if not args.input:
|
|
83
|
+
parser.error("IN is required (--list-pipelines shows the pipelines)")
|
|
84
|
+
if args.format == "pdf" and not args.output:
|
|
85
|
+
parser.error("--format pdf needs -o OUT (a PDF is not written to the terminal)")
|
|
86
|
+
if args.output and _same_file(args.input, args.output):
|
|
87
|
+
parser.error("OUT is the input file; it would be overwritten")
|
|
88
|
+
if args.output and not os.path.isdir(os.path.dirname(os.path.abspath(args.output))):
|
|
89
|
+
parser.error("folder of %s does not exist; nothing was sent" % args.output) # before the job, not after it
|
|
90
|
+
pipeline = args.pipeline
|
|
91
|
+
if not pipeline: # the default is named, never silent (ziel.md: fastest and cheapest available; stdout stays the result)
|
|
92
|
+
pipeline = client.default_pipeline()
|
|
93
|
+
print("ocr-software: no --pipeline given, using %s (the fastest and cheapest available one; --list-pipelines shows the others)" % pipeline,
|
|
94
|
+
file=sys.stderr)
|
|
95
|
+
if args.dry_run:
|
|
96
|
+
q = client.quote(args.input, pipeline, args.dpi)
|
|
97
|
+
q.setdefault("pipeline", pipeline)
|
|
98
|
+
print(price_sentence(q))
|
|
99
|
+
return 0
|
|
100
|
+
data = client.ocr(args.input, pipeline, args.format, timeout=args.timeout, dpi=args.dpi, job_budget=args.job_budget)
|
|
101
|
+
_emit(data, args.output)
|
|
102
|
+
return 0
|
|
103
|
+
except PagesUnread as e:
|
|
104
|
+
try:
|
|
105
|
+
_emit(e.data, args.output)
|
|
106
|
+
except OSError as w:
|
|
107
|
+
print("ocr-software: cannot write output: %s" % w, file=sys.stderr)
|
|
108
|
+
print("ocr-software: %s; the result is incomplete" % e, file=sys.stderr)
|
|
109
|
+
return EXIT_UNREAD
|
|
110
|
+
except OcrError as e:
|
|
111
|
+
print("ocr-software: %s" % e, file=sys.stderr)
|
|
112
|
+
return EXIT_USAGE if e.code in USAGE_CODES else EXIT_TIMEOUT if e.code == "timeout" else EXIT_ERROR
|
|
113
|
+
except OSError as e:
|
|
114
|
+
print("ocr-software: cannot write output: %s" % e, file=sys.stderr)
|
|
115
|
+
return EXIT_USAGE
|
|
116
|
+
except KeyboardInterrupt: # Ctrl+C: no traceback; a job already submitted keeps running and is not billed twice on retry
|
|
117
|
+
print("ocr-software: interrupted; a job already submitted keeps running on the service", file=sys.stderr)
|
|
118
|
+
return 130
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
sys.exit(main())
|
ocr_software/client.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Client for the ocr-software.com API (standard library only, Python 3.9 or newer).
|
|
2
|
+
|
|
3
|
+
Settings: OCR_SOFTWARE_API_KEY (or OCR_KEY, the name used in the API docs), OCR_SOFTWARE_BASE_URL (or OCR_BASE_URL),
|
|
4
|
+
OCR_SOFTWARE_ALLOWED_DIR (optional). The key is never printed, never in an error message, never in a log.
|
|
5
|
+
Spec of every path, field and error code: <base>/openapi.json.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import http.client
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import stat
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
import uuid
|
|
19
|
+
|
|
20
|
+
from .advice import default_pipeline
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://api.ocr-software.com"
|
|
23
|
+
MAX_RETRIES = 5 # retry rules of agents.md: 429, 500, 502, 503, 504 and network errors, waits 1, 2, 4, 8, 16 s
|
|
24
|
+
RETRY_STATUS = (429, 500, 502, 503, 504) # 502/504: proxy in front of a restarting service (seen 13.11.2026)
|
|
25
|
+
BACKOFF_BASE_S = 1.0 # tests set it to 0
|
|
26
|
+
RETRY_AFTER_LIMIT_S = 60 # longer: stop (locked_out has 900)
|
|
27
|
+
HTTP_TIMEOUT_S = 90 # a status call waits up to 60 s on the server
|
|
28
|
+
CONNECT_TIMEOUT_S = 10 # a wrong or unreachable address must fail fast, not after the read timeout
|
|
29
|
+
PROBE_MIN_BODY = 64 * 1024 # an upload cut after this much may have been refused early (wrong key, pipeline, address): ask
|
|
30
|
+
CONNECT_RETRIES = 2 # no connection at all (refused, unknown host, no route): 2 retries, not 5
|
|
31
|
+
WAIT_SLICE_S = 30 # one long poll of wait()
|
|
32
|
+
DEFAULT_WAIT_TIMEOUT_S = 3600
|
|
33
|
+
FORMATS = ("json", "text", "md", "hocr", "pdf")
|
|
34
|
+
MAX_DOCUMENT_BYTES = 100 * 1024 * 1024 # same as max_body_bytes of the service
|
|
35
|
+
MAX_RESPONSE_BYTES = 256 * 1024 * 1024 # a larger result is refused, never held in memory
|
|
36
|
+
DOCUMENT_MAGIC = (b"%PDF-", b"\x89PNG\r\n\x1a\n", b"\xff\xd8\xff", b"II*\x00", b"MM\x00*") # PDF, PNG, JPEG, TIFF
|
|
37
|
+
JOB_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
|
|
38
|
+
KEY_CHARS = re.compile(r"^[\x21-\x7e]+$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class OcrError(Exception):
|
|
42
|
+
"""Any failure. code is the API error code (unauthorized, busy, ...) or one of: no_key, bad_input, network, timeout."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, message, code="error", http_status=0):
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.code = code
|
|
47
|
+
self.http_status = http_status # 0 if the failure was not an HTTP answer
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PagesUnread(OcrError):
|
|
51
|
+
"""The job is done but not every page was read. data is the result anyway, job the final job state."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, message, job, data):
|
|
54
|
+
super().__init__(message, code="pages_unread")
|
|
55
|
+
self.job = job
|
|
56
|
+
self.data = data
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
60
|
+
"""The key must never follow a redirect to another host."""
|
|
61
|
+
|
|
62
|
+
def redirect_request(self, *args, **kwargs):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _ConnectTimeoutMixin:
|
|
67
|
+
"""Connect with CONNECT_TIMEOUT_S, then read with the normal timeout (urllib has one timeout for both)."""
|
|
68
|
+
|
|
69
|
+
def connect(self):
|
|
70
|
+
read_timeout, self.timeout = self.timeout, CONNECT_TIMEOUT_S
|
|
71
|
+
try:
|
|
72
|
+
super().connect()
|
|
73
|
+
finally:
|
|
74
|
+
self.timeout = read_timeout
|
|
75
|
+
self.sock.settimeout(read_timeout)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _HTTPConnection(_ConnectTimeoutMixin, http.client.HTTPConnection):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _HTTPSConnection(_ConnectTimeoutMixin, http.client.HTTPSConnection):
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _HTTPHandler(urllib.request.HTTPHandler):
|
|
87
|
+
def http_open(self, req):
|
|
88
|
+
return self.do_open(_HTTPConnection, req)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _HTTPSHandler(urllib.request.HTTPSHandler):
|
|
92
|
+
def https_open(self, req):
|
|
93
|
+
return self.do_open(_HTTPSConnection, req)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_OPENER = urllib.request.build_opener(_NoRedirect, _HTTPHandler, _HTTPSHandler)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_CUT_OFF = (ConnectionResetError, ConnectionAbortedError, BrokenPipeError) # Windows 10053/10054, Linux EPIPE/ECONNRESET
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _int(text):
|
|
103
|
+
try:
|
|
104
|
+
return int(text)
|
|
105
|
+
except (TypeError, ValueError):
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _error_of(e):
|
|
110
|
+
"""Code and message of an API error body {"error":{"code","message","details","missing","pipelines"}}."""
|
|
111
|
+
try:
|
|
112
|
+
err = json.loads(e.read(65536))["error"] # bounded: an error body is short
|
|
113
|
+
except (ValueError, KeyError, TypeError, OSError, http.client.HTTPException):
|
|
114
|
+
return "http_error", str(e.reason)
|
|
115
|
+
message = err.get("message", "")
|
|
116
|
+
extra = {k: err[k] for k in ("details", "missing", "pipelines") if k in err}
|
|
117
|
+
details = extra.get("details")
|
|
118
|
+
if isinstance(details, list) and all(isinstance(d, str) and d in message for d in details):
|
|
119
|
+
del extra["details"] # the service joins the details into the message: show them once
|
|
120
|
+
return err.get("code", "http_error"), message + (" " + json.dumps(extra) if extra else "")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _refusal(url, head):
|
|
124
|
+
"""The real answer to an upload that was cut. The service (behind its proxy) answers a wrong key, pipeline or address
|
|
125
|
+
before it reads the body and closes; a client that is still sending gets a reset and never sees the answer (Windows
|
|
126
|
+
10053/10054, found 13.11.2026). The same request with an empty body is refused for the same reason at once, and
|
|
127
|
+
otherwise answers empty_document: then the key and the pipeline are fine and the cut was load. Returns an OcrError
|
|
128
|
+
to raise, or None if the cut is to be retried."""
|
|
129
|
+
probe_head = {k: v for k, v in head.items() if k != "Idempotency-Key"} # the probe is not the upload
|
|
130
|
+
try:
|
|
131
|
+
with _OPENER.open(urllib.request.Request(url, data=b"", method="POST", headers=probe_head), timeout=CONNECT_TIMEOUT_S):
|
|
132
|
+
return None
|
|
133
|
+
except urllib.error.HTTPError as e:
|
|
134
|
+
code, message = _error_of(e)
|
|
135
|
+
if code == "empty_document" or (e.code in RETRY_STATUS and code != "locked_out"):
|
|
136
|
+
return None
|
|
137
|
+
return OcrError("%d %s: %s" % (e.code, code, message), code, e.code)
|
|
138
|
+
except (OSError, http.client.HTTPException): # no answer to the probe either: the normal retry rules decide
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_PAGE_HEADING = re.compile(r"^## Page (\d+)\n\n", re.M)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def split_pages(text, format="text"):
|
|
146
|
+
"""The pages of a result as [(number, text)]. format "text": pages are separated by a form feed (as pdftotext does), "md":
|
|
147
|
+
every page starts with the heading "## Page N" (the heading is not part of the page text). A result that does not look
|
|
148
|
+
like either format is one page, never an error."""
|
|
149
|
+
if format == "md":
|
|
150
|
+
starts, want = [], 1
|
|
151
|
+
for m in _PAGE_HEADING.finditer(text): # a heading the model wrote inside a page does not count: numbers run 1, 2, 3 ...
|
|
152
|
+
if int(m.group(1)) == want:
|
|
153
|
+
starts.append(m)
|
|
154
|
+
want += 1
|
|
155
|
+
if starts:
|
|
156
|
+
ends = [m.start() for m in starts[1:]] + [len(text)]
|
|
157
|
+
return [(i + 1, text[m.end():end].rstrip("\n") + "\n") for i, (m, end) in enumerate(zip(starts, ends))]
|
|
158
|
+
return [(1, text)]
|
|
159
|
+
return [(i + 1, page) for i, page in enumerate(text.split("\f"))]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def check_job_id(job_id):
|
|
163
|
+
if not isinstance(job_id, str) or not JOB_ID.match(job_id):
|
|
164
|
+
raise OcrError("invalid job_id", "bad_input")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def read_document(path, allowed_dir=None):
|
|
168
|
+
"""Read a local document: inside allowed_dir if set (realpath, so no escape by symlink or ..), a regular file of
|
|
169
|
+
at most 100 MiB, starting with the magic bytes of PDF, PNG, JPEG or TIFF."""
|
|
170
|
+
real = os.path.realpath(path)
|
|
171
|
+
if allowed_dir:
|
|
172
|
+
allowed = os.path.realpath(allowed_dir)
|
|
173
|
+
try:
|
|
174
|
+
inside = os.path.commonpath([real, allowed]) == allowed
|
|
175
|
+
except ValueError: # other drive on Windows
|
|
176
|
+
inside = False
|
|
177
|
+
if not inside:
|
|
178
|
+
raise OcrError("file is outside the allowed folder: %s" % path, "bad_input")
|
|
179
|
+
try:
|
|
180
|
+
with open(real, "rb") as f: # one open: the file that is checked is the file that is sent
|
|
181
|
+
st = os.fstat(f.fileno())
|
|
182
|
+
if not stat.S_ISREG(st.st_mode):
|
|
183
|
+
raise OcrError("not a file: %s" % path, "bad_input")
|
|
184
|
+
if st.st_size > MAX_DOCUMENT_BYTES:
|
|
185
|
+
raise OcrError("file is larger than %d MiB: %s" % (MAX_DOCUMENT_BYTES >> 20, path), "bad_input")
|
|
186
|
+
data = f.read(MAX_DOCUMENT_BYTES + 1) # bounded even if the file grows meanwhile
|
|
187
|
+
except OSError:
|
|
188
|
+
raise OcrError("not a file: %s" % path, "bad_input")
|
|
189
|
+
if len(data) > MAX_DOCUMENT_BYTES:
|
|
190
|
+
raise OcrError("file is larger than %d MiB: %s" % (MAX_DOCUMENT_BYTES >> 20, path), "bad_input")
|
|
191
|
+
# like the service: a PDF may start with junk, "%PDF-" anywhere in the first 1024 bytes
|
|
192
|
+
if not data:
|
|
193
|
+
raise OcrError("file is empty: %s" % path, "bad_input")
|
|
194
|
+
if not (data.startswith(DOCUMENT_MAGIC) or b"%PDF-" in data[:1024]):
|
|
195
|
+
raise OcrError("not a PDF, PNG, JPEG or TIFF file: %s" % path, "bad_input")
|
|
196
|
+
return data
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def unread_message(job):
|
|
200
|
+
"""None if every page was read, else one sentence saying what is missing."""
|
|
201
|
+
failed, not_run, stop = job.get("pages_failed", 0), job.get("pages_not_run", 0), job.get("stop")
|
|
202
|
+
if not (failed or not_run or stop):
|
|
203
|
+
return None
|
|
204
|
+
parts = []
|
|
205
|
+
if failed:
|
|
206
|
+
parts.append("%d page(s) failed" % failed)
|
|
207
|
+
if not_run:
|
|
208
|
+
parts.append("%d page(s) not run" % not_run)
|
|
209
|
+
if stop:
|
|
210
|
+
parts.append("stopped: %s" % stop)
|
|
211
|
+
return "job %s is done but not every page was read: %s" % (job.get("job_id", "?"), ", ".join(parts))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Client:
|
|
215
|
+
def __init__(self, api_key=None, base_url=None, allowed_dir=None):
|
|
216
|
+
env = os.environ
|
|
217
|
+
key = api_key if api_key is not None else env.get("OCR_SOFTWARE_API_KEY") or env.get("OCR_KEY", "")
|
|
218
|
+
self._key = key.strip() # a key copied from a file or $(cat key) often ends in a newline
|
|
219
|
+
self.base_url = (base_url or env.get("OCR_SOFTWARE_BASE_URL") or env.get("OCR_BASE_URL") or DEFAULT_BASE_URL).strip().rstrip("/")
|
|
220
|
+
self.allowed_dir = allowed_dir if allowed_dir is not None else env.get("OCR_SOFTWARE_ALLOWED_DIR") or env.get("OCR_ALLOWED_DIR")
|
|
221
|
+
if urllib.parse.urlsplit(self.base_url).scheme not in ("http", "https"):
|
|
222
|
+
raise OcrError("base URL must start with http:// or https://", "bad_input")
|
|
223
|
+
|
|
224
|
+
def __repr__(self):
|
|
225
|
+
return "Client(base_url=%r)" % self.base_url # never the key
|
|
226
|
+
|
|
227
|
+
def _call(self, method, path, query=None, body=None, key_needed=True, headers=None):
|
|
228
|
+
"""One API call with the retry rules. Returns (bytes, headers). Raises OcrError carrying the API error code."""
|
|
229
|
+
url = self.base_url + path
|
|
230
|
+
query = {k: v for k, v in (query or {}).items() if v is not None}
|
|
231
|
+
if query:
|
|
232
|
+
url += "?" + urllib.parse.urlencode(query, safe=",@")
|
|
233
|
+
head = dict(headers or {})
|
|
234
|
+
if key_needed:
|
|
235
|
+
if not self._key:
|
|
236
|
+
raise OcrError("API key missing: set OCR_SOFTWARE_API_KEY (the key starts with ocr_)", "no_key")
|
|
237
|
+
if not KEY_CHARS.fullmatch(self._key): # fullmatch: "$" would let a trailing newline pass # a bad character would make http.client echo the key
|
|
238
|
+
raise OcrError("API key contains characters that are not allowed in a header", "no_key")
|
|
239
|
+
head["Authorization"] = "Bearer " + self._key
|
|
240
|
+
probed = False
|
|
241
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
242
|
+
req = urllib.request.Request(url, data=body, method=method, headers=head)
|
|
243
|
+
try:
|
|
244
|
+
with _OPENER.open(req, timeout=HTTP_TIMEOUT_S) as resp:
|
|
245
|
+
data = resp.read(MAX_RESPONSE_BYTES + 1)
|
|
246
|
+
if len(data) > MAX_RESPONSE_BYTES:
|
|
247
|
+
raise OcrError("response larger than %d bytes, refused" % MAX_RESPONSE_BYTES, "too_large")
|
|
248
|
+
return data, resp.headers
|
|
249
|
+
except urllib.error.HTTPError as e:
|
|
250
|
+
code, message = _error_of(e)
|
|
251
|
+
retry_after = _int(e.headers.get("Retry-After"))
|
|
252
|
+
if retry_after > RETRY_AFTER_LIMIT_S:
|
|
253
|
+
raise OcrError("%d %s: %s (Retry-After %d s: not retried, ask the human)" % (e.code, code, message, retry_after), code, e.code)
|
|
254
|
+
if e.code not in RETRY_STATUS or attempt == MAX_RETRIES:
|
|
255
|
+
raise OcrError("%d %s: %s" % (e.code, code, message), code, e.code)
|
|
256
|
+
delay = max(retry_after, BACKOFF_BASE_S * 2 ** attempt)
|
|
257
|
+
except urllib.error.URLError as e: # urllib raises URLError only if the request could not be sent
|
|
258
|
+
if isinstance(e.reason, _CUT_OFF) and body and len(body) >= PROBE_MIN_BODY and not probed:
|
|
259
|
+
probed = True
|
|
260
|
+
refusal = _refusal(url, head)
|
|
261
|
+
if refusal:
|
|
262
|
+
raise refusal
|
|
263
|
+
if isinstance(e.reason, _CUT_OFF): # connected, then cut: the service drops an upload beyond 2 per key instead of 429
|
|
264
|
+
if attempt == MAX_RETRIES:
|
|
265
|
+
raise OcrError("the service cut the connection %d times (more than 2 uploads at once with this key?): %s" % (attempt + 1, e.reason), "network")
|
|
266
|
+
delay = BACKOFF_BASE_S * 2 ** attempt
|
|
267
|
+
elif attempt >= CONNECT_RETRIES:
|
|
268
|
+
raise OcrError("cannot connect to %s: %s (check base_url, OCR_SOFTWARE_BASE_URL or --base-url)" % (self.base_url, e.reason), "network")
|
|
269
|
+
else:
|
|
270
|
+
delay = BACKOFF_BASE_S * 2 ** attempt
|
|
271
|
+
except (OSError, http.client.HTTPException) as e: # timeouts, timeouts (socket.timeout is no TimeoutError before 3.10), resets, cut bodies
|
|
272
|
+
if attempt == MAX_RETRIES:
|
|
273
|
+
raise OcrError("network error: %s" % e, "network")
|
|
274
|
+
delay = BACKOFF_BASE_S * 2 ** attempt
|
|
275
|
+
time.sleep(delay)
|
|
276
|
+
|
|
277
|
+
def _json(self, method, path, **kw):
|
|
278
|
+
data, _ = self._call(method, path, **kw)
|
|
279
|
+
try:
|
|
280
|
+
answer = json.loads(data)
|
|
281
|
+
except ValueError: # a proxy or captive portal answering 200 with a web page
|
|
282
|
+
answer = None
|
|
283
|
+
if not isinstance(answer, dict):
|
|
284
|
+
raise OcrError("%s did not answer like the OCR API (not JSON): check OCR_SOFTWARE_BASE_URL" % self.base_url, "bad_response")
|
|
285
|
+
return answer
|
|
286
|
+
|
|
287
|
+
def catalog(self):
|
|
288
|
+
"""What the service offers (no key needed): pipelines, formats, features."""
|
|
289
|
+
return self._json("GET", "/v1/catalog", key_needed=False)
|
|
290
|
+
|
|
291
|
+
def default_pipeline(self):
|
|
292
|
+
"""name@version used when the caller names none: the fastest and cheapest available one (advice.default_pipeline). Say
|
|
293
|
+
to the user which one it was."""
|
|
294
|
+
name = default_pipeline(self.catalog().get("pipelines", []))
|
|
295
|
+
if not name:
|
|
296
|
+
raise OcrError("no pipeline is available right now: try again later or see %s/v1/catalog" % self.base_url, "unavailable")
|
|
297
|
+
return name
|
|
298
|
+
|
|
299
|
+
def quote(self, path, pipeline, dpi=None):
|
|
300
|
+
"""Dry run: nothing runs, nothing is billed. Returns the page count, price_per_page_millicents, price_millicents (100,000
|
|
301
|
+
millicents = 1 EUR) and warnings. ocr_software.advice.price_sentence(q) turns it into one sentence in euros."""
|
|
302
|
+
body = read_document(path, self.allowed_dir)
|
|
303
|
+
return self._json("POST", "/v1/jobs", query={"pipeline": pipeline, "dry_run": 1, "dpi": dpi}, body=body)
|
|
304
|
+
|
|
305
|
+
def submit(self, path, pipeline, formats=None, features=None, dpi=None, client_ref=None, job_budget=None):
|
|
306
|
+
"""Submit one document. Returns the job state (job_id, state, pages ...); the job may still run."""
|
|
307
|
+
return self._submit(read_document(path, self.allowed_dir), pipeline, formats, features, dpi, client_ref, job_budget)
|
|
308
|
+
|
|
309
|
+
def _submit(self, data, pipeline, formats=None, features=None, dpi=None, client_ref=None, job_budget=None):
|
|
310
|
+
if not pipeline:
|
|
311
|
+
raise OcrError("pipeline is required (name@version, see catalog())", "bad_input")
|
|
312
|
+
query = {"pipeline": pipeline, "formats": formats, "features": features, "dpi": dpi, "client_ref": client_ref,
|
|
313
|
+
"job_budget": job_budget} # no wait here: POST /v1/jobs rejects it (400); status() long-polls
|
|
314
|
+
# The idempotency key makes the retries of the retry rules safe: no second job, no second charge.
|
|
315
|
+
return self._json("POST", "/v1/jobs", query=query, body=data, headers={"Idempotency-Key": str(uuid.uuid4())})
|
|
316
|
+
|
|
317
|
+
def status(self, job_id, wait=0):
|
|
318
|
+
"""State and counts of a job. wait (0 to 60 s): hold until the job changes."""
|
|
319
|
+
check_job_id(job_id)
|
|
320
|
+
return self._json("GET", "/v1/jobs/" + job_id, query={"wait": max(0, min(60, int(wait)))})
|
|
321
|
+
|
|
322
|
+
def wait(self, job_id, timeout=DEFAULT_WAIT_TIMEOUT_S):
|
|
323
|
+
"""Poll until state is done, return the last state. done does not mean every page worked: see unread_message()."""
|
|
324
|
+
deadline = time.monotonic() + timeout
|
|
325
|
+
while True:
|
|
326
|
+
job = self.status(job_id, wait=WAIT_SLICE_S)
|
|
327
|
+
if job.get("state") == "done":
|
|
328
|
+
return job
|
|
329
|
+
if time.monotonic() >= deadline:
|
|
330
|
+
raise OcrError("job %s still running after %d s" % (job_id, timeout), "timeout")
|
|
331
|
+
|
|
332
|
+
def result(self, job_id, format="json", source_path=None):
|
|
333
|
+
"""Result bytes of a finished job. format pdf (searchable PDF) needs source_path, the original document."""
|
|
334
|
+
return self._result(job_id, format, read_document(source_path, self.allowed_dir) if source_path else None)
|
|
335
|
+
|
|
336
|
+
def _result(self, job_id, format, source):
|
|
337
|
+
check_job_id(job_id)
|
|
338
|
+
if format not in FORMATS:
|
|
339
|
+
raise OcrError("format must be one of %s" % ", ".join(FORMATS), "bad_input")
|
|
340
|
+
if format == "pdf":
|
|
341
|
+
if source is None:
|
|
342
|
+
raise OcrError("format pdf needs the original document (source_path)", "bad_input")
|
|
343
|
+
data, _ = self._call("POST", "/v1/jobs/%s/result" % job_id, query={"format": "pdf"}, body=source)
|
|
344
|
+
else:
|
|
345
|
+
data, _ = self._call("GET", "/v1/jobs/%s/result" % job_id, query={"format": format})
|
|
346
|
+
return data
|
|
347
|
+
|
|
348
|
+
def ocr(self, path, pipeline, format="text", timeout=DEFAULT_WAIT_TIMEOUT_S, **options):
|
|
349
|
+
"""Submit, wait, fetch: the result bytes. options go to submit (dpi, client_ref, job_budget, formats, features).
|
|
350
|
+
Raises PagesUnread (with .data, the result anyway) if a page failed or was not run: never silent."""
|
|
351
|
+
if format not in FORMATS:
|
|
352
|
+
raise OcrError("format must be one of %s" % ", ".join(FORMATS), "bad_input")
|
|
353
|
+
source = read_document(path, self.allowed_dir) # read once: PDF output needs the same bytes again
|
|
354
|
+
job_id = self._submit(source, pipeline, **options).get("job_id")
|
|
355
|
+
if not job_id:
|
|
356
|
+
raise OcrError("the answer of %s has no job_id: check OCR_SOFTWARE_BASE_URL" % self.base_url, "bad_response")
|
|
357
|
+
done = self.wait(job_id, timeout)
|
|
358
|
+
data = self._result(job_id, format, source)
|
|
359
|
+
problem = unread_message(done)
|
|
360
|
+
if problem:
|
|
361
|
+
raise PagesUnread(problem, done, data)
|
|
362
|
+
return data
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ocr-software
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client and command line for the ocr-software.com OCR API: text, word boxes, layout, searchable PDF.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://ocr-software.com/
|
|
7
|
+
Project-URL: Repository, https://github.com/ocr-software/ocr-software-python
|
|
8
|
+
Keywords: ocr,pdf,searchable-pdf,hocr,markdown,document-ai
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/plain
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest==9.0.2; extra == "test"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
ocr-software: Python client and command line for the ocr-software.com OCR API (MIT, standard library only, Python 3.9 or newer).
|
|
19
|
+
|
|
20
|
+
Install:
|
|
21
|
+
pipx install ocr-software command line only, in its own environment
|
|
22
|
+
pip install ocr-software client library, inside a venv
|
|
23
|
+
uvx ocr-software --version run once without installing (uv)
|
|
24
|
+
First call (list the pipelines, then read one file):
|
|
25
|
+
ocr-software --list-pipelines
|
|
26
|
+
ocr-software scan.pdf --pipeline scan-ctc@2 -o scan.txt
|
|
27
|
+
|
|
28
|
+
Settings (environment; the command line flag wins):
|
|
29
|
+
OCR_SOFTWARE_API_KEY API key (starts with ocr_). OCR_KEY is read too, it is the name used in the API docs.
|
|
30
|
+
OCR_SOFTWARE_BASE_URL default https://api.ocr-software.com ; own server e.g. http://127.0.0.1:8080 (also --base-url). OCR_BASE_URL is read too.
|
|
31
|
+
OCR_SOFTWARE_ALLOWED_DIR optional: read documents only from below this folder (symlinks and .. do not escape)
|
|
32
|
+
The key is never printed and never part of an error message. Without --pipeline the default is used: the fastest and cheapest available pipeline (no previews). It is named on stderr; --list-pipelines marks it. In Python: client.ocr(path, client.default_pipeline()).
|
|
33
|
+
|
|
34
|
+
Command line:
|
|
35
|
+
ocr-software --list-pipelines available pipelines, one per line: name@version, tab, what it delivers and costs; the default one on stderr (no key needed)
|
|
36
|
+
ocr-software scan.pdf --pipeline NAME@VERSION --dry-run page count and price in euros, nothing runs, nothing is billed
|
|
37
|
+
ocr-software scan.pdf text to stdout, with the default pipeline (named on stderr)
|
|
38
|
+
ocr-software scan.pdf --pipeline NAME@VERSION text to stdout
|
|
39
|
+
ocr-software scan.pdf -o scan.md --pipeline NAME@VERSION --format md
|
|
40
|
+
ocr-software scan.pdf -o scan.pdf.txt --pipeline NAME@VERSION
|
|
41
|
+
ocr-software scan.pdf -o searchable.pdf --pipeline NAME@VERSION --format pdf searchable PDF, -o is required
|
|
42
|
+
ocr-software scan.pdf --pipeline NAME@VERSION --format json > scan.json
|
|
43
|
+
ocr-software scan.pdf --pipeline NAME@VERSION --base-url http://10.0.0.5:8080 your own server
|
|
44
|
+
python -m ocr_software ... the same command without the script on PATH
|
|
45
|
+
Options: --format text (default) | md | json | hocr | pdf ; --dpi 150|200|300 (PDF render resolution) ;
|
|
46
|
+
--timeout SECONDS (wait for the job, default 3600) ; --job-budget MILLICENTS (cap; ignored where billing is off) ; --dry-run ; --version
|
|
47
|
+
Without -o the result goes to stdout as UTF-8 (not for pdf). With -o the file appears whole or not at all, an existing
|
|
48
|
+
file is replaced, and the input file itself is refused as output.
|
|
49
|
+
|
|
50
|
+
Exit codes:
|
|
51
|
+
0 done, every page read
|
|
52
|
+
1 error from the service or the network (message on stderr with the API error code, for example 401 unauthorized);
|
|
53
|
+
also an address that does not answer: a typo and an outage look the same, so a script may retry this
|
|
54
|
+
2 wrong call: unreadable or unsupported input, output not writable, no key, malformed address (not http/https)
|
|
55
|
+
3 the job is done but a page failed or did not run, or the job stopped early: the result is written anyway,
|
|
56
|
+
stderr says what is missing and names the job; treat the file as incomplete
|
|
57
|
+
4 the job did not finish within --timeout; it keeps running, stderr names the job
|
|
58
|
+
|
|
59
|
+
Python:
|
|
60
|
+
from ocr_software import Client, PagesUnread
|
|
61
|
+
client = Client() # key and address from the environment
|
|
62
|
+
text = client.ocr("scan.pdf", "NAME@VERSION", "md") # bytes; raises PagesUnread (with .data) if a page was not read
|
|
63
|
+
pdf = client.ocr("scan.pdf", "NAME@VERSION", "pdf") # searchable PDF
|
|
64
|
+
job = client.submit("scan.pdf", "NAME@VERSION") # step by step: submit, wait, result
|
|
65
|
+
client.wait(job["job_id"]); client.result(job["job_id"], "json")
|
|
66
|
+
client.quote("scan.pdf", "NAME@VERSION") # dry run: page count and price, nothing runs
|
|
67
|
+
from ocr_software import price_sentence; price_sentence(q) # the same sentence as --dry-run, in euros
|
|
68
|
+
Price: the API counts money in millicents, 100,000 millicents = 1 EUR (a page of the cheapest pipeline costs 100 = EUR 0.001).
|
|
69
|
+
Today nothing is charged ("you pay EUR 0.00 today"); the list price is shown for information. Free use has limits:
|
|
70
|
+
https://ocr-software.com/legal/fair-usage/ ; for more volume write to info@ocr-software.com.
|
|
71
|
+
Word boxes (json): box = [x0, y0, x1, y1] in pixels of the page image, origin top left; width, height and dpi of the page say which
|
|
72
|
+
image. In PDF points: pixels * 72 / dpi. Per word, verified is true only where a second reader confirmed it (scan-vlm-ctc@2);
|
|
73
|
+
safe_ok is always false today. Judge by confidence (0 to 1). Every field: https://ocr-software.com/docs/reference/
|
|
74
|
+
Retries follow the API rules: 429, 500, 502, 503, 504 and network errors up to 5 times (waits 1, 2, 4, 8, 16 s or Retry-After). An
|
|
75
|
+
upload the service cuts off (it does that beyond 2 uploads at once per key, instead of answering 429) counts like 429. If no connection
|
|
76
|
+
can be made at all (wrong address, host down) it gives up after 2 retries and names the address: a few seconds, up to about 10 s on
|
|
77
|
+
Windows, where a refused port itself takes 2 s per try (connect timeout 10 s). A Retry-After over 60 s (locked_out) is not waited for. A submit carries an Idempotency-Key, so a retry never creates a second job.
|
|
78
|
+
Every document is checked locally first: PDF, PNG, JPEG or TIFF by its first bytes, at most 100 MiB.
|
|
79
|
+
|
|
80
|
+
Paperless-ngx, pre-consume script (sketch, not tried against a real Paperless; a non-PDF input keeps its file name but becomes a PDF):
|
|
81
|
+
#!/bin/sh
|
|
82
|
+
ocr-software "$DOCUMENT_WORKING_PATH" --pipeline NAME@VERSION --format pdf -o "$DOCUMENT_WORKING_PATH.ocr" && mv "$DOCUMENT_WORKING_PATH.ocr" "$DOCUMENT_WORKING_PATH"
|
|
83
|
+
with OCR_SOFTWARE_API_KEY and OCR_SOFTWARE_BASE_URL set in the environment of the Paperless container.
|
|
84
|
+
|
|
85
|
+
Tests (fake local service, no network, no key):
|
|
86
|
+
python -m venv .venv
|
|
87
|
+
.venv/bin/pip install pytest (Windows: .venv\Scripts\pip install pytest)
|
|
88
|
+
.venv/bin/python -m pytest -p no:cacheprovider . (Windows: .venv\Scripts\python -m pytest -p no:cacheprovider .)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
ocr_software/__init__.py,sha256=JTRYcLC_n99LNPDG0mzIe7egHau8-IIcBChc8joRfAs,354
|
|
2
|
+
ocr_software/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
ocr_software/advice.py,sha256=R4JCoJMCLtpRbo1I6Ga3Rim4taGFEQqBu-uJEeuegVc,4481
|
|
4
|
+
ocr_software/cli.py,sha256=5TWkJp63Uzb-6XsC1m8YXpWdLt3rjhI9kfJKui8rGks,6121
|
|
5
|
+
ocr_software/client.py,sha256=Zr_mAQ_5afCtD-Eb60DHSzXPXzCoBj1r05l4rjYzpGU,18788
|
|
6
|
+
ocr_software-0.1.0.dist-info/licenses/LICENSE,sha256=E_lY7BQrEo1ekTWu4ZrM1nXk9oe7Lcoye3TqknyW0DI,1073
|
|
7
|
+
ocr_software-0.1.0.dist-info/METADATA,sha256=kBkvqg0PfM0_d7tplBf-cJrg1csZsDs5qDZYElntEl8,6785
|
|
8
|
+
ocr_software-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
ocr_software-0.1.0.dist-info/entry_points.txt,sha256=CtQxrFjWhf9uMiIWmGCORvgBLVLvuod4qQT7i38iJFo,55
|
|
10
|
+
ocr_software-0.1.0.dist-info/top_level.txt,sha256=I9VcBBNNYq0d22lG1zSeBdPZp-ilw7bbm3ZmoWaHxI8,13
|
|
11
|
+
ocr_software-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ocr-software.com
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ocr_software
|