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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """pdfslice-py: split PDFs into page images, then gather or check them back.
2
+
3
+ Python port of https://github.com/DuckyMomo20012/pdfslice (TypeScript).
4
+ """
5
+
6
+ __version__ = "1.0.0"
@@ -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)