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.
- pdfslice_py/__init__.py +6 -0
- pdfslice_py/cli.py +130 -0
- pdfslice_py/discover.py +67 -0
- pdfslice_py/filename_template.py +55 -0
- pdfslice_py/gather.py +254 -0
- pdfslice_py/hash.py +17 -0
- pdfslice_py/logger.py +77 -0
- pdfslice_py/manifest.py +76 -0
- pdfslice_py/split.py +150 -0
- pdfslice_py-1.0.0.dist-info/METADATA +69 -0
- pdfslice_py-1.0.0.dist-info/RECORD +15 -0
- pdfslice_py-1.0.0.dist-info/WHEEL +5 -0
- pdfslice_py-1.0.0.dist-info/entry_points.txt +2 -0
- pdfslice_py-1.0.0.dist-info/licenses/LICENSE.md +52 -0
- pdfslice_py-1.0.0.dist-info/top_level.txt +1 -0
pdfslice_py/__init__.py
ADDED
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()
|
pdfslice_py/discover.py
ADDED
|
@@ -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")
|
pdfslice_py/manifest.py
ADDED
|
@@ -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,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
|