pdfslice-py 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,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"
pdfslice_py/cli.py ADDED
@@ -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)
pdfslice_py/gather.py ADDED
@@ -0,0 +1,254 @@
1
+ """Rebuild a PDF from split page images, or just check for missing pages.
2
+ Direct port of lib/gather.ts.
3
+
4
+ pdf-lib's `PDFDocument.create()` + `embedJpg`/`addPage`/`drawImage` at
5
+ image-pixel dimensions (TS) is equivalent here to Pillow saving a multi-page
6
+ PDF from the JPEGs directly with `resolution=72.0`, since both put one
7
+ image pixel per PDF point (i.e. treat the image as if it were 72 DPI),
8
+ regardless of the image's own DPI metadata.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import shutil
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Literal
18
+
19
+ from PIL import Image
20
+ from pypdf import PdfReader
21
+
22
+ from .discover import find_images_deep, parse_page_from_image_name
23
+ from .filename_template import DEFAULT_TEMPLATE
24
+ from .hash import hash_file
25
+ from .logger import Logger
26
+ from .manifest import Manifest, ManifestImageEntry, manifest_path_for, read_manifest, write_manifest
27
+
28
+ _PDF_PAGE_RESOLUTION = 72.0 # 1 image pixel == 1 PDF point
29
+
30
+ Action = Literal["created", "skipped-unchanged", "would-create", "missing-source", "check-only"]
31
+
32
+
33
+ @dataclass
34
+ class UnitReport:
35
+ folder: str
36
+ source_pdf: str | None
37
+ page_count: int | None
38
+ found_images: int
39
+ missing_pages: list[int]
40
+ action: Action
41
+ output_pdf: str | None = None
42
+
43
+
44
+ @dataclass
45
+ class GatherOptions:
46
+ input: str
47
+ dry_run: bool = False
48
+ check_only: bool = False
49
+ backup: bool = True
50
+ logger: Logger = field(default=None) # type: ignore[assignment]
51
+
52
+
53
+ def _find_unit_folders(root: str | Path) -> list[str]:
54
+ """Discover "unit" folders: a folder produced by `split` — recognized by
55
+ the `.pdfslice-manifest.json` written by split (not by the mere presence
56
+ of a PDF, since the source folder the PDF was found in also still
57
+ contains a PDF that split never deletes)."""
58
+ root = Path(root)
59
+ if not root.is_dir():
60
+ return []
61
+
62
+ units: list[str] = []
63
+
64
+ def walk(directory: Path) -> None:
65
+ if manifest_path_for(directory).exists():
66
+ units.append(str(directory))
67
+ return # don't descend further into a recognized unit
68
+ for entry in sorted(directory.iterdir()):
69
+ if entry.is_dir():
70
+ walk(entry)
71
+
72
+ walk(root)
73
+ return units
74
+
75
+
76
+ def gather_all(
77
+ input: str | Path, # noqa: A002 - mirrors TS `input` option name
78
+ logger: Logger,
79
+ dry_run: bool = False,
80
+ check_only: bool = False,
81
+ backup: bool = True,
82
+ ) -> list[UnitReport]:
83
+ units = _find_unit_folders(input)
84
+ logger.info(f"Found {len(units)} unit folder(s) under {input}")
85
+
86
+ reports: list[UnitReport] = []
87
+ for folder in units:
88
+ reports.append(
89
+ _gather_one(
90
+ folder, logger=logger, dry_run=dry_run, check_only=check_only, backup=backup
91
+ )
92
+ )
93
+ return reports
94
+
95
+
96
+ def _gather_one(
97
+ folder: str,
98
+ *,
99
+ logger: Logger,
100
+ dry_run: bool = False,
101
+ check_only: bool = False,
102
+ backup: bool = True,
103
+ ) -> UnitReport:
104
+ manifest = read_manifest(folder)
105
+ if manifest is None:
106
+ logger.warn("No manifest found in unit folder", folder=folder)
107
+ return UnitReport(
108
+ folder=folder,
109
+ source_pdf=None,
110
+ page_count=None,
111
+ found_images=0,
112
+ missing_pages=[],
113
+ action="missing-source",
114
+ )
115
+
116
+ source_pdf_path = Path(folder) / manifest.sourcePdf
117
+ if not source_pdf_path.exists():
118
+ logger.warn(
119
+ "Source PDF recorded in manifest is missing",
120
+ folder=folder,
121
+ expected=manifest.sourcePdf,
122
+ )
123
+ return UnitReport(
124
+ folder=folder,
125
+ source_pdf=None,
126
+ page_count=None,
127
+ found_images=0,
128
+ missing_pages=[],
129
+ action="missing-source",
130
+ )
131
+
132
+ page_count = len(PdfReader(str(source_pdf_path)).pages)
133
+ template = manifest.filenameTemplate or DEFAULT_TEMPLATE
134
+
135
+ image_paths = find_images_deep(folder)
136
+ found_pages: set[int] = set()
137
+ for img_path in image_paths:
138
+ page = parse_page_from_image_name(Path(img_path).name, template)
139
+ if page is not None:
140
+ found_pages.add(page)
141
+
142
+ missing_pages = [p for p in range(1, page_count + 1) if p not in found_pages]
143
+
144
+ if missing_pages:
145
+ logger.warn(
146
+ "Missing page image(s)",
147
+ folder=folder,
148
+ missing_pages=missing_pages,
149
+ expected=page_count,
150
+ found=len(found_pages),
151
+ )
152
+ else:
153
+ logger.info(f"All {page_count} page image(s) present", folder=folder)
154
+
155
+ if check_only:
156
+ return UnitReport(
157
+ folder=folder,
158
+ source_pdf=str(source_pdf_path),
159
+ page_count=page_count,
160
+ found_images=len(found_pages),
161
+ missing_pages=missing_pages,
162
+ action="check-only",
163
+ )
164
+
165
+ # Decide whether to (re)build the combined PDF, comparing the manifest's
166
+ # recorded image hashes against the current on-disk images.
167
+ ordered = sorted(
168
+ (p for p in image_paths if parse_page_from_image_name(Path(p).name, template) is not None),
169
+ key=lambda p: parse_page_from_image_name(Path(p).name, template), # type: ignore[arg-type]
170
+ )
171
+ current_entries = [
172
+ ManifestImageEntry(
173
+ file=Path(p).name,
174
+ page=parse_page_from_image_name(Path(p).name, template), # type: ignore[arg-type]
175
+ hash=hash_file(p),
176
+ )
177
+ for p in ordered
178
+ ]
179
+
180
+ # Output overwrites the source PDF in place, per spec.
181
+ output_pdf_path = source_pdf_path
182
+
183
+ unchanged = (
184
+ bool(manifest.gatheredAt)
185
+ and manifest.pageCount == page_count
186
+ and len(manifest.images) == len(current_entries)
187
+ and all(a.hash == b.hash for a, b in zip(manifest.images, current_entries, strict=True))
188
+ )
189
+
190
+ if unchanged and not missing_pages:
191
+ logger.info("No changes detected, skipping PDF creation", folder=folder)
192
+ return UnitReport(
193
+ folder=folder,
194
+ source_pdf=str(source_pdf_path),
195
+ page_count=page_count,
196
+ found_images=len(found_pages),
197
+ missing_pages=missing_pages,
198
+ output_pdf=str(output_pdf_path),
199
+ action="skipped-unchanged",
200
+ )
201
+
202
+ if dry_run:
203
+ logger.info(f"[dry-run] would overwrite {output_pdf_path}")
204
+ return UnitReport(
205
+ folder=folder,
206
+ source_pdf=str(source_pdf_path),
207
+ page_count=page_count,
208
+ found_images=len(found_pages),
209
+ missing_pages=missing_pages,
210
+ output_pdf=str(output_pdf_path),
211
+ action="would-create",
212
+ )
213
+
214
+ # Back up the existing PDF before overwriting it (default on; --no-backup to skip).
215
+ if backup:
216
+ backup_path = Path(folder) / f"{output_pdf_path.stem}.bak-{int(time.time() * 1000)}.pdf"
217
+ shutil.copyfile(output_pdf_path, backup_path)
218
+ logger.info("Backed up previous PDF", backup_path=str(backup_path))
219
+
220
+ images = [Image.open(p).convert("RGB") for p in ordered]
221
+ first, rest = images[0], images[1:]
222
+ first.save(
223
+ output_pdf_path, "PDF", save_all=True, append_images=rest, resolution=_PDF_PAGE_RESOLUTION
224
+ )
225
+ for im in images:
226
+ im.close()
227
+
228
+ from datetime import datetime, timezone
229
+
230
+ now = datetime.now(timezone.utc).isoformat()
231
+ write_manifest(
232
+ folder,
233
+ Manifest(
234
+ version=1,
235
+ sourcePdf=manifest.sourcePdf,
236
+ sourcePdfHash=hash_file(output_pdf_path),
237
+ pageCount=page_count,
238
+ images=current_entries,
239
+ updatedAt=now,
240
+ gatheredAt=now,
241
+ filenameTemplate=template,
242
+ ),
243
+ )
244
+
245
+ logger.info("PDF overwritten", output_pdf=str(output_pdf_path))
246
+ return UnitReport(
247
+ folder=folder,
248
+ source_pdf=str(source_pdf_path),
249
+ page_count=page_count,
250
+ found_images=len(found_pages),
251
+ missing_pages=missing_pages,
252
+ output_pdf=str(output_pdf_path),
253
+ action="created",
254
+ )
pdfslice_py/hash.py ADDED
@@ -0,0 +1,17 @@
1
+ """File hashing. Direct port of lib/hash.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+
8
+ _CHUNK_SIZE = 1024 * 1024
9
+
10
+
11
+ def hash_file(path: str | Path) -> str:
12
+ """SHA-256 hash of a file's contents, streamed (safe for large PDFs/images)."""
13
+ h = hashlib.sha256()
14
+ with open(path, "rb") as f:
15
+ for chunk in iter(lambda: f.read(_CHUNK_SIZE), b""):
16
+ h.update(chunk)
17
+ return h.hexdigest()
pdfslice_py/logger.py ADDED
@@ -0,0 +1,77 @@
1
+ """Logging. Port of lib/logger.ts (winston) onto Python's stdlib logging.
2
+
3
+ Call sites use `logger.info("message", key=value, ...)` the way the
4
+ TypeScript original used `logger.info('message', { key: value })` — extra
5
+ keyword arguments are the "meta" object, printed as JSON after the message
6
+ and, if `log_file` is set, also written as a JSON line per record.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import sys
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ _COLOR_BY_LEVEL = {
19
+ "debug": "\033[34m", # blue
20
+ "info": "\033[32m", # green
21
+ "warn": "\033[33m", # yellow
22
+ "error": "\033[31m", # red
23
+ }
24
+ _RESET = "\033[0m"
25
+
26
+
27
+ class Logger:
28
+ def __init__(self, level: str, log_file: str | Path | None = None) -> None:
29
+ self._level = level
30
+ self._log_file = Path(log_file) if log_file is not None else None
31
+ self._order = {"debug": 0, "info": 1, "warn": 2, "error": 3}
32
+
33
+ def _enabled(self, level: str) -> bool:
34
+ return self._order[level] >= self._order[self._level]
35
+
36
+ def _emit(self, level: str, message: str, meta: dict[str, Any]) -> None:
37
+ if not self._enabled(level):
38
+ return
39
+
40
+ color = _COLOR_BY_LEVEL.get(level, "")
41
+ extra = f" {json.dumps(meta)}" if meta else ""
42
+ stream = sys.stderr if level == "error" else sys.stdout
43
+ print(f"{color}{level}{_RESET}: {message}{extra}", file=stream)
44
+
45
+ if self._log_file is not None:
46
+ record = {
47
+ "level": level,
48
+ "message": message,
49
+ "timestamp": datetime.now(timezone.utc).isoformat(),
50
+ **meta,
51
+ }
52
+ with open(self._log_file, "a", encoding="utf-8") as f:
53
+ f.write(json.dumps(record) + "\n")
54
+
55
+ def debug(self, message: str, **meta: Any) -> None:
56
+ self._emit("debug", message, meta)
57
+
58
+ def info(self, message: str, **meta: Any) -> None:
59
+ self._emit("info", message, meta)
60
+
61
+ def warn(self, message: str, **meta: Any) -> None:
62
+ self._emit("warn", message, meta)
63
+
64
+ def error(self, message: str, **meta: Any) -> None:
65
+ self._emit("error", message, meta)
66
+
67
+
68
+ def create_logger(
69
+ verbose: bool = False, quiet: bool = False, log_file: str | Path | None = None
70
+ ) -> Logger:
71
+ level = "error" if quiet else "debug" if verbose else "info"
72
+ return Logger(level=level, log_file=log_file)
73
+
74
+
75
+ # Keep the stdlib logging module importable/usable by consumers who'd rather
76
+ # wire pdfslice_py into their own logging config instead of using Logger above.
77
+ _stdlib_logger = logging.getLogger("pdfslice_py")
@@ -0,0 +1,76 @@
1
+ """Per-unit split manifest. Direct port of lib/manifest.ts.
2
+
3
+ JSON keys are kept camelCase (matching the TypeScript original) so manifest
4
+ files stay readable/interoperable between the two implementations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ MANIFEST_FILENAME = ".pdfslice-manifest.json"
14
+
15
+
16
+ @dataclass
17
+ class ManifestImageEntry:
18
+ file: str # file name relative to the manifest's folder
19
+ page: int
20
+ hash: str
21
+
22
+
23
+ @dataclass
24
+ class Manifest:
25
+ version: int
26
+ sourcePdf: str # noqa: N815 - camelCase to match on-disk JSON schema
27
+ sourcePdfHash: str # noqa: N815
28
+ pageCount: int # noqa: N815
29
+ images: list[ManifestImageEntry] = field(default_factory=list)
30
+ updatedAt: str = "" # noqa: N815
31
+ gatheredAt: str | None = None # noqa: N815 - set once `gather` has run at least once
32
+ filenameTemplate: str = "" # noqa: N815
33
+
34
+ def to_json(self) -> str:
35
+ return json.dumps(
36
+ {
37
+ "version": self.version,
38
+ "sourcePdf": self.sourcePdf,
39
+ "sourcePdfHash": self.sourcePdfHash,
40
+ "pageCount": self.pageCount,
41
+ "images": [{"file": i.file, "page": i.page, "hash": i.hash} for i in self.images],
42
+ "updatedAt": self.updatedAt,
43
+ "gatheredAt": self.gatheredAt,
44
+ "filenameTemplate": self.filenameTemplate,
45
+ },
46
+ indent=2,
47
+ )
48
+
49
+ @classmethod
50
+ def from_json(cls, text: str) -> Manifest:
51
+ data = json.loads(text)
52
+ return cls(
53
+ version=data["version"],
54
+ sourcePdf=data["sourcePdf"],
55
+ sourcePdfHash=data["sourcePdfHash"],
56
+ pageCount=data["pageCount"],
57
+ images=[ManifestImageEntry(**i) for i in data.get("images", [])],
58
+ updatedAt=data.get("updatedAt", ""),
59
+ gatheredAt=data.get("gatheredAt"),
60
+ filenameTemplate=data.get("filenameTemplate", ""),
61
+ )
62
+
63
+
64
+ def manifest_path_for(folder: str | Path) -> Path:
65
+ return Path(folder) / MANIFEST_FILENAME
66
+
67
+
68
+ def read_manifest(folder: str | Path) -> Manifest | None:
69
+ p = manifest_path_for(folder)
70
+ if not p.exists():
71
+ return None
72
+ return Manifest.from_json(p.read_text(encoding="utf-8"))
73
+
74
+
75
+ def write_manifest(folder: str | Path, manifest: Manifest) -> None:
76
+ manifest_path_for(folder).write_text(manifest.to_json(), encoding="utf-8")
pdfslice_py/split.py ADDED
@@ -0,0 +1,150 @@
1
+ """PDF -> per-page JPG splitting. Direct port of lib/split.ts.
2
+
3
+ pdf-to-img + sharp (TS) map to pypdfium2 + Pillow here: pypdfium2 (Google's
4
+ PDFium, BSD-licensed — chosen over PyMuPDF's AGPL license for a permissively
5
+ licensed CLI) rasterizes each page at 2x scale (matching pdf-to-img's
6
+ `{ scale: 2 }`), Pillow re-encodes it to JPEG at quality 90.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ import pypdfium2 as pdfium
17
+ from pypdf import PdfReader
18
+
19
+ from .discover import find_pdfs, page_image_name
20
+ from .filename_template import DEFAULT_TEMPLATE
21
+ from .hash import hash_file
22
+ from .logger import Logger
23
+ from .manifest import Manifest, ManifestImageEntry, write_manifest
24
+
25
+ _RENDER_SCALE = 2 # ~2x native resolution, matches pdf-to-img's `{ scale: 2 }`
26
+ _JPEG_QUALITY = 90
27
+
28
+
29
+ @dataclass
30
+ class SplitResult:
31
+ pdf: str
32
+ output_folder: str
33
+ page_count: int
34
+ images: list[str] = field(default_factory=list)
35
+ skipped: bool = False
36
+
37
+
38
+ def _folder_name_for(pdf_path: str | Path) -> str:
39
+ return Path(pdf_path).stem
40
+
41
+
42
+ def split_all(
43
+ input: str | Path, # noqa: A002 - mirrors TS `input` option name
44
+ logger: Logger,
45
+ level: int = 1,
46
+ flatten: bool = False,
47
+ template: str = DEFAULT_TEMPLATE,
48
+ dry_run: bool = False,
49
+ ) -> list[SplitResult]:
50
+ pdfs = find_pdfs(input, level)
51
+ logger.info(f"Found {len(pdfs)} PDF file(s) under {input}", level=level)
52
+
53
+ results: list[SplitResult] = []
54
+ for pdf_path in pdfs:
55
+ results.append(
56
+ _split_one(
57
+ pdf_path,
58
+ input=input,
59
+ flatten=flatten,
60
+ template=template,
61
+ dry_run=dry_run,
62
+ logger=logger,
63
+ )
64
+ )
65
+ return results
66
+
67
+
68
+ def _split_one(
69
+ pdf_path: str,
70
+ *,
71
+ input: str | Path, # noqa: A002
72
+ flatten: bool,
73
+ template: str,
74
+ dry_run: bool,
75
+ logger: Logger,
76
+ ) -> SplitResult:
77
+ base_name = _folder_name_for(pdf_path)
78
+ parent_dir = Path(input) if flatten else Path(pdf_path).parent
79
+ output_folder = parent_dir / base_name
80
+ dest_pdf_path = output_folder / Path(pdf_path).name
81
+
82
+ logger.info(f"Processing {pdf_path}", output_folder=str(output_folder))
83
+
84
+ if dry_run:
85
+ logger.info(f"[dry-run] would create folder {output_folder}")
86
+ logger.info(f"[dry-run] would move {pdf_path} -> {dest_pdf_path} (copy, original kept)")
87
+ page_count = len(PdfReader(pdf_path).pages)
88
+ for i in range(1, page_count + 1):
89
+ logger.info(f"[dry-run] would create image {page_image_name(base_name, i, template)}")
90
+ return SplitResult(
91
+ pdf=str(pdf_path),
92
+ output_folder=str(output_folder),
93
+ page_count=page_count,
94
+ images=[],
95
+ skipped=True,
96
+ )
97
+
98
+ output_folder.mkdir(parents=True, exist_ok=True)
99
+
100
+ # "Move" without deleting the original: copy into the new folder. The
101
+ # original PDF at its source path is left untouched, per spec.
102
+ if not dest_pdf_path.exists():
103
+ shutil.copyfile(pdf_path, dest_pdf_path)
104
+
105
+ page_count = len(PdfReader(str(dest_pdf_path)).pages)
106
+
107
+ images: list[str] = []
108
+ image_entries: list[ManifestImageEntry] = []
109
+
110
+ doc = pdfium.PdfDocument(str(dest_pdf_path))
111
+ try:
112
+ for i in range(page_count):
113
+ page = doc[i]
114
+ bitmap = page.render(scale=_RENDER_SCALE)
115
+ img = bitmap.to_pil().convert("RGB")
116
+
117
+ page_num = i + 1
118
+ file_name = page_image_name(base_name, page_num, template)
119
+ image_path = output_folder / file_name
120
+ img.save(image_path, "JPEG", quality=_JPEG_QUALITY)
121
+ file_hash = hash_file(image_path)
122
+ images.append(str(image_path))
123
+ image_entries.append(ManifestImageEntry(file=file_name, page=page_num, hash=file_hash))
124
+ logger.debug("Wrote page image", file_name=file_name, page=page_num)
125
+
126
+ bitmap.close()
127
+ page.close()
128
+ finally:
129
+ doc.close()
130
+
131
+ source_pdf_hash = hash_file(dest_pdf_path)
132
+ manifest = Manifest(
133
+ version=1,
134
+ sourcePdf=dest_pdf_path.name,
135
+ sourcePdfHash=source_pdf_hash,
136
+ pageCount=page_count,
137
+ images=image_entries,
138
+ updatedAt=datetime.now(timezone.utc).isoformat(),
139
+ filenameTemplate=template,
140
+ )
141
+ write_manifest(output_folder, manifest)
142
+
143
+ logger.info(f"Split complete: {page_count} page(s)", output_folder=str(output_folder))
144
+ return SplitResult(
145
+ pdf=str(dest_pdf_path),
146
+ output_folder=str(output_folder),
147
+ page_count=page_count,
148
+ images=images,
149
+ skipped=False,
150
+ )
@@ -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,15 @@
1
+ pdfslice_py/__init__.py,sha256=G-QH1Xiq_dM5rnFR978Sv2Ceoe3KYl9q66qIWctiwwg,177
2
+ pdfslice_py/cli.py,sha256=_1--jOPg3SBLB1XKc4qmo-zxV_9PzEybKTEJzhJKewg,3817
3
+ pdfslice_py/discover.py,sha256=OJtApa_ZxC37yMtqD52j2YMC_rp4rAU9BmBA4L0ROrQ,2468
4
+ pdfslice_py/filename_template.py,sha256=i3c3W07t54CttFFDc0xOlCLqdrFMdi9U-rsEQblWJp4,1660
5
+ pdfslice_py/gather.py,sha256=ryniS7Gqm1W1dOH6ARWVkfKxu85T7hPSFJa5gMfea_4,8026
6
+ pdfslice_py/hash.py,sha256=eA9YRSyiWys-tAt2XvfjG5lfadoL1q0WJm1Vf_b8gNo,449
7
+ pdfslice_py/logger.py,sha256=mq1LqU_pwzMa3ywmU4W76Jz7Lz3__8lUiigWcp8pklA,2642
8
+ pdfslice_py/manifest.py,sha256=50Ck1QIO5HyExszZT5pGqojB-ZZIPSNhn24QDSpe90c,2457
9
+ pdfslice_py/split.py,sha256=jUJOL-JoVKo5PRyGkgSTUEEv4NdOYvw7WnEUPvIxqCQ,4729
10
+ pdfslice_py-1.0.0.dist-info/licenses/LICENSE.md,sha256=6kiYIyoUFr5c3ZomHtS80ceLoDxDYl8MR-3JN1TqGNU,2313
11
+ pdfslice_py-1.0.0.dist-info/METADATA,sha256=roXnMhq94mHeRc9ZLSTe_mVZ7__7BIVdxiK2DiEETRo,3212
12
+ pdfslice_py-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ pdfslice_py-1.0.0.dist-info/entry_points.txt,sha256=GSO-zW8KjyBZnVO-Pehi5u7IdNhkF4jW48-h2vZckRo,49
14
+ pdfslice_py-1.0.0.dist-info/top_level.txt,sha256=_i8YbxJ8q7ojzCHjzJs1J2ggAg2Yr4hNVEMZqqBBi04,12
15
+ pdfslice_py-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
+ pdfslice = pdfslice_py.cli:cli
@@ -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 @@
1
+ pdfslice_py