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.
- scanlayer-1.0.0/LICENSE.md +21 -0
- scanlayer-1.0.0/PKG-INFO +17 -0
- scanlayer-1.0.0/README.md +208 -0
- scanlayer-1.0.0/pyproject.toml +38 -0
- scanlayer-1.0.0/scanlayer/__init__.py +32 -0
- scanlayer-1.0.0/scanlayer/__main__.py +10 -0
- scanlayer-1.0.0/scanlayer/cli/__init__.py +10 -0
- scanlayer-1.0.0/scanlayer/cli/dry_run.py +191 -0
- scanlayer-1.0.0/scanlayer/cli/parser.py +199 -0
- scanlayer-1.0.0/scanlayer/cli/run.py +217 -0
- scanlayer-1.0.0/scanlayer/config.py +267 -0
- scanlayer-1.0.0/scanlayer/fonts/DejaVuSans.ttf +0 -0
- scanlayer-1.0.0/scanlayer/layout/__init__.py +0 -0
- scanlayer-1.0.0/scanlayer/layout/columns.py +264 -0
- scanlayer-1.0.0/scanlayer/main.py +666 -0
- scanlayer-1.0.0/scanlayer/ocr/__init__.py +5 -0
- scanlayer-1.0.0/scanlayer/ocr/engine.py +335 -0
- scanlayer-1.0.0/scanlayer/ocr/export.py +264 -0
- scanlayer-1.0.0/scanlayer/pdf/__init__.py +5 -0
- scanlayer-1.0.0/scanlayer/pdf/builder.py +309 -0
- scanlayer-1.0.0/scanlayer/pdf/fonts.py +110 -0
- scanlayer-1.0.0/scanlayer/preprocessing/__init__.py +5 -0
- scanlayer-1.0.0/scanlayer/preprocessing/enhance.py +367 -0
- scanlayer-1.0.0/scanlayer/utils/__init__.py +19 -0
- scanlayer-1.0.0/scanlayer/utils/debug_image.py +85 -0
- scanlayer-1.0.0/scanlayer/utils/errors.py +42 -0
- scanlayer-1.0.0/scanlayer/utils/logger.py +100 -0
- scanlayer-1.0.0/scanlayer/utils/validators.py +199 -0
- scanlayer-1.0.0/scanlayer.egg-info/PKG-INFO +17 -0
- scanlayer-1.0.0/scanlayer.egg-info/SOURCES.txt +41 -0
- scanlayer-1.0.0/scanlayer.egg-info/dependency_links.txt +1 -0
- scanlayer-1.0.0/scanlayer.egg-info/entry_points.txt +2 -0
- scanlayer-1.0.0/scanlayer.egg-info/requires.txt +12 -0
- scanlayer-1.0.0/scanlayer.egg-info/top_level.txt +1 -0
- scanlayer-1.0.0/setup.cfg +4 -0
- scanlayer-1.0.0/tests/test_columns.py +129 -0
- scanlayer-1.0.0/tests/test_config.py +60 -0
- scanlayer-1.0.0/tests/test_dry_run.py +136 -0
- scanlayer-1.0.0/tests/test_export.py +112 -0
- scanlayer-1.0.0/tests/test_fonts.py +58 -0
- scanlayer-1.0.0/tests/test_main_integration.py +82 -0
- scanlayer-1.0.0/tests/test_pdf_builder.py +61 -0
- 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.
|
scanlayer-1.0.0/PKG-INFO
ADDED
|
@@ -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 → 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
|
+
[](#)
|
|
12
|
+

|
|
13
|
+
[](https://Hyacinthe-primus.github.io/scanlayer/)
|
|
14
|
+
[](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,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
|