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.
@@ -0,0 +1,199 @@
1
+ """
2
+ Input and runtime environment validation.
3
+
4
+ Centralizes pre-checks to fail early with clear messages when prerequisites
5
+ are missing. Each validator raises a specific ValidationError subclass.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ from scanlayer import config
14
+ from scanlayer.utils.logger import get_logger
15
+
16
+ log = get_logger(__name__)
17
+
18
+
19
+ class ValidationError(Exception):
20
+ """Base for user-facing validation errors with actionable messages."""
21
+
22
+
23
+ class InputFileError(ValidationError):
24
+ """The input file does not exist, is not readable, or has an unsupported format."""
25
+
26
+
27
+ class OutputPathError(ValidationError):
28
+ """The output path is invalid (parent directory missing,
29
+ insufficient permissions, etc.)."""
30
+
31
+
32
+ class TesseractEnvironmentError(ValidationError):
33
+ """Tesseract is missing, not found on PATH, or its tessdata is missing."""
34
+
35
+
36
+ class DependencyError(ValidationError):
37
+ """A required Python dependency is not installed or broken."""
38
+
39
+
40
+ SUPPORTED_IMAGE_EXTENSIONS = {
41
+ ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp",
42
+ ".gif", ".webp", ".jp2", ".j2k",
43
+ }
44
+
45
+ _COMMON_LANG_FILES = {"eng.traineddata", "fra.traineddata", "osd.traineddata"}
46
+
47
+
48
+ def validate_input_file(path: str) -> str:
49
+ """Check that the input file exists, is readable, and has a recognized extension.
50
+
51
+ Returns the resolved absolute path. Raises InputFileError on failure.
52
+ """
53
+ if not path or not isinstance(path, str):
54
+ raise InputFileError("No input path provided.")
55
+
56
+ p = Path(path).expanduser()
57
+ if not p.exists():
58
+ raise InputFileError(
59
+ f"Input file does not exist: {path}\n"
60
+ f"Check the path and make sure you are in the correct directory."
61
+ )
62
+ if not p.is_file():
63
+ raise InputFileError(
64
+ f"The specified path is not a file: {path}"
65
+ )
66
+ if os.access(p, os.R_OK) is False:
67
+ raise InputFileError(
68
+ f"Input file is not readable (permissions): {path}"
69
+ )
70
+
71
+ ext = p.suffix.lower()
72
+ if ext not in SUPPORTED_IMAGE_EXTENSIONS:
73
+ log.warning(
74
+ f"Extension '{ext}' not in the supported list "
75
+ f"({', '.join(sorted(SUPPORTED_IMAGE_EXTENSIONS))}). "
76
+ f"Attempting anyway, Pillow will reject if the format is unknown."
77
+ )
78
+
79
+ if p.stat().st_size == 0:
80
+ raise InputFileError(f"Input file is empty: {path}")
81
+
82
+ return str(p.resolve())
83
+
84
+
85
+ def validate_output_path(path: str, expected_ext: str = ".pdf") -> str:
86
+ """Check that the output path is valid (parent exists, is writable).
87
+
88
+ Returns the resolved absolute path. A mismatched extension only logs
89
+ a warning since the file is still written in the requested format.
90
+ Raises OutputPathError on failure.
91
+ """
92
+ if not path or not isinstance(path, str):
93
+ raise OutputPathError("No output path provided.")
94
+
95
+ p = Path(path).expanduser()
96
+ if p.exists() and p.is_dir():
97
+ raise OutputPathError(
98
+ f"The output path points to a directory, not a file: {path}"
99
+ )
100
+
101
+ parent = p.parent
102
+ if not parent.exists():
103
+ raise OutputPathError(
104
+ f"Parent directory of the output file does not exist: {parent}\n"
105
+ f"Create it first, or fix the output path."
106
+ )
107
+ if os.access(parent, os.W_OK) is False:
108
+ raise OutputPathError(
109
+ f"Output directory is not writable: {parent}"
110
+ )
111
+
112
+ ext = p.suffix.lower()
113
+ if ext != expected_ext.lower():
114
+ log.warning(
115
+ f"Output path does not have the {expected_ext} extension "
116
+ f"('{ext}'). The file will still be written in the "
117
+ f"requested format, but the extension is misleading."
118
+ )
119
+
120
+ return str(p.resolve())
121
+
122
+
123
+ def validate_tesseract_environment() -> None:
124
+ """Check that Tesseract is installed and tessdata is available.
125
+
126
+ Raises TesseractEnvironmentError with an actionable message if not.
127
+ """
128
+ cmd = config.TESSERACT_CMD
129
+
130
+ if cmd not in ("tesseract",):
131
+ if not os.path.exists(cmd):
132
+ raise TesseractEnvironmentError(
133
+ f"Tesseract not found at the configured location: {cmd}\n"
134
+ f"Check config.DEV_TESSERACT_OVERRIDE, the "
135
+ f"TESSERACT_CMD environment variable, or scanlayer.configure().\n"
136
+ f"Install Tesseract via your package manager "
137
+ f"(apt install tesseract-ocr / brew install tesseract), "
138
+ f"or the Tesseract installer on Windows."
139
+ )
140
+ if not os.access(cmd, os.X_OK):
141
+ raise TesseractEnvironmentError(
142
+ f"Tesseract is not executable: {cmd} (permissions)"
143
+ )
144
+ else:
145
+ import shutil
146
+ resolved = shutil.which("tesseract")
147
+ if resolved is None:
148
+ raise TesseractEnvironmentError(
149
+ "Tesseract not found on the system PATH.\n"
150
+ "Install Tesseract via your package manager "
151
+ "(apt install tesseract-ocr / brew install tesseract / "
152
+ "or download the tesseract-ocr installer on Windows).\n"
153
+ "Or override the path via the TESSERACT_CMD=/path/to/tesseract "
154
+ "environment variable."
155
+ )
156
+
157
+ if config.TESSDATA_DIR:
158
+ td = Path(config.TESSDATA_DIR)
159
+ if not td.exists():
160
+ raise TesseractEnvironmentError(
161
+ f"Configured tessdata directory does not exist: {td}"
162
+ )
163
+ present = {f.name for f in td.glob("*.traineddata")}
164
+ missing_common = _COMMON_LANG_FILES - present
165
+ if missing_common:
166
+ log.warning(
167
+ f"Missing tessdata for: {', '.join(sorted(missing_common))}. "
168
+ f"OCR in those languages will fail."
169
+ )
170
+
171
+
172
+ def validate_image_readable(path: str) -> None:
173
+ """Open the image with Pillow to verify it is not corrupted.
174
+
175
+ Raises InputFileError if Pillow cannot identify it.
176
+ """
177
+ try:
178
+ from PIL import Image
179
+ with Image.open(path) as img:
180
+ img.verify() # does not load into memory, only checks the header
181
+ except Exception as exc:
182
+ raise InputFileError(
183
+ f"Unreadable or corrupted image: {path}\n"
184
+ f"Pillow could not identify it: {type(exc).__name__}: {exc}"
185
+ ) from exc
186
+
187
+
188
+ def validate_all(
189
+ input_path: str, output_path: str, output_format: str = "pdf"
190
+ ) -> tuple[str, str]:
191
+ """Run all validations in order of fastest first.
192
+
193
+ Returns (input_abs, output_abs) if everything is OK.
194
+ """
195
+ input_abs = validate_input_file(input_path)
196
+ validate_tesseract_environment()
197
+ output_abs = validate_output_path(output_path, expected_ext=f".{output_format}")
198
+ validate_image_readable(input_abs)
199
+ return input_abs, output_abs
@@ -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,30 @@
1
+ scanlayer/__init__.py,sha256=RVVGA5ggyXr5_dHpTZAlCk9tugT7ioqmUxW4we1Brr8,996
2
+ scanlayer/__main__.py,sha256=7dtsL73buULWsVpixlyOrhw_ldPrxAkTzeh3FLDE5II,207
3
+ scanlayer/config.py,sha256=HSndjcHm_teBEYgl9la0eFWuKwNrtUXWroDFh6QhlOw,7849
4
+ scanlayer/main.py,sha256=07XiZ9ouXop6BQVj9CZCtH01YlzED35_N50SVsZ9jvQ,23158
5
+ scanlayer/cli/__init__.py,sha256=fC3bR5kkWENgx31--7Y7tb3A8BGbXyg5D-z41aR9krY,205
6
+ scanlayer/cli/dry_run.py,sha256=ueXZSrOLm2qV0G1n0k-zwG9qLggaAPhnjH685fPPNqQ,6860
7
+ scanlayer/cli/parser.py,sha256=mUzE-qyliy9jGlEuUQ9HV8Pk-mWZWOQuRrvM2XEK07c,8570
8
+ scanlayer/cli/run.py,sha256=BC8iZQ9aM8HPkXLU9VbHuC4-gFADrH6gTPPYi5EX2D4,7624
9
+ scanlayer/fonts/DejaVuSans.ttf,sha256=rnt4VeEVpZZtixs_gPJUzMEX7Ib5ll4gLuKUBFODcoA,759720
10
+ scanlayer/layout/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ scanlayer/layout/columns.py,sha256=NuJHZ3AsbH7w2uXWriguS9VBNX94cAq9vXmOyd6mIuk,8596
12
+ scanlayer/ocr/__init__.py,sha256=GI3op-X4or5sZ48XJc_17br0-V3BuryJ4VNkXjVG8Hk,181
13
+ scanlayer/ocr/engine.py,sha256=FRkjsTK8cI0I8DWhWLViP8bTcQ-ZqdMvISkjx3_bHjc,11045
14
+ scanlayer/ocr/export.py,sha256=u09trX5LYlf_x9Ly8lJXwYbpnd7M6hWNTaRePziNZbA,8941
15
+ scanlayer/pdf/__init__.py,sha256=GCKBhmuxyriuas392LNtMVcdCM0_41wF3Nfi8dtXdjs,133
16
+ scanlayer/pdf/builder.py,sha256=As7AHHonKgh8Mq6sbEzRXmRMNWfIyPR96B7EWdKJl08,10258
17
+ scanlayer/pdf/fonts.py,sha256=Wh-_CypWvg4om3mif_cD1ml712fd8g6_547f5WLNKsc,3253
18
+ scanlayer/preprocessing/__init__.py,sha256=byrSD5vjFvwEg-92nMJxA9CWVmPxpNfyJ0medhzR-QA,188
19
+ scanlayer/preprocessing/enhance.py,sha256=877QZhnA2dnK93b3U0oCGsb6oHXAN27X1wB8FvTPFak,12640
20
+ scanlayer/utils/__init__.py,sha256=V1q0cs2APJ7keK4Y4uwfFfBVqwJmqHJE7LzNThhKCMA,332
21
+ scanlayer/utils/debug_image.py,sha256=z0dOuYwRmJedC7jvfbEJbGKA8NiHGpVsAO3WlhsyFV4,2564
22
+ scanlayer/utils/errors.py,sha256=jr2OUiIevFSUJlWO90etav-GUOnP9O-3bXkHjPPSKnw,1827
23
+ scanlayer/utils/logger.py,sha256=QIulvS3RjJ1fFkaNdlyaRM4wzVuZOg87hGnPsTmV4dA,3080
24
+ scanlayer/utils/validators.py,sha256=oNHEgwrcY2X0MkUKYUWxtic4UZL6zypUv3hdjlTuBqY,6801
25
+ scanlayer-1.0.0.dist-info/licenses/LICENSE.md,sha256=GnqaSHPX43GuJ4OQkCrW6zCf5eschtYCwHej5siK6w8,1079
26
+ scanlayer-1.0.0.dist-info/METADATA,sha256=UWrnIa62RpJpnpGHlFsCOAFKT28vDlaGQVq9nd1-lec,538
27
+ scanlayer-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
28
+ scanlayer-1.0.0.dist-info/entry_points.txt,sha256=1BhRpUFaKnTFgLWrRVAr8MAgpqmwH-8sz5kbmCkwf-s,49
29
+ scanlayer-1.0.0.dist-info/top_level.txt,sha256=52X241TR07ERaeDJP76tmilaQcqfpDCpndN0nQJlGSs,10
30
+ scanlayer-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scanlayer = scanlayer.cli:main
@@ -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 @@
1
+ scanlayer