scanlayer 1.0.0__tar.gz

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.
Files changed (43) hide show
  1. scanlayer-1.0.0/LICENSE.md +21 -0
  2. scanlayer-1.0.0/PKG-INFO +17 -0
  3. scanlayer-1.0.0/README.md +208 -0
  4. scanlayer-1.0.0/pyproject.toml +38 -0
  5. scanlayer-1.0.0/scanlayer/__init__.py +32 -0
  6. scanlayer-1.0.0/scanlayer/__main__.py +10 -0
  7. scanlayer-1.0.0/scanlayer/cli/__init__.py +10 -0
  8. scanlayer-1.0.0/scanlayer/cli/dry_run.py +191 -0
  9. scanlayer-1.0.0/scanlayer/cli/parser.py +199 -0
  10. scanlayer-1.0.0/scanlayer/cli/run.py +217 -0
  11. scanlayer-1.0.0/scanlayer/config.py +267 -0
  12. scanlayer-1.0.0/scanlayer/fonts/DejaVuSans.ttf +0 -0
  13. scanlayer-1.0.0/scanlayer/layout/__init__.py +0 -0
  14. scanlayer-1.0.0/scanlayer/layout/columns.py +264 -0
  15. scanlayer-1.0.0/scanlayer/main.py +666 -0
  16. scanlayer-1.0.0/scanlayer/ocr/__init__.py +5 -0
  17. scanlayer-1.0.0/scanlayer/ocr/engine.py +335 -0
  18. scanlayer-1.0.0/scanlayer/ocr/export.py +264 -0
  19. scanlayer-1.0.0/scanlayer/pdf/__init__.py +5 -0
  20. scanlayer-1.0.0/scanlayer/pdf/builder.py +309 -0
  21. scanlayer-1.0.0/scanlayer/pdf/fonts.py +110 -0
  22. scanlayer-1.0.0/scanlayer/preprocessing/__init__.py +5 -0
  23. scanlayer-1.0.0/scanlayer/preprocessing/enhance.py +367 -0
  24. scanlayer-1.0.0/scanlayer/utils/__init__.py +19 -0
  25. scanlayer-1.0.0/scanlayer/utils/debug_image.py +85 -0
  26. scanlayer-1.0.0/scanlayer/utils/errors.py +42 -0
  27. scanlayer-1.0.0/scanlayer/utils/logger.py +100 -0
  28. scanlayer-1.0.0/scanlayer/utils/validators.py +199 -0
  29. scanlayer-1.0.0/scanlayer.egg-info/PKG-INFO +17 -0
  30. scanlayer-1.0.0/scanlayer.egg-info/SOURCES.txt +41 -0
  31. scanlayer-1.0.0/scanlayer.egg-info/dependency_links.txt +1 -0
  32. scanlayer-1.0.0/scanlayer.egg-info/entry_points.txt +2 -0
  33. scanlayer-1.0.0/scanlayer.egg-info/requires.txt +12 -0
  34. scanlayer-1.0.0/scanlayer.egg-info/top_level.txt +1 -0
  35. scanlayer-1.0.0/setup.cfg +4 -0
  36. scanlayer-1.0.0/tests/test_columns.py +129 -0
  37. scanlayer-1.0.0/tests/test_config.py +60 -0
  38. scanlayer-1.0.0/tests/test_dry_run.py +136 -0
  39. scanlayer-1.0.0/tests/test_export.py +112 -0
  40. scanlayer-1.0.0/tests/test_fonts.py +58 -0
  41. scanlayer-1.0.0/tests/test_main_integration.py +82 -0
  42. scanlayer-1.0.0/tests/test_pdf_builder.py +61 -0
  43. scanlayer-1.0.0/tests/test_validators.py +324 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 scanlayer contributors
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,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: scanlayer
3
+ Version: 1.0.0
4
+ Summary: Image (scan/photo) to searchable PDF, or raw OCR export, via Tesseract.
5
+ Requires-Python: >=3.9
6
+ License-File: LICENSE.md
7
+ Requires-Dist: pytesseract>=0.3.10
8
+ Requires-Dist: Pillow>=10.0.0
9
+ Requires-Dist: reportlab>=4.0.0
10
+ Requires-Dist: opencv-python-headless>=4.8.0
11
+ Requires-Dist: numpy>=1.24.0
12
+ Requires-Dist: pdf2image>=1.17.0
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=7.0; extra == "test"
15
+ Provides-Extra: yaml
16
+ Requires-Dist: PyYAML>=6.0; extra == "yaml"
17
+ Dynamic: license-file
@@ -0,0 +1,208 @@
1
+ <div align="center">
2
+ <img src="scanlayer.png" alt="scanlayer" width="110"/>
3
+ <h1>scanlayer</h1>
4
+ <p><em>Scanned image or photographed document &rarr; searchable PDF, or raw OCR text/JSON/TSV/hOCR.</em></p>
5
+ </div>
6
+
7
+ **Turn a scanned image or photographed document into a searchable PDF**, or
8
+ export the raw OCR result as plain text, JSON, TSV, or hOCR. Use it as a
9
+ command-line tool or as a Python library; both are the same engine underneath.
10
+
11
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue)](#)
12
+ ![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)
13
+ [![](https://img.shields.io/badge/-documentation-green)](https://Hyacinthe-primus.github.io/scanlayer/)
14
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md)
15
+
16
+ ---
17
+
18
+ ## What it does
19
+
20
+ A scanned invoice, a phone photo of a letter, a stack of photographed pages:
21
+ scanlayer runs it through [Tesseract OCR](https://github.com/tesseract-ocr/tesseract)
22
+ and gives you back either:
23
+
24
+ - a **searchable PDF**: the original page image, with an invisible, precisely
25
+ positioned text layer over it, so you can select and search text exactly
26
+ where it visually appears, or
27
+ - the **raw OCR result** as `txt`, `json`, `tsv`, or `hocr`: text, per-word
28
+ confidence, and bounding boxes, no PDF built at all.
29
+
30
+ Along the way it automatically straightens rotated/skewed pages, corrects
31
+ uneven lighting, denoises and sharpens for OCR accuracy without touching what
32
+ you actually see in the output, reconstructs correct reading order on
33
+ genuine multi-column pages, and races several OCR configurations against
34
+ each other to pick the most confident result.
35
+
36
+ | | |
37
+ |---|---|
38
+ | **Two interfaces, one engine** | `scanlayer` CLI and `import scanlayer` call the exact same pipeline |
39
+ | **Five output formats** | Searchable `pdf`, or raw `txt` / `json` / `tsv` / `hocr` |
40
+ | **Real column detection** | Two-column articles/letters read in correct order, not interleaved |
41
+ | **Batch, merge, native PDF input** | Convert a folder in one call, merge pages into one PDF, or `--dry-run` a batch before spending time on OCR |
42
+ | **One configuration surface** | `configure()`, a JSON/YAML profile, or CLI flags, documented precedence |
43
+ | **Debug overlay** | `--debug-image` draws every word, color-coded by confidence |
44
+
45
+ See the [feature catalog](https://Hyacinthe-primus.github.io/scanlayer/features.html) for the complete list, and
46
+ [Roadmap & Limitations](https://Hyacinthe-primus.github.io/scanlayer/roadmap.html) for what's deliberately out of
47
+ scope or not built yet.
48
+
49
+ ## Requirements
50
+
51
+ - Python 3.9+
52
+ - [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) (a separate, system-level install, see below)
53
+ - [poppler](https://poppler.freedesktop.org/) *only if* you feed scanlayer a native `.pdf` file directly
54
+
55
+ ```bash
56
+ # Debian / Ubuntu
57
+ sudo apt install tesseract-ocr poppler-utils
58
+
59
+ # macOS (Homebrew)
60
+ brew install tesseract poppler
61
+
62
+ # Windows: Tesseract -> https://github.com/tesseract-ocr/tesseract/wiki
63
+ # poppler -> download a release, add its bin/ to PATH
64
+ ```
65
+
66
+ Full detail, including how scanlayer locates the Tesseract binary
67
+ automatically and how to bundle your own, is in
68
+ [Installation](https://Hyacinthe-primus.github.io/scanlayer/installation.html) and [Bundling Tesseract](https://Hyacinthe-primus.github.io/scanlayer/bundling-tesseract.html).
69
+
70
+ ## Install
71
+
72
+ ```bash
73
+ pip install scanlayer
74
+ ```
75
+
76
+ This installs the `scanlayer` console command and makes `import scanlayer`
77
+ available anywhere on the machine.
78
+
79
+ Working on scanlayer itself, or want to run it straight from a checkout with
80
+ no install at all?
81
+
82
+ ```bash
83
+ git clone https://github.com/Hyacinthe-primus/scanlayer.git
84
+ cd scanlayer # the repo root, which contains requirements.txt
85
+ pip install -r requirements.txt
86
+ python -m scanlayer invoice.jpg -o invoice.pdf # works with no install at all
87
+ ```
88
+
89
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contributor setup
90
+ (editable install and running the test suite).
91
+
92
+ ## Quick start
93
+
94
+ **As a CLI:**
95
+
96
+ ```bash
97
+ scanlayer invoice.jpg -o invoice.pdf --lang fra+eng --dpi 300
98
+ ```
99
+
100
+ **As a library:**
101
+
102
+ ```python
103
+ import scanlayer
104
+
105
+ result = scanlayer.convert("invoice.jpg", "invoice.pdf", lang="fra+eng", dpi=300)
106
+ print(f"{result.words_count} words, {result.mean_confidence:.1f}% confidence")
107
+ ```
108
+
109
+ Every CLI flag and library keyword argument in this project are named to
110
+ match each other (`--lang` ↔ `lang=`, `--dpi` ↔ `dpi=`, and so on), see
111
+ [Examples](https://Hyacinthe-primus.github.io/scanlayer/examples.html) for every
112
+ feature shown both ways, side by side, and
113
+ [CLI Reference](https://Hyacinthe-primus.github.io/scanlayer/cli-reference.html) /
114
+ [Library API](https://Hyacinthe-primus.github.io/scanlayer/library-api.html) for
115
+ the complete details of each.
116
+
117
+ A few more common cases:
118
+
119
+ ```bash
120
+ # Batch-convert a folder
121
+ scanlayer *.jpg -o ./converted/
122
+
123
+ # Merge several photographed pages into one searchable PDF
124
+ scanlayer page1.jpg page2.jpg page3.jpg -o report.pdf --merge
125
+
126
+ # Export raw OCR text/JSON instead of a PDF
127
+ scanlayer invoice.jpg -o invoice.json --format json
128
+
129
+ # See what OCR actually detected, color-coded by confidence
130
+ scanlayer invoice.jpg --debug-image
131
+
132
+ # Validate a batch before spending time on OCR: files exist,
133
+ # Tesseract reachable, output paths writable
134
+ scanlayer *.jpg -o ./converted/ --dry-run
135
+ ```
136
+
137
+ ```python
138
+ import scanlayer
139
+
140
+ # Batch
141
+ result = scanlayer.convert_batch(["*.jpg"], "./converted/")
142
+
143
+ # Merge
144
+ scanlayer.convert_merge(["page1.jpg", "page2.jpg", "page3.jpg"], "report.pdf")
145
+
146
+ # Raw export
147
+ scanlayer.convert("invoice.jpg", "invoice.json", output_format="json")
148
+ ```
149
+
150
+ ## Documentation
151
+
152
+ | | |
153
+ |---|---|
154
+ | **[Installation](https://Hyacinthe-primus.github.io/scanlayer/installation.html)** | Tesseract, poppler, and the two ways to install scanlayer itself |
155
+ | **[Examples](https://Hyacinthe-primus.github.io/scanlayer/examples.html)** | Every feature, CLI and library side by side |
156
+ | **[CLI Reference](https://Hyacinthe-primus.github.io/scanlayer/cli-reference.html)** | Every flag and exit code |
157
+ | **[Library API](https://Hyacinthe-primus.github.io/scanlayer/library-api.html)** | `convert()`, `convert_batch()`, `convert_merge()`, exceptions, and the full low-level pipeline API |
158
+ | **[Feature Catalog](https://Hyacinthe-primus.github.io/scanlayer/features.html)** | Everything scanlayer does, by pipeline stage |
159
+ | **[Configuration](https://Hyacinthe-primus.github.io/scanlayer/configuration.html)** | `configure()`, config files, precedence, every tunable |
160
+ | **[Output Formats](https://Hyacinthe-primus.github.io/scanlayer/output-formats.html)** | The `pdf`/`txt`/`json`/`tsv`/`hocr` schemas |
161
+ | **[Multi-Page & Merge](https://Hyacinthe-primus.github.io/scanlayer/multi-page-merge.html)** | Batching, merging, native PDF input |
162
+ | **[Debug Visualization](https://Hyacinthe-primus.github.io/scanlayer/debug-visualization.html)** | Reading the `--debug-image` confidence overlay |
163
+ | **[Bundling Tesseract](https://Hyacinthe-primus.github.io/scanlayer/bundling-tesseract.html)** | Shipping your own Tesseract binary |
164
+ | **[Troubleshooting](https://Hyacinthe-primus.github.io/scanlayer/troubleshooting.html)** | Common errors and fixes |
165
+ | **[Roadmap & Limitations](https://Hyacinthe-primus.github.io/scanlayer/roadmap.html)** | What's missing and what's deliberately out of scope |
166
+
167
+ The site is published from the `gh-pages` branch at
168
+ https://Hyacinthe-primus.github.io/scanlayer/. To browse it locally, open
169
+ `index.html` in a `gh-pages` worktree (e.g. `git worktree add <path> gh-pages`);
170
+ it has no build step or server-side dependency.
171
+
172
+ ## Repository layout
173
+
174
+ ```
175
+ .
176
+ ├── .github/ # FUNDING.yml
177
+ ├── scanlayer/ # library source
178
+ │ ├── __init__.py
179
+ │ ├── __main__.py # enables `python -m scanlayer`
180
+ │ ├── main.py # public API: convert()/convert_batch()/convert_merge()
181
+ │ ├── config.py
182
+ │ ├── cli/ # argparse CLI: parser.py, run.py, dry_run.py
183
+ │ ├── fonts/ # bundled DejaVu Sans for the PDF text layer
184
+ │ ├── preprocessing/
185
+ │ ├── ocr/
186
+ │ ├── layout/
187
+ │ ├── pdf/
188
+ │ └── utils/
189
+ ├── tests/ # pytest suite
190
+ ├── pyproject.toml
191
+ ├── requirements.txt
192
+ ├── README.md
193
+ ├── CONTRIBUTING.md
194
+ ├── SECURITY.md
195
+ └── LICENSE.md
196
+ ```
197
+
198
+ ## Contributing
199
+
200
+ Bug reports, fixes, and feature discussions are welcome: see
201
+ [CONTRIBUTING.md](CONTRIBUTING.md) for how to set up a development
202
+ environment and what to include in a pull request. There is a `tests/`
203
+ directory (now including `utils/validators.py` and the `--dry-run` flag, with
204
+ cross-platform coverage for Tesseract discovery on Windows/macOS/Linux) and it runs with `pytest`, but no CI yet; see [Roadmap & Limitations](https://Hyacinthe-primus.github.io/scanlayer/roadmap.html) and the [Adding tests](CONTRIBUTING.md#adding-tests) section of the contributing guide for where coverage is thinnest.
205
+
206
+ ## License
207
+
208
+ [MIT](LICENSE.md).
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scanlayer"
7
+ version = "1.0.0"
8
+ description = "Image (scan/photo) to searchable PDF, or raw OCR export, via Tesseract."
9
+ requires-python = ">=3.9"
10
+ dependencies = [
11
+ "pytesseract>=0.3.10",
12
+ "Pillow>=10.0.0",
13
+ "reportlab>=4.0.0",
14
+ "opencv-python-headless>=4.8.0",
15
+ "numpy>=1.24.0",
16
+ "pdf2image>=1.17.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ test = ["pytest>=7.0"]
21
+ yaml = ["PyYAML>=6.0"]
22
+
23
+ [project.scripts]
24
+ scanlayer = "scanlayer.cli:main"
25
+
26
+ [tool.setuptools.packages.find]
27
+ include = ["scanlayer*"]
28
+
29
+ [tool.setuptools.package-data]
30
+ scanlayer = ["fonts/*.ttf"]
31
+
32
+ [tool.ruff]
33
+ target-version = "py39"
34
+ line-length = 100
35
+
36
+ [tool.ruff.lint]
37
+ select = ["E", "F", "W", "I", "B"]
38
+ ignore = ["B008", "B904"]
@@ -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"
@@ -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