pdfslice-py 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.
- pdfslice_py-1.0.0/LICENSE.md +52 -0
- pdfslice_py-1.0.0/PKG-INFO +69 -0
- pdfslice_py-1.0.0/README.md +230 -0
- pdfslice_py-1.0.0/pyproject.toml +56 -0
- pdfslice_py-1.0.0/setup.cfg +4 -0
- pdfslice_py-1.0.0/src/pdfslice_py/__init__.py +6 -0
- pdfslice_py-1.0.0/src/pdfslice_py/cli.py +130 -0
- pdfslice_py-1.0.0/src/pdfslice_py/discover.py +67 -0
- pdfslice_py-1.0.0/src/pdfslice_py/filename_template.py +55 -0
- pdfslice_py-1.0.0/src/pdfslice_py/gather.py +254 -0
- pdfslice_py-1.0.0/src/pdfslice_py/hash.py +17 -0
- pdfslice_py-1.0.0/src/pdfslice_py/logger.py +77 -0
- pdfslice_py-1.0.0/src/pdfslice_py/manifest.py +76 -0
- pdfslice_py-1.0.0/src/pdfslice_py/split.py +150 -0
- pdfslice_py-1.0.0/src/pdfslice_py.egg-info/PKG-INFO +69 -0
- pdfslice_py-1.0.0/src/pdfslice_py.egg-info/SOURCES.txt +22 -0
- pdfslice_py-1.0.0/src/pdfslice_py.egg-info/dependency_links.txt +1 -0
- pdfslice_py-1.0.0/src/pdfslice_py.egg-info/entry_points.txt +2 -0
- pdfslice_py-1.0.0/src/pdfslice_py.egg-info/requires.txt +10 -0
- pdfslice_py-1.0.0/src/pdfslice_py.egg-info/top_level.txt +1 -0
- pdfslice_py-1.0.0/tests/test_discover.py +84 -0
- pdfslice_py-1.0.0/tests/test_filename_template.py +64 -0
- pdfslice_py-1.0.0/tests/test_manifest.py +56 -0
- pdfslice_py-1.0.0/tests/test_split_gather.py +67 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License
|
|
2
|
+
|
|
3
|
+
By exercising the Licensed Rights (defined below), You accept and agree to be
|
|
4
|
+
bound by the terms and conditions of this Creative Commons
|
|
5
|
+
Attribution-NonCommercial-ShareAlike 4.0 International Public License ("Public
|
|
6
|
+
License"). To the extent this Public License may be interpreted as a contract,
|
|
7
|
+
You are granted the Licensed Rights in consideration of Your acceptance of these
|
|
8
|
+
terms and conditions, and the Licensor grants You such rights in consideration
|
|
9
|
+
of benefits the Licensor receives from making the Licensed Material available
|
|
10
|
+
under these terms and conditions.
|
|
11
|
+
|
|
12
|
+
## Section 1 – Definitions.
|
|
13
|
+
|
|
14
|
+
- **Licensed Material**: the artistic or literary work, database, or other
|
|
15
|
+
material to which the Licensor applied this Public License.
|
|
16
|
+
- **Licensor**: the individual(s) or entity(ies) granting rights under this
|
|
17
|
+
Public License.
|
|
18
|
+
- **You**: the individual or entity exercising the Licensed Rights under this
|
|
19
|
+
Public License.
|
|
20
|
+
- **Share**: to provide material to the public by any means or process.
|
|
21
|
+
- **Adapted Material**: material derived from or modified based on the Licensed
|
|
22
|
+
Material.
|
|
23
|
+
- **NonCommercial**: not primarily intended for or directed towards commercial
|
|
24
|
+
advantage or monetary compensation.
|
|
25
|
+
|
|
26
|
+
## Section 2 – Scope.
|
|
27
|
+
|
|
28
|
+
### 2.1 License Grant
|
|
29
|
+
|
|
30
|
+
Subject to the terms of this Public License, the Licensor grants You a
|
|
31
|
+
worldwide, royalty-free, non-exclusive, irrevocable license to:
|
|
32
|
+
|
|
33
|
+
- **Share**: copy and redistribute the Licensed Material in any medium or
|
|
34
|
+
format.
|
|
35
|
+
- **Adapt**: remix, transform, and build upon the Licensed Material.
|
|
36
|
+
|
|
37
|
+
### 2.2 Conditions
|
|
38
|
+
|
|
39
|
+
- **Attribution (BY)**: You must give appropriate credit, provide a link to the
|
|
40
|
+
license, and indicate if changes were made.
|
|
41
|
+
- **NonCommercial (NC)**: You may **not** use the material for commercial
|
|
42
|
+
purposes.
|
|
43
|
+
- **ShareAlike (SA)**: If you remix, transform, or build upon the material, you
|
|
44
|
+
must distribute your contributions under the same license as the original.
|
|
45
|
+
|
|
46
|
+
## Section 3 – Disclaimer.
|
|
47
|
+
|
|
48
|
+
The Licensed Material is provided "as-is" without any warranties or guarantees.
|
|
49
|
+
The Licensor is not liable for any damages arising from its use.
|
|
50
|
+
|
|
51
|
+
**Full License Text:**
|
|
52
|
+
[Creative Commons License](https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pdfslice-py
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Split, gather, and verify PDF <-> page-image sets (Python port of pdfslice)
|
|
5
|
+
Author: DuckyMomo20012
|
|
6
|
+
License: # Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License
|
|
7
|
+
|
|
8
|
+
By exercising the Licensed Rights (defined below), You accept and agree to be
|
|
9
|
+
bound by the terms and conditions of this Creative Commons
|
|
10
|
+
Attribution-NonCommercial-ShareAlike 4.0 International Public License ("Public
|
|
11
|
+
License"). To the extent this Public License may be interpreted as a contract,
|
|
12
|
+
You are granted the Licensed Rights in consideration of Your acceptance of these
|
|
13
|
+
terms and conditions, and the Licensor grants You such rights in consideration
|
|
14
|
+
of benefits the Licensor receives from making the Licensed Material available
|
|
15
|
+
under these terms and conditions.
|
|
16
|
+
|
|
17
|
+
## Section 1 – Definitions.
|
|
18
|
+
|
|
19
|
+
- **Licensed Material**: the artistic or literary work, database, or other
|
|
20
|
+
material to which the Licensor applied this Public License.
|
|
21
|
+
- **Licensor**: the individual(s) or entity(ies) granting rights under this
|
|
22
|
+
Public License.
|
|
23
|
+
- **You**: the individual or entity exercising the Licensed Rights under this
|
|
24
|
+
Public License.
|
|
25
|
+
- **Share**: to provide material to the public by any means or process.
|
|
26
|
+
- **Adapted Material**: material derived from or modified based on the Licensed
|
|
27
|
+
Material.
|
|
28
|
+
- **NonCommercial**: not primarily intended for or directed towards commercial
|
|
29
|
+
advantage or monetary compensation.
|
|
30
|
+
|
|
31
|
+
## Section 2 – Scope.
|
|
32
|
+
|
|
33
|
+
### 2.1 License Grant
|
|
34
|
+
|
|
35
|
+
Subject to the terms of this Public License, the Licensor grants You a
|
|
36
|
+
worldwide, royalty-free, non-exclusive, irrevocable license to:
|
|
37
|
+
|
|
38
|
+
- **Share**: copy and redistribute the Licensed Material in any medium or
|
|
39
|
+
format.
|
|
40
|
+
- **Adapt**: remix, transform, and build upon the Licensed Material.
|
|
41
|
+
|
|
42
|
+
### 2.2 Conditions
|
|
43
|
+
|
|
44
|
+
- **Attribution (BY)**: You must give appropriate credit, provide a link to the
|
|
45
|
+
license, and indicate if changes were made.
|
|
46
|
+
- **NonCommercial (NC)**: You may **not** use the material for commercial
|
|
47
|
+
purposes.
|
|
48
|
+
- **ShareAlike (SA)**: If you remix, transform, or build upon the material, you
|
|
49
|
+
must distribute your contributions under the same license as the original.
|
|
50
|
+
|
|
51
|
+
## Section 3 – Disclaimer.
|
|
52
|
+
|
|
53
|
+
The Licensed Material is provided "as-is" without any warranties or guarantees.
|
|
54
|
+
The Licensor is not liable for any damages arising from its use.
|
|
55
|
+
|
|
56
|
+
**Full License Text:**
|
|
57
|
+
[Creative Commons License](https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode)
|
|
58
|
+
|
|
59
|
+
Requires-Python: >=3.10
|
|
60
|
+
License-File: LICENSE.md
|
|
61
|
+
Requires-Dist: click>=8.1
|
|
62
|
+
Requires-Dist: pypdf>=4.0
|
|
63
|
+
Requires-Dist: pypdfium2>=4.30
|
|
64
|
+
Requires-Dist: pillow>=10.0
|
|
65
|
+
Provides-Extra: test
|
|
66
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
67
|
+
Provides-Extra: lint
|
|
68
|
+
Requires-Dist: ruff>=0.6; extra == "lint"
|
|
69
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# pdfslice-py
|
|
2
|
+
|
|
3
|
+
PDF page-image splitter, gatherer, and integrity checker — Python port of
|
|
4
|
+
[pdfslice](https://github.com/DuckyMomo20012/pdfslice) (TypeScript).
|
|
5
|
+
|
|
6
|
+
pdfslice-py helps you break a PDF into one image per page, keep a manifest of
|
|
7
|
+
the split output, verify whether all page images are present, and rebuild a
|
|
8
|
+
PDF from those images when needed.
|
|
9
|
+
|
|
10
|
+
## About the Project
|
|
11
|
+
|
|
12
|
+
This project is designed for workflows where PDF pages need to be processed
|
|
13
|
+
as images without losing the source document structure. It keeps the
|
|
14
|
+
original PDF intact, writes page-level JPG files into a folder beside the
|
|
15
|
+
source, and records metadata so the project can later verify or reconstruct
|
|
16
|
+
the full document.
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
- Split PDF files into per-page JPG images
|
|
21
|
+
- Keep a manifest with page hashes and metadata
|
|
22
|
+
- Check for missing page images without rewriting a PDF
|
|
23
|
+
- Gather page images back into a single PDF
|
|
24
|
+
- Optionally flatten output folders across a directory tree
|
|
25
|
+
- Support dry-run mode for safe previewing
|
|
26
|
+
|
|
27
|
+
### Port notes (vs. the TypeScript original)
|
|
28
|
+
|
|
29
|
+
- `pdf-to-img` + `sharp` → [`pypdfium2`](https://github.com/pypdfium2-team/pypdfium2)
|
|
30
|
+
+ [`Pillow`](https://python-pillow.org/) for rasterizing pages and encoding
|
|
31
|
+
JPEGs. `pypdfium2` (Google's PDFium, BSD-licensed) was chosen over PyMuPDF
|
|
32
|
+
specifically to keep pdfslice-py permissively licensed — PyMuPDF is AGPL.
|
|
33
|
+
- `pdf-lib` → [`pypdf`](https://pypdf.readthedocs.io/) for reading page
|
|
34
|
+
counts, and Pillow's multi-page PDF writer for rebuilding a PDF from images
|
|
35
|
+
in `gather`.
|
|
36
|
+
- `winston` → a small stdlib-`logging`-based logger with the same
|
|
37
|
+
level/verbose/quiet/log-file behavior.
|
|
38
|
+
- `@stricli/core` → [`click`](https://click.palletsprojects.com/) for the CLI.
|
|
39
|
+
- The on-disk manifest (`.pdfslice-manifest.json`) uses the exact same
|
|
40
|
+
camelCase JSON schema as the TypeScript version, so a folder split by
|
|
41
|
+
either implementation can be gathered/checked by the other.
|
|
42
|
+
|
|
43
|
+
## Getting Started
|
|
44
|
+
|
|
45
|
+
### Prerequisites
|
|
46
|
+
|
|
47
|
+
- Python >= 3.10
|
|
48
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install uv
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Install
|
|
55
|
+
|
|
56
|
+
Install the CLI globally with uv:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
uv tool install .
|
|
60
|
+
# or, once published: uv tool install pdfslice-py
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or set up for local development:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv venv
|
|
67
|
+
uv pip install -e ".[test]"
|
|
68
|
+
uv run pdfslice --help
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Install straight from GitHub:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv tool install git+https://github.com/DuckyMomo20012/pdfslice-py.git
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
The CLI exposes three commands:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pdfslice split <input> [--level <n>] [--flatten] [--template <string>] [--dry-run] [--verbose] [--quiet] [--log-file <path>]
|
|
83
|
+
pdfslice gather <input> [--backup/--no-backup] [--dry-run] [--verbose] [--quiet] [--log-file <path>]
|
|
84
|
+
pdfslice check <input> [--verbose] [--quiet] [--log-file <path>]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
(With `uv run`, prefix each command with `uv run`, e.g. `uv run pdfslice split ./documents`.)
|
|
88
|
+
|
|
89
|
+
### 1) Split a PDF into images
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pdfslice split ./documents
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
This scans the target folder for PDF files and creates a folder for each
|
|
96
|
+
PDF, for example:
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
documents/
|
|
100
|
+
├── sample.pdf
|
|
101
|
+
└── sample/
|
|
102
|
+
├── sample.001.jpg
|
|
103
|
+
├── sample.002.jpg
|
|
104
|
+
├── sample.003.jpg
|
|
105
|
+
├── sample.pdf
|
|
106
|
+
└── .pdfslice-manifest.json
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The original PDF is preserved and copied into the generated output folder.
|
|
110
|
+
|
|
111
|
+
#### Directory search depth
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
pdfslice split ./documents --level 2
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Use `--level` to control how deep the search should go when scanning nested
|
|
118
|
+
folders.
|
|
119
|
+
|
|
120
|
+
#### Flatten output
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
pdfslice split ./documents --flatten
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
This places each generated output folder at the input root instead of
|
|
127
|
+
beside each source PDF.
|
|
128
|
+
|
|
129
|
+
#### Custom filename template
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
pdfslice split ./documents --template "page-{{page_number}}.jpg"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Use `{{filename}}` and `{{page_number}}` placeholders to control the page
|
|
136
|
+
image filename (default: `{{filename}}.{{page_number}}.jpg`). Exactly one
|
|
137
|
+
`{{page_number}}` is required. The template is saved in the manifest, so
|
|
138
|
+
`gather`/`check` parse page numbers back out correctly without needing
|
|
139
|
+
`--template` repeated.
|
|
140
|
+
|
|
141
|
+
### 2) Gather images back into a PDF
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pdfslice gather ./documents/sample
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
This rebuilds a combined PDF from the page images in the split unit folder
|
|
148
|
+
and **overwrites the original PDF in place** (same filename, same location).
|
|
149
|
+
A backup of the previous PDF (`sample.bak-<timestamp>.pdf`) is created first
|
|
150
|
+
by default — pass `--no-backup` to skip it.
|
|
151
|
+
|
|
152
|
+
If the PDF already reflects the current images (nothing has changed since
|
|
153
|
+
the last gather), the project skips unnecessary regeneration.
|
|
154
|
+
|
|
155
|
+
### 3) Check for missing page images
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
pdfslice check ./documents/sample
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
This reports missing pages without creating a PDF output. Exits with status
|
|
162
|
+
code 1 if any unit has missing pages.
|
|
163
|
+
|
|
164
|
+
## Common flags
|
|
165
|
+
|
|
166
|
+
- `--dry-run`: preview actions without writing files
|
|
167
|
+
- `--backup` / `--no-backup` (gather only, default on): back up the existing
|
|
168
|
+
PDF before overwriting it
|
|
169
|
+
- `--template <string>` (split only): custom page-image filename template
|
|
170
|
+
- `--verbose`: print debug logging
|
|
171
|
+
- `--quiet`: print only errors
|
|
172
|
+
- `--log-file <path>`: write logs as JSON lines as well as console
|
|
173
|
+
|
|
174
|
+
## Example workflow
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
pdfslice split ./input --level 2
|
|
178
|
+
pdfslice check ./input/report
|
|
179
|
+
pdfslice gather ./input/report
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Project Structure
|
|
183
|
+
|
|
184
|
+
```text
|
|
185
|
+
src/pdfslice_py/
|
|
186
|
+
├── __init__.py
|
|
187
|
+
├── cli.py # click CLI: split, gather, check commands
|
|
188
|
+
├── discover.py # find_pdfs, find_images_deep, page_image_name
|
|
189
|
+
├── filename_template.py # {{filename}}/{{page_number}} template compiler
|
|
190
|
+
├── gather.py # rebuild PDF from images / check-only mode
|
|
191
|
+
├── hash.py # streamed SHA-256 file hashing
|
|
192
|
+
├── logger.py # verbose/quiet/log-file logger
|
|
193
|
+
├── manifest.py # .pdfslice-manifest.json schema + read/write
|
|
194
|
+
└── split.py # PDF -> per-page JPG splitting
|
|
195
|
+
tests/
|
|
196
|
+
├── test_discover.py
|
|
197
|
+
├── test_filename_template.py
|
|
198
|
+
├── test_manifest.py
|
|
199
|
+
└── test_split_gather.py
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Running Tests
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
uv pip install -e ".[test]"
|
|
206
|
+
uv run pytest -v
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Contributing
|
|
210
|
+
|
|
211
|
+
Contributions are welcome.
|
|
212
|
+
|
|
213
|
+
1. Fork the repository
|
|
214
|
+
2. Create a feature branch
|
|
215
|
+
3. Make your changes
|
|
216
|
+
4. Run the relevant checks and tests
|
|
217
|
+
5. Open a pull request
|
|
218
|
+
|
|
219
|
+
## License
|
|
220
|
+
|
|
221
|
+
This project is licensed under the Creative Commons
|
|
222
|
+
Attribution-NonCommercial-ShareAlike 4.0 International License, matching the
|
|
223
|
+
[TypeScript original](https://github.com/DuckyMomo20012/pdfslice).
|
|
224
|
+
|
|
225
|
+
See [LICENSE.md](LICENSE.md) for the full text.
|
|
226
|
+
|
|
227
|
+
## Repository
|
|
228
|
+
|
|
229
|
+
- Original (TypeScript): https://github.com/DuckyMomo20012/pdfslice
|
|
230
|
+
- Author: DuckyMomo20012
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pdfslice-py"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Split, gather, and verify PDF <-> page-image sets (Python port of pdfslice)"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = { file = "LICENSE.md" }
|
|
11
|
+
authors = [{ name = "DuckyMomo20012" }]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"click>=8.1",
|
|
14
|
+
"pypdf>=4.0",
|
|
15
|
+
"pypdfium2>=4.30",
|
|
16
|
+
"pillow>=10.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
pdfslice = "pdfslice_py.cli:cli"
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
test = ["pytest>=8.0"]
|
|
24
|
+
lint = ["ruff>=0.6"]
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
line-length = 100
|
|
34
|
+
target-version = "py310"
|
|
35
|
+
src = ["src", "tests"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint]
|
|
38
|
+
select = [
|
|
39
|
+
"E", # pycodestyle errors
|
|
40
|
+
"F", # pyflakes
|
|
41
|
+
"I", # isort
|
|
42
|
+
"UP", # pyupgrade
|
|
43
|
+
"B", # flake8-bugbear
|
|
44
|
+
"A", # flake8-builtins
|
|
45
|
+
"N", # pep8-naming
|
|
46
|
+
"SIM", # flake8-simplify
|
|
47
|
+
]
|
|
48
|
+
ignore = [
|
|
49
|
+
"N815", # camelCase attrs — manifest fields mirror the on-disk JSON schema on purpose
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint.per-file-ignores]
|
|
53
|
+
"tests/*" = ["N802"] # test_* function names, camelCase-style asserts on manifest fields
|
|
54
|
+
|
|
55
|
+
[tool.ruff.format]
|
|
56
|
+
quote-style = "double"
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""CLI. Port of src/app.ts + src/commands/{split,gather,check}/{command,impl}.ts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from .filename_template import DEFAULT_TEMPLATE
|
|
10
|
+
from .gather import gather_all
|
|
11
|
+
from .logger import create_logger
|
|
12
|
+
from .split import split_all
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _log_flags(f: click.decorators.FC) -> click.decorators.FC:
|
|
16
|
+
f = click.option("--verbose", is_flag=True, default=False, help="Enable debug logging")(f)
|
|
17
|
+
f = click.option("--quiet", is_flag=True, default=False, help="Only log errors")(f)
|
|
18
|
+
f = click.option("--log-file", default=None, help="Path to also write logs as JSON")(f)
|
|
19
|
+
return f
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.group()
|
|
23
|
+
@click.version_option(version="0.1.0", prog_name="pdfslice")
|
|
24
|
+
def cli() -> None:
|
|
25
|
+
"""Split PDFs into page images, then gather or check them back."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@cli.command("split")
|
|
29
|
+
@click.argument("input", type=str)
|
|
30
|
+
@click.option(
|
|
31
|
+
"-l",
|
|
32
|
+
"--level",
|
|
33
|
+
type=int,
|
|
34
|
+
default=1,
|
|
35
|
+
help="How many directory levels deep to search for PDFs",
|
|
36
|
+
)
|
|
37
|
+
@click.option(
|
|
38
|
+
"-f",
|
|
39
|
+
"--flatten",
|
|
40
|
+
is_flag=True,
|
|
41
|
+
default=False,
|
|
42
|
+
help="Pull every discovered PDF's output folder to the input root, "
|
|
43
|
+
"instead of alongside each PDF",
|
|
44
|
+
)
|
|
45
|
+
@click.option(
|
|
46
|
+
"--template",
|
|
47
|
+
type=str,
|
|
48
|
+
default=DEFAULT_TEMPLATE,
|
|
49
|
+
help="Page image filename template. Placeholders: {{filename}}, "
|
|
50
|
+
"{{page_number}}. Must contain exactly one {{page_number}}.",
|
|
51
|
+
)
|
|
52
|
+
@click.option(
|
|
53
|
+
"--dry-run", is_flag=True, default=False, help="Preview actions without writing any files"
|
|
54
|
+
)
|
|
55
|
+
@_log_flags
|
|
56
|
+
def split_cmd(
|
|
57
|
+
input: str, # noqa: A002 - mirrors Click argument name and TS API
|
|
58
|
+
level: int,
|
|
59
|
+
flatten: bool,
|
|
60
|
+
template: str,
|
|
61
|
+
dry_run: bool,
|
|
62
|
+
verbose: bool,
|
|
63
|
+
quiet: bool,
|
|
64
|
+
log_file: str | None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Split PDF(s) into per-page JPG images alongside the source file."""
|
|
67
|
+
logger = create_logger(verbose=verbose, quiet=quiet, log_file=log_file)
|
|
68
|
+
results = split_all(
|
|
69
|
+
input,
|
|
70
|
+
logger,
|
|
71
|
+
level=level,
|
|
72
|
+
flatten=flatten,
|
|
73
|
+
template=template,
|
|
74
|
+
dry_run=dry_run,
|
|
75
|
+
)
|
|
76
|
+
logger.info(f"Done. Processed {len(results)} PDF(s).")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@cli.command("gather")
|
|
80
|
+
@click.argument("input", type=str)
|
|
81
|
+
@click.option(
|
|
82
|
+
"--dry-run", is_flag=True, default=False, help="Preview actions without writing any files"
|
|
83
|
+
)
|
|
84
|
+
@click.option(
|
|
85
|
+
"--backup/--no-backup",
|
|
86
|
+
default=True,
|
|
87
|
+
help="Back up the existing PDF before overwriting it",
|
|
88
|
+
)
|
|
89
|
+
@_log_flags
|
|
90
|
+
def gather_cmd(
|
|
91
|
+
input: str, # noqa: A002 - mirrors Click argument name and TS API
|
|
92
|
+
dry_run: bool,
|
|
93
|
+
backup: bool,
|
|
94
|
+
verbose: bool,
|
|
95
|
+
quiet: bool,
|
|
96
|
+
log_file: str | None,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Gather page images back into a PDF, reporting any missing pages."""
|
|
99
|
+
logger = create_logger(verbose=verbose, quiet=quiet, log_file=log_file)
|
|
100
|
+
reports = gather_all(input, logger, dry_run=dry_run, check_only=False, backup=backup)
|
|
101
|
+
|
|
102
|
+
with_missing = [r for r in reports if r.missing_pages]
|
|
103
|
+
if with_missing:
|
|
104
|
+
logger.warn(f"{len(with_missing)} unit(s) have missing pages")
|
|
105
|
+
logger.info(f"Done. Processed {len(reports)} unit folder(s).")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@cli.command("check")
|
|
109
|
+
@click.argument("input", type=str)
|
|
110
|
+
@_log_flags
|
|
111
|
+
def check_cmd(
|
|
112
|
+
input: str, # noqa: A002 - mirrors Click argument name and TS API
|
|
113
|
+
verbose: bool,
|
|
114
|
+
quiet: bool,
|
|
115
|
+
log_file: str | None,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Report missing page images without writing any PDF (read-only)."""
|
|
118
|
+
logger = create_logger(verbose=verbose, quiet=quiet, log_file=log_file)
|
|
119
|
+
reports = gather_all(input, logger, check_only=True)
|
|
120
|
+
|
|
121
|
+
with_missing = [r for r in reports if r.missing_pages]
|
|
122
|
+
if with_missing:
|
|
123
|
+
logger.warn(f"{len(with_missing)} unit(s) have missing pages")
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
else:
|
|
126
|
+
logger.info(f"All {len(reports)} unit(s) complete.")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
cli()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""PDF/image discovery on the filesystem. Direct port of lib/discover.ts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .filename_template import DEFAULT_TEMPLATE, compile_template
|
|
9
|
+
|
|
10
|
+
_IMAGE_SUFFIXES = {".jpg", ".jpeg"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def find_pdfs(root: str | Path, level: int = 1) -> list[str]:
|
|
14
|
+
"""Find all PDF files under `root`, descending up to `level` directories
|
|
15
|
+
deep. level=1 (default): PDFs directly in `root` only. level=2: `root`
|
|
16
|
+
and one subfolder deep. Etc. If `root` itself is a PDF file, returns
|
|
17
|
+
just that file."""
|
|
18
|
+
root = Path(root)
|
|
19
|
+
if root.is_file():
|
|
20
|
+
return [str(root)] if root.suffix.lower() == ".pdf" else []
|
|
21
|
+
|
|
22
|
+
results: list[str] = []
|
|
23
|
+
|
|
24
|
+
def walk(directory: Path, depth: int) -> None:
|
|
25
|
+
with os.scandir(directory) as entries:
|
|
26
|
+
for entry in entries:
|
|
27
|
+
full = Path(entry.path)
|
|
28
|
+
if entry.is_file() and full.suffix.lower() == ".pdf":
|
|
29
|
+
results.append(str(full))
|
|
30
|
+
elif entry.is_dir() and depth < level:
|
|
31
|
+
walk(full, depth + 1)
|
|
32
|
+
|
|
33
|
+
walk(root, 1)
|
|
34
|
+
return results
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def find_images_deep(root: str | Path) -> list[str]:
|
|
38
|
+
"""Recursively find all image files (jpg/jpeg) under `root`, any depth.
|
|
39
|
+
Used by gather/check, since split output can be nested by flatten mode."""
|
|
40
|
+
root = Path(root)
|
|
41
|
+
results: list[str] = []
|
|
42
|
+
|
|
43
|
+
def walk(directory: Path) -> None:
|
|
44
|
+
with os.scandir(directory) as entries:
|
|
45
|
+
for entry in entries:
|
|
46
|
+
full = Path(entry.path)
|
|
47
|
+
if entry.is_file() and full.suffix.lower() in _IMAGE_SUFFIXES:
|
|
48
|
+
results.append(str(full))
|
|
49
|
+
elif entry.is_dir():
|
|
50
|
+
walk(full)
|
|
51
|
+
|
|
52
|
+
walk(root)
|
|
53
|
+
return results
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def page_image_name(base_name: str, page: int, template: str = DEFAULT_TEMPLATE) -> str:
|
|
57
|
+
"""Build the page-image filename using a template (default:
|
|
58
|
+
"{{filename}}.{{page_number}}.jpg"). Page number is zero-padded to 3
|
|
59
|
+
digits; if the number itself is wider than 3 digits, no padding is
|
|
60
|
+
applied (natural width is used)."""
|
|
61
|
+
return compile_template(template).render(base_name, page)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_page_from_image_name(file_name: str, template: str = DEFAULT_TEMPLATE) -> int | None:
|
|
65
|
+
"""Parse a page number back out of a name produced by page_image_name,
|
|
66
|
+
using the same template it was generated with."""
|
|
67
|
+
return compile_template(template).parse_page(file_name)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Page-image filename templating. Direct port of lib/filename-template.ts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
DEFAULT_TEMPLATE = "{{filename}}.{{page_number}}.jpg"
|
|
10
|
+
|
|
11
|
+
_PLACEHOLDER_PATTERN = re.compile(r"(\{\{filename\}\}|\{\{page_number\}\})")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _pad_page_number(page: int) -> str:
|
|
15
|
+
return str(page).zfill(3) if page < 1000 else str(page)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class FilenameTemplate:
|
|
20
|
+
render: Callable[[str, int], str]
|
|
21
|
+
parse_page: Callable[[str], int | None]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def compile_template(template: str) -> FilenameTemplate:
|
|
25
|
+
page_count = template.count("{{page_number}}")
|
|
26
|
+
if page_count != 1:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
f"Template must contain exactly one {{{{page_number}}}} placeholder, "
|
|
29
|
+
f'found {page_count} in "{template}"'
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
parts = _PLACEHOLDER_PATTERN.split(template)
|
|
33
|
+
regex_source = ""
|
|
34
|
+
for i, part in enumerate(parts):
|
|
35
|
+
if i % 2 == 0:
|
|
36
|
+
regex_source += re.escape(part)
|
|
37
|
+
elif part == "{{page_number}}":
|
|
38
|
+
regex_source += r"(\d+)"
|
|
39
|
+
elif part == "{{filename}}":
|
|
40
|
+
regex_source += ".+?"
|
|
41
|
+
|
|
42
|
+
regex = re.compile(f"^{regex_source}$", re.IGNORECASE)
|
|
43
|
+
|
|
44
|
+
def render(base_name: str, page: int) -> str:
|
|
45
|
+
return template.replace("{{filename}}", base_name).replace(
|
|
46
|
+
"{{page_number}}", _pad_page_number(page)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def parse_page(file_name: str) -> int | None:
|
|
50
|
+
m = regex.match(file_name)
|
|
51
|
+
if not m:
|
|
52
|
+
return None
|
|
53
|
+
return int(m.group(1))
|
|
54
|
+
|
|
55
|
+
return FilenameTemplate(render=render, parse_page=parse_page)
|