scanlayer 1.0.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.
- scanlayer/__init__.py +32 -0
- scanlayer/__main__.py +10 -0
- scanlayer/cli/__init__.py +10 -0
- scanlayer/cli/dry_run.py +191 -0
- scanlayer/cli/parser.py +199 -0
- scanlayer/cli/run.py +217 -0
- scanlayer/config.py +267 -0
- scanlayer/fonts/DejaVuSans.ttf +0 -0
- scanlayer/layout/__init__.py +0 -0
- scanlayer/layout/columns.py +264 -0
- scanlayer/main.py +666 -0
- scanlayer/ocr/__init__.py +5 -0
- scanlayer/ocr/engine.py +335 -0
- scanlayer/ocr/export.py +264 -0
- scanlayer/pdf/__init__.py +5 -0
- scanlayer/pdf/builder.py +309 -0
- scanlayer/pdf/fonts.py +110 -0
- scanlayer/preprocessing/__init__.py +5 -0
- scanlayer/preprocessing/enhance.py +367 -0
- scanlayer/utils/__init__.py +19 -0
- scanlayer/utils/debug_image.py +85 -0
- scanlayer/utils/errors.py +42 -0
- scanlayer/utils/logger.py +100 -0
- scanlayer/utils/validators.py +199 -0
- scanlayer-1.0.0.dist-info/METADATA +17 -0
- scanlayer-1.0.0.dist-info/RECORD +30 -0
- scanlayer-1.0.0.dist-info/WHEEL +5 -0
- scanlayer-1.0.0.dist-info/entry_points.txt +2 -0
- scanlayer-1.0.0.dist-info/licenses/LICENSE.md +21 -0
- scanlayer-1.0.0.dist-info/top_level.txt +1 -0
scanlayer/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
scanlayer: turn a scanned or photographed document into a searchable PDF,
|
|
3
|
+
or export the raw OCR result as text, JSON, TSV, or hOCR.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
|
|
7
|
+
import scanlayer
|
|
8
|
+
|
|
9
|
+
result = scanlayer.convert("invoice.jpg", "invoice.pdf")
|
|
10
|
+
print(result.words_count, result.mean_confidence)
|
|
11
|
+
|
|
12
|
+
For batch processing, use convert_batch() which handles mixed extensions
|
|
13
|
+
and never raises for a single bad file.
|
|
14
|
+
|
|
15
|
+
Tesseract is located automatically. Use configure() to override:
|
|
16
|
+
|
|
17
|
+
scanlayer.configure(tesseract_cmd="/path/to/tesseract", lang="eng")
|
|
18
|
+
|
|
19
|
+
See scanlayer.config.configure for the full list of options.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from scanlayer.config import configure, configure_from_file, get_settings, load_config_file
|
|
23
|
+
from scanlayer.main import BatchResult, ConversionResult, convert, convert_batch, convert_merge
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"configure", "configure_from_file", "load_config_file", "get_settings",
|
|
27
|
+
"convert", "ConversionResult",
|
|
28
|
+
"convert_batch", "BatchResult",
|
|
29
|
+
"convert_merge",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
__version__ = "1.0.0"
|
scanlayer/__main__.py
ADDED
scanlayer/cli/dry_run.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exit codes and --dry-run validation logic for the scanlayer CLI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import os
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from scanlayer.main import _default_output_path
|
|
12
|
+
from scanlayer.utils.errors import BlankPageDetectedError, PipelineError
|
|
13
|
+
from scanlayer.utils.logger import log_error
|
|
14
|
+
from scanlayer.utils.validators import (
|
|
15
|
+
DependencyError,
|
|
16
|
+
InputFileError,
|
|
17
|
+
OutputPathError,
|
|
18
|
+
TesseractEnvironmentError,
|
|
19
|
+
ValidationError,
|
|
20
|
+
validate_image_readable,
|
|
21
|
+
validate_input_file,
|
|
22
|
+
validate_output_path,
|
|
23
|
+
validate_tesseract_environment,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
EXIT_OK = 0
|
|
27
|
+
EXIT_USER_ERROR = 1
|
|
28
|
+
EXIT_ENV_ERROR = 2
|
|
29
|
+
EXIT_UNEXPECTED_ERROR = 3
|
|
30
|
+
EXIT_PROCESSING_ERROR = 4
|
|
31
|
+
EXIT_PARTIAL_BATCH = 5
|
|
32
|
+
|
|
33
|
+
def _exit_code_for(exc: Exception) -> int:
|
|
34
|
+
if isinstance(exc, (InputFileError, OutputPathError)):
|
|
35
|
+
return EXIT_USER_ERROR
|
|
36
|
+
if isinstance(exc, (TesseractEnvironmentError, DependencyError)):
|
|
37
|
+
return EXIT_ENV_ERROR
|
|
38
|
+
if isinstance(exc, ValidationError):
|
|
39
|
+
return EXIT_USER_ERROR
|
|
40
|
+
if isinstance(exc, BlankPageDetectedError):
|
|
41
|
+
return EXIT_USER_ERROR # not a bug/env issue, user needs to pass --force
|
|
42
|
+
if isinstance(exc, PipelineError):
|
|
43
|
+
return EXIT_PROCESSING_ERROR
|
|
44
|
+
return EXIT_UNEXPECTED_ERROR
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _dry_run_resolve_output_path(
|
|
48
|
+
output_arg: str, input_path: str, is_batch: bool, output_format: str = "pdf"
|
|
49
|
+
) -> "tuple[str, Optional[str]]":
|
|
50
|
+
"""Like _resolve_output_path(), but never touches the filesystem.
|
|
51
|
+
|
|
52
|
+
_resolve_output_path() calls os.makedirs() as a side effect for
|
|
53
|
+
folder-style -o, which --dry-run must not do. Returns
|
|
54
|
+
(resolved_output_path, folder_or_None) – folder is set when this
|
|
55
|
+
is the auto-created-folder case, so the caller can validate it
|
|
56
|
+
without creating it.
|
|
57
|
+
"""
|
|
58
|
+
if output_arg is None:
|
|
59
|
+
return _default_output_path(input_path, output_format), None
|
|
60
|
+
is_folder = is_batch or output_arg.endswith(("/", "\\")) or os.path.isdir(output_arg)
|
|
61
|
+
if is_folder:
|
|
62
|
+
stem = os.path.splitext(os.path.basename(input_path))[0]
|
|
63
|
+
return os.path.join(output_arg, f"{stem}.{output_format}"), output_arg
|
|
64
|
+
return output_arg, None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _nearest_existing_dir(path: str) -> str:
|
|
68
|
+
"""Walk up from `path` to the nearest ancestor that already
|
|
69
|
+
exists. Used to check whether os.makedirs(path) would succeed
|
|
70
|
+
without actually calling it.
|
|
71
|
+
"""
|
|
72
|
+
path = os.path.abspath(path)
|
|
73
|
+
while not os.path.isdir(path):
|
|
74
|
+
parent = os.path.dirname(path)
|
|
75
|
+
if parent == path:
|
|
76
|
+
return path
|
|
77
|
+
path = parent
|
|
78
|
+
return path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _check_output_folder_creatable(folder: str) -> None:
|
|
82
|
+
"""Dry-run equivalent of the os.makedirs(exist_ok=True) a real
|
|
83
|
+
batch run does for folder-style -o: confirms the folder (or its
|
|
84
|
+
nearest existing ancestor, standing in for what makedirs would
|
|
85
|
+
need to write into) is writable, without creating anything.
|
|
86
|
+
"""
|
|
87
|
+
if os.path.exists(folder):
|
|
88
|
+
if not os.path.isdir(folder):
|
|
89
|
+
raise OutputPathError(
|
|
90
|
+
f"The output path points to a file, not a directory: {folder}"
|
|
91
|
+
)
|
|
92
|
+
target = folder
|
|
93
|
+
else:
|
|
94
|
+
target = _nearest_existing_dir(folder)
|
|
95
|
+
if os.access(target, os.W_OK) is False:
|
|
96
|
+
raise OutputPathError(f"Output directory is not writable: {target}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _dry_run_validate_one(
|
|
100
|
+
input_path: str, output_path: str, folder: "Optional[str]", output_format: str
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Same checks as validate_all(), fastest first, but uses the
|
|
103
|
+
dry-run folder check instead of validate_output_path() when the
|
|
104
|
+
output path is an auto-created batch folder that may not exist
|
|
105
|
+
yet (validate_output_path() would otherwise reject it for a
|
|
106
|
+
reason a real run would just fix by creating the folder).
|
|
107
|
+
"""
|
|
108
|
+
input_abs = validate_input_file(input_path)
|
|
109
|
+
validate_tesseract_environment()
|
|
110
|
+
if folder is not None:
|
|
111
|
+
_check_output_folder_creatable(folder)
|
|
112
|
+
else:
|
|
113
|
+
validate_output_path(output_path, expected_ext=f".{output_format}")
|
|
114
|
+
validate_image_readable(input_abs)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _run_dry_run(
|
|
118
|
+
expanded_inputs: list[str], args: argparse.Namespace, log
|
|
119
|
+
) -> int:
|
|
120
|
+
"""Validate a batch (files exist/readable, Tesseract reachable,
|
|
121
|
+
output paths writable) without running OCR or writing anything.
|
|
122
|
+
|
|
123
|
+
Non-merge: mirrors the exit-code behavior of the real conversion
|
|
124
|
+
loop in main() – a single input's failure returns that failure's
|
|
125
|
+
own exit code immediately, multi-input runs collect every
|
|
126
|
+
failure and return EXIT_PARTIAL_BATCH if any file failed.
|
|
127
|
+
|
|
128
|
+
--merge: mirrors convert_merge(), which combines all pages into
|
|
129
|
+
ONE output and aborts on the first bad page rather than
|
|
130
|
+
collecting per-file failures, so dry-run does the same (fail
|
|
131
|
+
fast on the first bad page, no EXIT_PARTIAL_BATCH here).
|
|
132
|
+
"""
|
|
133
|
+
is_batch = len(expanded_inputs) > 1
|
|
134
|
+
|
|
135
|
+
if args.merge:
|
|
136
|
+
# main() already rejects args.merge with args.output is None
|
|
137
|
+
# before _run_dry_run is reached.
|
|
138
|
+
try:
|
|
139
|
+
validate_output_path(args.output, expected_ext=".pdf")
|
|
140
|
+
except ValidationError as exc:
|
|
141
|
+
log_error(log, f"'--output': {exc}")
|
|
142
|
+
return _exit_code_for(exc)
|
|
143
|
+
try:
|
|
144
|
+
validate_tesseract_environment()
|
|
145
|
+
except ValidationError as exc:
|
|
146
|
+
log_error(log, f"'--merge': {exc}")
|
|
147
|
+
return _exit_code_for(exc)
|
|
148
|
+
|
|
149
|
+
for input_path in expanded_inputs:
|
|
150
|
+
try:
|
|
151
|
+
input_abs = validate_input_file(input_path)
|
|
152
|
+
validate_image_readable(input_abs)
|
|
153
|
+
except ValidationError as exc:
|
|
154
|
+
log_error(log, f"'{input_path}': {exc}")
|
|
155
|
+
return _exit_code_for(exc)
|
|
156
|
+
|
|
157
|
+
log.info(
|
|
158
|
+
f"dry-run: {len(expanded_inputs)} file(s) OK for --merge "
|
|
159
|
+
f"into {args.output}, no output written."
|
|
160
|
+
)
|
|
161
|
+
return EXIT_OK
|
|
162
|
+
|
|
163
|
+
failures: list[tuple[str, str]] = []
|
|
164
|
+
last_exit_code = EXIT_OK
|
|
165
|
+
|
|
166
|
+
for input_path in expanded_inputs:
|
|
167
|
+
output_path, folder = _dry_run_resolve_output_path(
|
|
168
|
+
args.output, input_path, is_batch, args.format
|
|
169
|
+
)
|
|
170
|
+
try:
|
|
171
|
+
_dry_run_validate_one(input_path, output_path, folder, args.format)
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
code = _exit_code_for(exc)
|
|
174
|
+
log_error(log, f"'{input_path}': {exc}")
|
|
175
|
+
failures.append((input_path, str(exc)))
|
|
176
|
+
last_exit_code = code
|
|
177
|
+
if not is_batch:
|
|
178
|
+
return last_exit_code
|
|
179
|
+
|
|
180
|
+
if failures:
|
|
181
|
+
if is_batch:
|
|
182
|
+
log_error(
|
|
183
|
+
log,
|
|
184
|
+
f"dry-run: {len(failures)}/{len(expanded_inputs)} "
|
|
185
|
+
f"file(s) failed validation.",
|
|
186
|
+
)
|
|
187
|
+
return EXIT_PARTIAL_BATCH
|
|
188
|
+
return last_exit_code
|
|
189
|
+
|
|
190
|
+
log.info(f"dry-run: {len(expanded_inputs)} file(s) OK, no output written.")
|
|
191
|
+
return EXIT_OK
|
scanlayer/cli/parser.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI argument parser for scanlayer.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
|
|
9
|
+
from scanlayer import config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _ScanlayerHelpFormatter(argparse.RawDescriptionHelpFormatter):
|
|
13
|
+
"""Wider help column and wrapping so long option descriptions stay
|
|
14
|
+
readable instead of collapsing into a single dense block."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, prog):
|
|
17
|
+
super().__init__(prog, max_help_position=28, width=100)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="scanlayer",
|
|
23
|
+
description="Convert a scanned image into a searchable PDF, or export the raw OCR result.",
|
|
24
|
+
formatter_class=_ScanlayerHelpFormatter,
|
|
25
|
+
epilog=(
|
|
26
|
+
"Examples:\n"
|
|
27
|
+
" scanlayer invoice.jpg -o invoice.pdf\n"
|
|
28
|
+
" scanlayer invoice.jpg -o invoice.pdf --lang fra --dpi 300\n"
|
|
29
|
+
" scanlayer invoice.jpg -o invoice.pdf --verbose\n"
|
|
30
|
+
" scanlayer invoice.jpg -o invoice.pdf --jpeg-quality 90\n"
|
|
31
|
+
" scanlayer *.jpg -o ./converted/ (batch: -o is a folder)\n"
|
|
32
|
+
" scanlayer invoice.jpg -o invoice.pdf --orientation none\n"
|
|
33
|
+
" scanlayer invoice.jpg -o invoice.pdf --orientation 10\n"
|
|
34
|
+
" scanlayer invoice.jpg -o invoice.json --format json\n"
|
|
35
|
+
" scanlayer invoice.jpg -o invoice.pdf --debug-image\n"
|
|
36
|
+
" scanlayer invoice.jpg --debug-image (no -o: writes "
|
|
37
|
+
"invoice.pdf + invoice_debug.png next to the input)\n"
|
|
38
|
+
" scanlayer invoice.jpg -o invoice.pdf --font ./MyFont.ttf\n"
|
|
39
|
+
" scanlayer *.jpg -o ./converted/ --dry-run (validate, no OCR)\n"
|
|
40
|
+
"\n"
|
|
41
|
+
"Exit codes:\n"
|
|
42
|
+
" 0 = success\n"
|
|
43
|
+
" 1 = user error (file not found, invalid format, blank page "
|
|
44
|
+
"without --force...)\n"
|
|
45
|
+
" 2 = environment error (Tesseract missing...)\n"
|
|
46
|
+
" 3 = unexpected error (bug)\n"
|
|
47
|
+
" 4 = processing error (OCR/PDF stage failed for a real reason)\n"
|
|
48
|
+
" 5 = partial batch failure (multiple inputs, some failed)\n"
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"input", nargs="+",
|
|
53
|
+
help="Path(s) to the source image(s) (jpg, png, tiff...). "
|
|
54
|
+
"Give several and point -o at a folder to batch-convert.",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
io_group = parser.add_argument_group("input / output")
|
|
58
|
+
io_group.add_argument(
|
|
59
|
+
"-o", "--output", required=False, default=None,
|
|
60
|
+
help="Output PDF path (single input) or folder (multiple "
|
|
61
|
+
"inputs). If omitted, each output is written next to its "
|
|
62
|
+
"own input, same directory/stem, extension from --format. "
|
|
63
|
+
"Required when --merge is used.",
|
|
64
|
+
)
|
|
65
|
+
io_group.add_argument(
|
|
66
|
+
"--format", choices=["pdf", "txt", "json", "tsv", "hocr"], default="pdf",
|
|
67
|
+
help="Output format. 'pdf' (default) builds a searchable PDF. "
|
|
68
|
+
"'txt', 'json', 'tsv', 'hocr' export the raw OCR result "
|
|
69
|
+
"instead, no PDF is built. See the README for each "
|
|
70
|
+
"format's schema.",
|
|
71
|
+
)
|
|
72
|
+
io_group.add_argument(
|
|
73
|
+
"--merge", action="store_true",
|
|
74
|
+
help="Combine all inputs into ONE multi-page PDF instead of "
|
|
75
|
+
"one output per input. Works with multiple image "
|
|
76
|
+
"arguments (page order = argument order) or a single "
|
|
77
|
+
"multi-page PDF input. Only with --format pdf. -o must "
|
|
78
|
+
"be a file path, not a folder.",
|
|
79
|
+
)
|
|
80
|
+
io_group.add_argument(
|
|
81
|
+
"--config", default=None,
|
|
82
|
+
help="Path to a .yaml/.yml/.json config profile applied via "
|
|
83
|
+
"configure() before any other option (CLI flags still "
|
|
84
|
+
"override it). Keys match configure()'s kwargs, e.g. "
|
|
85
|
+
'{"tesseract_cmd": "/usr/bin/tesseract", "lang": "eng"}. '
|
|
86
|
+
"See scanlayer.config.configure for the full key list.",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
ocr_group = parser.add_argument_group("OCR options")
|
|
90
|
+
ocr_group.add_argument(
|
|
91
|
+
"--lang", default=None,
|
|
92
|
+
help="Tesseract language(s), e.g. fra, eng, fra+eng "
|
|
93
|
+
"(default: fra+eng)",
|
|
94
|
+
)
|
|
95
|
+
ocr_group.add_argument(
|
|
96
|
+
"--dpi", type=int, default=None,
|
|
97
|
+
help="DPI to use if not detectable in the image",
|
|
98
|
+
)
|
|
99
|
+
ocr_group.add_argument(
|
|
100
|
+
"--psm", type=int, default=None, metavar="N",
|
|
101
|
+
help="Force a single page segmentation mode instead of trying "
|
|
102
|
+
"TESSERACT_PSM_CANDIDATES (default [3, 4, 6, 11]) and "
|
|
103
|
+
"keeping the highest mean confidence. Runs exactly one "
|
|
104
|
+
"OCR pass, no auto-selection. Common values: 3 (fully "
|
|
105
|
+
"automatic), 4 (single column), 6 (single uniform block, "
|
|
106
|
+
"e.g. receipts), 7 (single line), 11 (sparse/scattered "
|
|
107
|
+
"text, e.g. order forms). 0 and 2 are rejected, they "
|
|
108
|
+
"produce no OCR text.",
|
|
109
|
+
)
|
|
110
|
+
ocr_group.add_argument(
|
|
111
|
+
"--orientation", default=None,
|
|
112
|
+
help="Orientation correction mode. Omit for automatic "
|
|
113
|
+
"detection (EXIF + OSD + deskew, the default). 'none' "
|
|
114
|
+
"disables correction and uses the image as loaded. Or "
|
|
115
|
+
"give a precise clockwise angle in degrees (e.g. '10' "
|
|
116
|
+
"or '-3.5') to skip auto-detection.",
|
|
117
|
+
)
|
|
118
|
+
ocr_group.add_argument(
|
|
119
|
+
"--no-column-detection", action="store_true",
|
|
120
|
+
help="Disable multi-column reading-order detection for this "
|
|
121
|
+
"run, e.g. if it misfires on a specific document. "
|
|
122
|
+
"Equivalent to configure(multi_column_detection=False).",
|
|
123
|
+
)
|
|
124
|
+
ocr_group.add_argument(
|
|
125
|
+
"--min-confidence", type=int, default=None,
|
|
126
|
+
help="Confidence threshold (0-100) below which a word is "
|
|
127
|
+
f"dropped (default: {config.MIN_WORD_CONFIDENCE})",
|
|
128
|
+
)
|
|
129
|
+
ocr_group.add_argument(
|
|
130
|
+
"--whitelist", default=None,
|
|
131
|
+
help="Allowed characters for OCR, e.g. "
|
|
132
|
+
"'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'",
|
|
133
|
+
)
|
|
134
|
+
ocr_group.add_argument(
|
|
135
|
+
"--blacklist", default=None,
|
|
136
|
+
help="Forbidden characters for OCR, e.g. '|`~'",
|
|
137
|
+
)
|
|
138
|
+
ocr_group.add_argument(
|
|
139
|
+
"--font", default=None,
|
|
140
|
+
help="TTF font to embed in the invisible text layer, "
|
|
141
|
+
"overriding the automatic pick (CJK CID font / bundled "
|
|
142
|
+
"DejaVu Sans / Helvetica fallback based on --lang). "
|
|
143
|
+
"Useful for scripts the auto-pick misses (Arabic, "
|
|
144
|
+
"Hebrew, Thai, Devanagari...).",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
pdf_group = parser.add_argument_group("PDF output")
|
|
148
|
+
pdf_group.add_argument(
|
|
149
|
+
"--jpeg-quality", type=int, default=None,
|
|
150
|
+
help=f"JPEG quality for the background (default: {config.PDF_JPEG_QUALITY})",
|
|
151
|
+
)
|
|
152
|
+
pdf_group.add_argument(
|
|
153
|
+
"--title", default=None,
|
|
154
|
+
help="Title of the PDF document (metadata)",
|
|
155
|
+
)
|
|
156
|
+
pdf_group.add_argument(
|
|
157
|
+
"--author", default=None,
|
|
158
|
+
help="Author of the PDF document (metadata)",
|
|
159
|
+
)
|
|
160
|
+
pdf_group.add_argument(
|
|
161
|
+
"--subject", default=None,
|
|
162
|
+
help="Subject of the PDF document (metadata)",
|
|
163
|
+
)
|
|
164
|
+
pdf_group.add_argument(
|
|
165
|
+
"--force", action="store_true",
|
|
166
|
+
help="Generate the PDF even if the source image looks "
|
|
167
|
+
"blank/near-uniform (background image only, no text "
|
|
168
|
+
"layer, no OCR run). Without this, a likely-blank page "
|
|
169
|
+
"raises an error instead of silently producing an "
|
|
170
|
+
"empty PDF.",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
diag_group = parser.add_argument_group("diagnostics")
|
|
174
|
+
diag_group.add_argument(
|
|
175
|
+
"--debug-image", action="store_true",
|
|
176
|
+
help="Also save a '<output-stem>_debug.png' next to the "
|
|
177
|
+
"output, showing detected word boxes color-coded by "
|
|
178
|
+
"confidence, per-word confidence scores, and run "
|
|
179
|
+
"metadata. Works with any --format.",
|
|
180
|
+
)
|
|
181
|
+
diag_group.add_argument(
|
|
182
|
+
"--dry-run", action="store_true",
|
|
183
|
+
help="Validate the batch without running OCR or writing any "
|
|
184
|
+
"output: checks inputs exist and are readable, Tesseract "
|
|
185
|
+
"is reachable, and output paths are writable. Exits with "
|
|
186
|
+
"the same codes as a normal run, so scripts can fail "
|
|
187
|
+
"fast before paying for OCR. Combine with --merge to "
|
|
188
|
+
"validate a merge run instead.",
|
|
189
|
+
)
|
|
190
|
+
diag_group.add_argument(
|
|
191
|
+
"-v", "--verbose", action="store_true",
|
|
192
|
+
help="Enable verbose logging (DEBUG level)",
|
|
193
|
+
)
|
|
194
|
+
diag_group.add_argument(
|
|
195
|
+
"-q", "--quiet", action="store_true",
|
|
196
|
+
help="Suppress all logs except errors (ERROR level)",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return parser
|
scanlayer/cli/run.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI run orchestration for scanlayer: argument handling, config wiring,
|
|
3
|
+
and the main conversion loop. Maps exceptions to exit codes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import glob
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
from scanlayer import config
|
|
14
|
+
from scanlayer.cli.dry_run import (
|
|
15
|
+
EXIT_OK,
|
|
16
|
+
EXIT_PARTIAL_BATCH,
|
|
17
|
+
EXIT_UNEXPECTED_ERROR,
|
|
18
|
+
EXIT_USER_ERROR,
|
|
19
|
+
_exit_code_for,
|
|
20
|
+
_run_dry_run,
|
|
21
|
+
)
|
|
22
|
+
from scanlayer.cli.parser import _build_parser
|
|
23
|
+
from scanlayer.main import _expand_pdf_input, _resolve_output_path, convert, convert_merge
|
|
24
|
+
from scanlayer.utils.logger import get_logger, log_error
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv: list[str] = None) -> int:
|
|
28
|
+
"""CLI entry point. Returns a standardized exit code."""
|
|
29
|
+
parser = _build_parser()
|
|
30
|
+
args = parser.parse_args(argv)
|
|
31
|
+
|
|
32
|
+
if args.verbose:
|
|
33
|
+
os.environ["LOG_LEVEL"] = "DEBUG"
|
|
34
|
+
elif args.quiet:
|
|
35
|
+
os.environ["LOG_LEVEL"] = "ERROR"
|
|
36
|
+
|
|
37
|
+
log = get_logger("main")
|
|
38
|
+
|
|
39
|
+
if args.config:
|
|
40
|
+
try:
|
|
41
|
+
file_settings = config.configure_from_file(args.config)
|
|
42
|
+
log.info(f"Applied config file: {args.config} ({sorted(file_settings)})")
|
|
43
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
44
|
+
log_error(log, f"--config: {exc}")
|
|
45
|
+
return EXIT_USER_ERROR
|
|
46
|
+
|
|
47
|
+
if args.no_column_detection:
|
|
48
|
+
config.configure(multi_column_detection=False)
|
|
49
|
+
|
|
50
|
+
if args.psm is not None:
|
|
51
|
+
if args.psm in (0, 2):
|
|
52
|
+
log_error(
|
|
53
|
+
log,
|
|
54
|
+
f"--psm {args.psm}: PSM 0 and 2 only run orientation/script "
|
|
55
|
+
"detection, they don't produce OCR text, so no words would "
|
|
56
|
+
"be extracted. Pick a PSM that actually reads text, e.g. "
|
|
57
|
+
"3, 4, 6, 7, or 11 (see --help for --psm).",
|
|
58
|
+
)
|
|
59
|
+
return EXIT_USER_ERROR
|
|
60
|
+
if not 0 <= args.psm <= 13:
|
|
61
|
+
log_error(
|
|
62
|
+
log,
|
|
63
|
+
f"--psm must be a Tesseract page segmentation mode, 0-13. "
|
|
64
|
+
f"Got {args.psm}.",
|
|
65
|
+
)
|
|
66
|
+
return EXIT_USER_ERROR
|
|
67
|
+
config.configure(psm_candidates=[args.psm])
|
|
68
|
+
|
|
69
|
+
if args.font:
|
|
70
|
+
config.configure(font_path=args.font)
|
|
71
|
+
|
|
72
|
+
if args.min_confidence is not None:
|
|
73
|
+
if not 0 <= args.min_confidence <= 100:
|
|
74
|
+
log_error(log, "--min-confidence must be between 0 and 100.")
|
|
75
|
+
return EXIT_USER_ERROR
|
|
76
|
+
config.MIN_WORD_CONFIDENCE = args.min_confidence
|
|
77
|
+
|
|
78
|
+
orientation_value: "str | float | None" = None
|
|
79
|
+
if args.orientation is not None:
|
|
80
|
+
if args.orientation.strip().lower() == "none":
|
|
81
|
+
orientation_value = "none"
|
|
82
|
+
else:
|
|
83
|
+
try:
|
|
84
|
+
orientation_value = float(args.orientation)
|
|
85
|
+
except ValueError:
|
|
86
|
+
log_error(
|
|
87
|
+
log,
|
|
88
|
+
f"--orientation must be 'none' or a numeric angle in "
|
|
89
|
+
f"degrees, got: '{args.orientation}'."
|
|
90
|
+
)
|
|
91
|
+
return EXIT_USER_ERROR
|
|
92
|
+
|
|
93
|
+
pdf_metadata = {}
|
|
94
|
+
if args.title:
|
|
95
|
+
pdf_metadata["title"] = args.title
|
|
96
|
+
if args.author:
|
|
97
|
+
pdf_metadata["author"] = args.author
|
|
98
|
+
if args.subject:
|
|
99
|
+
pdf_metadata["subject"] = args.subject
|
|
100
|
+
|
|
101
|
+
if args.merge and args.format != "pdf":
|
|
102
|
+
log_error(log, "--merge only supports --format pdf.")
|
|
103
|
+
return EXIT_USER_ERROR
|
|
104
|
+
if args.merge and args.output is None:
|
|
105
|
+
log_error(
|
|
106
|
+
log,
|
|
107
|
+
"--merge requires -o/--output (a single explicit file "
|
|
108
|
+
"path): there's no one input to derive a shared output "
|
|
109
|
+
"name from.",
|
|
110
|
+
)
|
|
111
|
+
return EXIT_USER_ERROR
|
|
112
|
+
if args.merge and (args.output.endswith(("/", "\\")) or os.path.isdir(args.output)):
|
|
113
|
+
log_error(log, "--merge requires -o/--output to be a file path, not a folder.")
|
|
114
|
+
return EXIT_USER_ERROR
|
|
115
|
+
|
|
116
|
+
raw_inputs: list[str] = []
|
|
117
|
+
for pat in args.input:
|
|
118
|
+
expanded = glob.glob(pat)
|
|
119
|
+
if expanded:
|
|
120
|
+
raw_inputs.extend(expanded)
|
|
121
|
+
else:
|
|
122
|
+
raw_inputs.append(pat)
|
|
123
|
+
|
|
124
|
+
with tempfile.TemporaryDirectory(prefix="scanlayer_pdf_") as tmp_dir:
|
|
125
|
+
try:
|
|
126
|
+
expanded_inputs: list[str] = []
|
|
127
|
+
for input_path in raw_inputs:
|
|
128
|
+
expanded_inputs.extend(_expand_pdf_input(input_path, args.dpi, tmp_dir))
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
code = _exit_code_for(exc)
|
|
131
|
+
log_error(log, f"PDF input expansion failed: {exc}")
|
|
132
|
+
return code
|
|
133
|
+
|
|
134
|
+
if args.dry_run:
|
|
135
|
+
return _run_dry_run(expanded_inputs, args, log)
|
|
136
|
+
|
|
137
|
+
if args.merge:
|
|
138
|
+
try:
|
|
139
|
+
convert_merge(
|
|
140
|
+
input_paths=expanded_inputs,
|
|
141
|
+
output_path=args.output,
|
|
142
|
+
lang=args.lang,
|
|
143
|
+
dpi=args.dpi,
|
|
144
|
+
jpeg_quality=args.jpeg_quality,
|
|
145
|
+
char_whitelist=args.whitelist,
|
|
146
|
+
char_blacklist=args.blacklist,
|
|
147
|
+
pdf_metadata=pdf_metadata,
|
|
148
|
+
force=args.force,
|
|
149
|
+
orientation=orientation_value,
|
|
150
|
+
)
|
|
151
|
+
return EXIT_OK
|
|
152
|
+
except Exception as exc:
|
|
153
|
+
code = _exit_code_for(exc)
|
|
154
|
+
if code == EXIT_UNEXPECTED_ERROR:
|
|
155
|
+
import traceback
|
|
156
|
+
log_error(log, f"--merge: unexpected error: {type(exc).__name__}: {exc}")
|
|
157
|
+
log.debug(traceback.format_exc())
|
|
158
|
+
else:
|
|
159
|
+
log_error(log, f"--merge: {exc}")
|
|
160
|
+
return code
|
|
161
|
+
|
|
162
|
+
is_batch = len(expanded_inputs) > 1
|
|
163
|
+
failures: list[tuple[str, str]] = []
|
|
164
|
+
last_exit_code = EXIT_OK
|
|
165
|
+
|
|
166
|
+
for input_path in expanded_inputs:
|
|
167
|
+
try:
|
|
168
|
+
output_path = _resolve_output_path(
|
|
169
|
+
args.output, input_path, is_batch, args.format
|
|
170
|
+
)
|
|
171
|
+
except OSError as exc:
|
|
172
|
+
log_error(log, f"'{input_path}': cannot prepare output path: {exc}")
|
|
173
|
+
failures.append((input_path, str(exc)))
|
|
174
|
+
last_exit_code = EXIT_USER_ERROR
|
|
175
|
+
if not is_batch:
|
|
176
|
+
return last_exit_code
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
convert(
|
|
181
|
+
input_path=input_path,
|
|
182
|
+
output_path=output_path,
|
|
183
|
+
lang=args.lang,
|
|
184
|
+
dpi=args.dpi,
|
|
185
|
+
jpeg_quality=args.jpeg_quality,
|
|
186
|
+
char_whitelist=args.whitelist,
|
|
187
|
+
char_blacklist=args.blacklist,
|
|
188
|
+
pdf_metadata=pdf_metadata,
|
|
189
|
+
force=args.force,
|
|
190
|
+
orientation=orientation_value,
|
|
191
|
+
output_format=args.format,
|
|
192
|
+
debug_image=args.debug_image,
|
|
193
|
+
)
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
code = _exit_code_for(exc)
|
|
196
|
+
if code == EXIT_UNEXPECTED_ERROR:
|
|
197
|
+
import traceback
|
|
198
|
+
log_error(log, f"'{input_path}': unexpected error: {type(exc).__name__}: {exc}")
|
|
199
|
+
log.debug(traceback.format_exc())
|
|
200
|
+
else:
|
|
201
|
+
log_error(log, f"'{input_path}': {exc}")
|
|
202
|
+
failures.append((input_path, str(exc)))
|
|
203
|
+
last_exit_code = code
|
|
204
|
+
if not is_batch:
|
|
205
|
+
return last_exit_code
|
|
206
|
+
|
|
207
|
+
if failures:
|
|
208
|
+
if is_batch:
|
|
209
|
+
log_error(log, f"{len(failures)}/{len(expanded_inputs)} file(s) failed.")
|
|
210
|
+
return EXIT_PARTIAL_BATCH
|
|
211
|
+
return last_exit_code
|
|
212
|
+
|
|
213
|
+
return EXIT_OK
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
sys.exit(main())
|