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 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
@@ -0,0 +1,10 @@
1
+ """
2
+ Entry point for `python -m scanlayer`.
3
+
4
+ Avoids double-importing main.py since __init__.py already imports it.
5
+ """
6
+
7
+ from scanlayer.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ raise SystemExit(main())
@@ -0,0 +1,10 @@
1
+ """
2
+ CLI entry point for scanlayer.
3
+
4
+ Exit codes: 0=success, 1=user error, 2=env error, 3=unexpected,
5
+ 4=processing error, 5=partial batch failure.
6
+ """
7
+
8
+ from scanlayer.cli.run import main
9
+
10
+ __all__ = ["main"]
@@ -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
@@ -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())