modpdf 0.1.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.
- modpdf/__init__.py +17 -0
- modpdf/cli.py +567 -0
- modpdf/document.py +159 -0
- modpdf/gui/__init__.py +21 -0
- modpdf/gui/app.py +57 -0
- modpdf/gui/grid.py +180 -0
- modpdf/gui/icons.py +172 -0
- modpdf/gui/session.py +160 -0
- modpdf/gui/settings.py +79 -0
- modpdf/gui/theme.py +237 -0
- modpdf/gui/thumbnails.py +123 -0
- modpdf/gui/widgets.py +401 -0
- modpdf/gui/window.py +1572 -0
- modpdf/gui/workers.py +124 -0
- modpdf/inspection.py +335 -0
- modpdf/ops/__init__.py +0 -0
- modpdf/ops/compress.py +1113 -0
- modpdf/ops/merge.py +45 -0
- modpdf/ops/outline.py +200 -0
- modpdf/ops/sanitize.py +213 -0
- modpdf/ops/select.py +47 -0
- modpdf/ops/split.py +64 -0
- modpdf/pagespec.py +105 -0
- modpdf/pdfium_lock.py +27 -0
- modpdf/security/__init__.py +0 -0
- modpdf/security/fs.py +284 -0
- modpdf/security/limits.py +76 -0
- modpdf/security/netguard.py +121 -0
- modpdf/security/secrets.py +71 -0
- modpdf/tasks.py +246 -0
- modpdf/verify.py +248 -0
- modpdf-0.1.0.dist-info/METADATA +292 -0
- modpdf-0.1.0.dist-info/RECORD +37 -0
- modpdf-0.1.0.dist-info/WHEEL +4 -0
- modpdf-0.1.0.dist-info/entry_points.txt +3 -0
- modpdf-0.1.0.dist-info/licenses/LICENSE +202 -0
- modpdf-0.1.0.dist-info/licenses/NOTICE +29 -0
modpdf/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""ModPDF — split, merge, reorder and compress PDFs entirely on your own machine.
|
|
2
|
+
|
|
3
|
+
Nothing in this package opens a network connection. See `modpdf.security.netguard`
|
|
4
|
+
for the runtime enforcement of that promise, and THREAT_MODEL.md for what it does
|
|
5
|
+
and does not protect against.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
__version__ = version("modpdf")
|
|
14
|
+
except PackageNotFoundError: # source checkout without an install
|
|
15
|
+
__version__ = "0.0.0+unknown"
|
|
16
|
+
|
|
17
|
+
__all__ = ["__version__"]
|
modpdf/cli.py
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
"""The `modpdf` command line.
|
|
2
|
+
|
|
3
|
+
This layer does three things and nothing else: turn arguments into the values
|
|
4
|
+
an operation wants, call it, and report what happened. The operations themselves
|
|
5
|
+
live in `modpdf.tasks`, which the desktop app calls too — anything the GUI would
|
|
6
|
+
also need belongs there rather than here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import textwrap
|
|
12
|
+
from collections.abc import Callable, Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from dataclasses import asdict
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Annotated, TypeVar
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
from modpdf import __version__, tasks
|
|
22
|
+
from modpdf.document import DocumentError, EncryptedDocumentError
|
|
23
|
+
from modpdf.inspection import Inspection
|
|
24
|
+
from modpdf.ops.compress import DEFAULT_LEVEL, CompressReport, Level, Mode
|
|
25
|
+
from modpdf.ops.sanitize import SanitizeReport
|
|
26
|
+
from modpdf.ops.split import Piece, plan_pieces
|
|
27
|
+
from modpdf.pagespec import PageSpecError, parse_pagespec, parse_pagespec_groups
|
|
28
|
+
from modpdf.security import netguard, secrets
|
|
29
|
+
from modpdf.security.fs import FileSystemError, synced_location
|
|
30
|
+
from modpdf.security.limits import LimitExceededError
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
name="modpdf",
|
|
34
|
+
help="Split, merge, reorder and compress PDFs on your own machine.",
|
|
35
|
+
no_args_is_help=True,
|
|
36
|
+
add_completion=False,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
T = TypeVar("T")
|
|
40
|
+
|
|
41
|
+
out = Console()
|
|
42
|
+
err = Console(stderr=True)
|
|
43
|
+
|
|
44
|
+
# Everything a user can do wrong, as opposed to everything that can go wrong.
|
|
45
|
+
# These get a one-line message; anything else keeps its traceback, because an
|
|
46
|
+
# unexpected failure in a tool like this is a bug we want reported in full.
|
|
47
|
+
USER_ERRORS = (
|
|
48
|
+
PageSpecError,
|
|
49
|
+
DocumentError,
|
|
50
|
+
FileSystemError,
|
|
51
|
+
LimitExceededError,
|
|
52
|
+
ValueError,
|
|
53
|
+
IndexError,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextmanager
|
|
58
|
+
def reporting() -> Iterator[None]:
|
|
59
|
+
try:
|
|
60
|
+
yield
|
|
61
|
+
except USER_ERRORS as exc:
|
|
62
|
+
err.print(f"[red]error:[/red] {exc}")
|
|
63
|
+
raise typer.Exit(1) from None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def damage_reporter(name: str) -> Callable[[list[str]], None]:
|
|
67
|
+
"""Tell the user their document was broken and had to be repaired.
|
|
68
|
+
|
|
69
|
+
QPDF repairs a damaged PDF quietly and usually does a good job, but a
|
|
70
|
+
recovered file can have lost pages or content. Processing one without
|
|
71
|
+
saying so would mean handing back a document that is subtly not what the
|
|
72
|
+
user thinks it is.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def report(repairs: list[str]) -> None:
|
|
76
|
+
count = len(repairs)
|
|
77
|
+
err.print(
|
|
78
|
+
f"[yellow]warning:[/yellow] {name} is damaged. It was repaired well "
|
|
79
|
+
f"enough to read, but content may be missing or altered "
|
|
80
|
+
f"({count} issue{'s' if count != 1 else ''})."
|
|
81
|
+
)
|
|
82
|
+
for detail in repairs[:2]:
|
|
83
|
+
err.print(f" [dim]{detail}[/dim]")
|
|
84
|
+
if count > 2:
|
|
85
|
+
err.print(f" [dim]...and {count - 2} more[/dim]")
|
|
86
|
+
|
|
87
|
+
return report
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def with_password(source: Path, use_stdin: bool, work: Callable[[str | None], T]) -> T:
|
|
91
|
+
"""Run `work` with a password, asking for one only if it turns out to be needed.
|
|
92
|
+
|
|
93
|
+
Trying first and prompting second means an unencrypted document never asks
|
|
94
|
+
anything, and an encrypted one asks once, at the moment it matters. The whole
|
|
95
|
+
task is retried rather than the open alone, because a task writes nothing
|
|
96
|
+
until it succeeds — so a retry cannot leave half a document behind.
|
|
97
|
+
"""
|
|
98
|
+
password = secrets.resolve(use_stdin=use_stdin)
|
|
99
|
+
try:
|
|
100
|
+
return work(password)
|
|
101
|
+
except EncryptedDocumentError:
|
|
102
|
+
if password is not None:
|
|
103
|
+
raise
|
|
104
|
+
entered = secrets.ask(source.name)
|
|
105
|
+
if entered is None:
|
|
106
|
+
raise
|
|
107
|
+
return work(entered)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def version_callback(requested: bool) -> None:
|
|
111
|
+
if requested:
|
|
112
|
+
out.print(f"modpdf {__version__}")
|
|
113
|
+
raise typer.Exit
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.callback()
|
|
117
|
+
def main_options(
|
|
118
|
+
version: Annotated[
|
|
119
|
+
bool,
|
|
120
|
+
typer.Option(
|
|
121
|
+
"--version", callback=version_callback, is_eager=True, help="Show the version and exit."
|
|
122
|
+
),
|
|
123
|
+
] = False,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""ModPDF works entirely offline. It has no network code and blocks its own."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def warn_if_synced(destination: Path) -> None:
|
|
129
|
+
"""Say so if the output is about to land in a cloud-synced folder.
|
|
130
|
+
|
|
131
|
+
ModPDF does not upload anything, but a file written into a Dropbox folder is
|
|
132
|
+
uploaded within seconds all the same, and the user has no reason to think
|
|
133
|
+
about that while typing an output path. We still write the file — it is
|
|
134
|
+
their machine and their decision — we just decline to let them believe
|
|
135
|
+
something that is not true. Written to stderr so --json output stays clean.
|
|
136
|
+
"""
|
|
137
|
+
found = synced_location(destination)
|
|
138
|
+
if found is None:
|
|
139
|
+
return
|
|
140
|
+
err.print(
|
|
141
|
+
f"[yellow]note:[/yellow] this writes into your {found.service} folder, "
|
|
142
|
+
f"so {found.service} will upload it."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command()
|
|
147
|
+
def split(
|
|
148
|
+
source: Annotated[Path, typer.Argument(help="The PDF to split.")],
|
|
149
|
+
output_dir: Annotated[Path, typer.Option("--out", "-o", help="Directory for the pieces.")],
|
|
150
|
+
pages: Annotated[
|
|
151
|
+
str,
|
|
152
|
+
typer.Option(
|
|
153
|
+
"--pages",
|
|
154
|
+
help="Page groups; each comma-separated group becomes one file, e.g. 1-3,7,12-",
|
|
155
|
+
),
|
|
156
|
+
],
|
|
157
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite existing files.")] = False,
|
|
158
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would be written.")] = False,
|
|
159
|
+
password_stdin: Annotated[
|
|
160
|
+
bool, typer.Option("--password-stdin", help="Read the document password from stdin.")
|
|
161
|
+
] = False,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Split one PDF into several."""
|
|
164
|
+
with reporting():
|
|
165
|
+
|
|
166
|
+
def plan_and_split(password: str | None) -> tuple[int, list[Piece]]:
|
|
167
|
+
count = tasks.page_count(source, password=password)
|
|
168
|
+
groups = parse_pagespec_groups(pages, count)
|
|
169
|
+
pieces = plan_pieces(source.stem, groups)
|
|
170
|
+
if dry_run:
|
|
171
|
+
return count, pieces
|
|
172
|
+
|
|
173
|
+
warn_if_synced(output_dir)
|
|
174
|
+
tasks.split_document(
|
|
175
|
+
source,
|
|
176
|
+
pieces,
|
|
177
|
+
output_dir,
|
|
178
|
+
password=password,
|
|
179
|
+
overwrite=force,
|
|
180
|
+
on_damage=damage_reporter(source.name),
|
|
181
|
+
)
|
|
182
|
+
return count, pieces
|
|
183
|
+
|
|
184
|
+
count, pieces = with_password(source, password_stdin, plan_and_split)
|
|
185
|
+
|
|
186
|
+
if dry_run:
|
|
187
|
+
_preview(pieces, output_dir, count)
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
out.print(f"{count} pages → {len(pieces)} files in {output_dir}")
|
|
191
|
+
for piece in pieces:
|
|
192
|
+
out.print(f" {piece.filename} [dim]{piece.page_count} pages[/dim]")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@app.command()
|
|
196
|
+
def merge(
|
|
197
|
+
sources: Annotated[list[Path], typer.Argument(help="PDFs to join, in order.")],
|
|
198
|
+
output: Annotated[Path, typer.Option("--out", "-o", help="The merged PDF.")],
|
|
199
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
|
|
200
|
+
password_stdin: Annotated[
|
|
201
|
+
bool,
|
|
202
|
+
typer.Option(
|
|
203
|
+
"--password-stdin",
|
|
204
|
+
help="Read the document password from stdin; used for every input file.",
|
|
205
|
+
),
|
|
206
|
+
] = False,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Join several PDFs into one, in the order given."""
|
|
209
|
+
with reporting():
|
|
210
|
+
if len(sources) < 2:
|
|
211
|
+
raise ValueError("merging needs at least two files")
|
|
212
|
+
|
|
213
|
+
warn_if_synced(output)
|
|
214
|
+
|
|
215
|
+
def do_merge(password: str | None) -> list[int]:
|
|
216
|
+
_, counts = tasks.merge_files(
|
|
217
|
+
sources,
|
|
218
|
+
output,
|
|
219
|
+
password=password,
|
|
220
|
+
overwrite=force,
|
|
221
|
+
on_damage=damage_reporter(sources[0].name),
|
|
222
|
+
)
|
|
223
|
+
return counts
|
|
224
|
+
|
|
225
|
+
counts = with_password(sources[0], password_stdin, do_merge)
|
|
226
|
+
summary = " + ".join(str(n) for n in counts)
|
|
227
|
+
total = sum(counts)
|
|
228
|
+
out.print(f"{len(sources)} files ({summary} pages) → {output} [dim]{total} pages[/dim]")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@app.command()
|
|
232
|
+
def reorder(
|
|
233
|
+
source: Annotated[Path, typer.Argument(help="The PDF to reorder.")],
|
|
234
|
+
output: Annotated[Path, typer.Option("--out", "-o", help="The rearranged PDF.")],
|
|
235
|
+
order: Annotated[str, typer.Option("--order", help="The new page order, e.g. 3,1,2,5-8")],
|
|
236
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
|
|
237
|
+
password_stdin: Annotated[
|
|
238
|
+
bool, typer.Option("--password-stdin", help="Read the document password from stdin.")
|
|
239
|
+
] = False,
|
|
240
|
+
) -> None:
|
|
241
|
+
"""Rearrange, select or duplicate pages.
|
|
242
|
+
|
|
243
|
+
The output contains exactly the pages listed, in that order, so leaving a
|
|
244
|
+
page out of --order leaves it out of the document.
|
|
245
|
+
"""
|
|
246
|
+
with reporting():
|
|
247
|
+
|
|
248
|
+
def do_reorder(password: str | None) -> tuple[int, list[int]]:
|
|
249
|
+
original = tasks.page_count(source, password=password)
|
|
250
|
+
indices = parse_pagespec(order, original)
|
|
251
|
+
warn_if_synced(output)
|
|
252
|
+
tasks.extract_pages(
|
|
253
|
+
source,
|
|
254
|
+
indices,
|
|
255
|
+
output,
|
|
256
|
+
password=password,
|
|
257
|
+
overwrite=force,
|
|
258
|
+
on_damage=damage_reporter(source.name),
|
|
259
|
+
)
|
|
260
|
+
return original, indices
|
|
261
|
+
|
|
262
|
+
original, indices = with_password(source, password_stdin, do_reorder)
|
|
263
|
+
dropped = original - len(set(indices))
|
|
264
|
+
note = f" [yellow]({dropped} pages dropped)[/yellow]" if dropped else ""
|
|
265
|
+
out.print(f"{original} pages → {output} [dim]{len(indices)} pages[/dim]{note}")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@app.command()
|
|
269
|
+
def inspect(
|
|
270
|
+
source: Annotated[Path, typer.Argument(help="The PDF to examine.")],
|
|
271
|
+
as_json: Annotated[
|
|
272
|
+
bool, typer.Option("--json", help="Emit machine-readable output instead.")
|
|
273
|
+
] = False,
|
|
274
|
+
password_stdin: Annotated[
|
|
275
|
+
bool, typer.Option("--password-stdin", help="Read the document password from stdin.")
|
|
276
|
+
] = False,
|
|
277
|
+
) -> None:
|
|
278
|
+
"""Report what is actually inside a PDF. Changes nothing.
|
|
279
|
+
|
|
280
|
+
Answers the question you should ask before forwarding a document: what is
|
|
281
|
+
in here besides the pages I can see?
|
|
282
|
+
"""
|
|
283
|
+
with reporting():
|
|
284
|
+
found = with_password(
|
|
285
|
+
source,
|
|
286
|
+
password_stdin,
|
|
287
|
+
lambda password: tasks.inspect_file(source, password=password),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if as_json:
|
|
291
|
+
out.print_json(data=_as_dict(found))
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
_print_inspection(found)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _as_dict(found: Inspection) -> dict[str, object]:
|
|
298
|
+
payload = asdict(found)
|
|
299
|
+
payload["path"] = str(found.path)
|
|
300
|
+
payload["concerns"] = [asdict(concern) for concern in found.concerns]
|
|
301
|
+
return payload
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _print_inspection(found: Inspection) -> None:
|
|
305
|
+
encryption = "encrypted" if found.encrypted else "not encrypted"
|
|
306
|
+
out.print(f"[bold]{found.path.name}[/bold]")
|
|
307
|
+
out.print(
|
|
308
|
+
f" {found.page_count} pages · {_human_size(found.size_bytes)} · "
|
|
309
|
+
f"PDF {found.pdf_version} · {encryption}"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
concerns = found.concerns
|
|
313
|
+
if not concerns:
|
|
314
|
+
out.print("\n [green]Nothing notable.[/green] No scripts, no embedded files, no")
|
|
315
|
+
out.print(" automatic actions, and only one revision in the file.")
|
|
316
|
+
else:
|
|
317
|
+
count = len(concerns)
|
|
318
|
+
out.print(f"\n {count} thing{'s' if count != 1 else ''} worth knowing:\n")
|
|
319
|
+
# Wrapped by hand rather than by the console, so continuation lines stay
|
|
320
|
+
# indented under their heading instead of running back to column 0.
|
|
321
|
+
width = max(out.width - 6, 40)
|
|
322
|
+
for concern in concerns:
|
|
323
|
+
heading = f"{concern.label} — {concern.detail}"
|
|
324
|
+
head, *rest = textwrap.wrap(heading, width=width) or [heading]
|
|
325
|
+
out.print(f" [yellow]{head}[/yellow]")
|
|
326
|
+
for line in rest:
|
|
327
|
+
out.print(f" [yellow]{line}[/yellow]")
|
|
328
|
+
for line in textwrap.wrap(concern.why, width=width):
|
|
329
|
+
out.print(f" [dim]{line}[/dim]")
|
|
330
|
+
|
|
331
|
+
if found.permissions:
|
|
332
|
+
allowed = sorted(name for name, ok in found.permissions.items() if ok)
|
|
333
|
+
denied = sorted(name for name, ok in found.permissions.items() if not ok)
|
|
334
|
+
out.print("\n [bold]Permissions[/bold]")
|
|
335
|
+
out.print(
|
|
336
|
+
" [dim]These are requests, not enforcement. Any reader is free to ignore them,[/dim]"
|
|
337
|
+
)
|
|
338
|
+
out.print(" [dim]and many do.[/dim]")
|
|
339
|
+
for heading, names in (("allowed", allowed), ("denied ", denied)):
|
|
340
|
+
joined = ", ".join(names) or "none"
|
|
341
|
+
wrapped = textwrap.wrap(joined, width=max(out.width - 14, 40))
|
|
342
|
+
out.print(f" {heading}: {wrapped[0] if wrapped else 'none'}")
|
|
343
|
+
for line in wrapped[1:]:
|
|
344
|
+
out.print(f" {line}")
|
|
345
|
+
|
|
346
|
+
fonts = f"{len(found.fonts)} font{'s' if len(found.fonts) != 1 else ''}"
|
|
347
|
+
images = f"{found.image_count} image{'s' if found.image_count != 1 else ''}"
|
|
348
|
+
out.print(f"\n [dim]Contents: {images}, {fonts}[/dim]")
|
|
349
|
+
if found.fonts:
|
|
350
|
+
out.print(f" [dim]Fonts: {', '.join(found.fonts[:6])}[/dim]")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _human_size(count: int) -> str:
|
|
354
|
+
size = float(count)
|
|
355
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
356
|
+
if size < 1024 or unit == "GB":
|
|
357
|
+
precision = 0 if unit == "B" else 1
|
|
358
|
+
return f"{size:.{precision}f} {unit}"
|
|
359
|
+
size /= 1024
|
|
360
|
+
return f"{size:.1f} GB"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@app.command()
|
|
364
|
+
def sanitize(
|
|
365
|
+
source: Annotated[Path, typer.Argument(help="The PDF to clean.")],
|
|
366
|
+
output: Annotated[Path, typer.Option("--out", "-o", help="The cleaned PDF.")],
|
|
367
|
+
keep_metadata: Annotated[
|
|
368
|
+
bool,
|
|
369
|
+
typer.Option("--keep-metadata", help="Keep the title, author and dates."),
|
|
370
|
+
] = False,
|
|
371
|
+
strip_links: Annotated[
|
|
372
|
+
bool,
|
|
373
|
+
typer.Option("--strip-links", help="Also remove plain web links, not just actions."),
|
|
374
|
+
] = False,
|
|
375
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
|
|
376
|
+
password_stdin: Annotated[
|
|
377
|
+
bool, typer.Option("--password-stdin", help="Read the document password from stdin.")
|
|
378
|
+
] = False,
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Remove active content, hidden attachments and earlier revisions.
|
|
381
|
+
|
|
382
|
+
Keeps the pages and their text. This is not redaction: anything visible on a
|
|
383
|
+
page stays there, and a black rectangle drawn over text does not remove it.
|
|
384
|
+
"""
|
|
385
|
+
with reporting():
|
|
386
|
+
warn_if_synced(output)
|
|
387
|
+
|
|
388
|
+
def do_sanitize(password: str | None) -> tuple[int, SanitizeReport]:
|
|
389
|
+
pages = tasks.page_count(source, password=password)
|
|
390
|
+
_, report = tasks.sanitize_file(
|
|
391
|
+
source,
|
|
392
|
+
output,
|
|
393
|
+
keep_metadata=keep_metadata,
|
|
394
|
+
strip_links=strip_links,
|
|
395
|
+
password=password,
|
|
396
|
+
overwrite=force,
|
|
397
|
+
on_damage=damage_reporter(source.name),
|
|
398
|
+
)
|
|
399
|
+
return pages, report
|
|
400
|
+
|
|
401
|
+
pages, report = with_password(source, password_stdin, do_sanitize)
|
|
402
|
+
_print_sanitize_report(report, output, pages)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _print_sanitize_report(report: SanitizeReport, output: Path, pages: int) -> None:
|
|
406
|
+
out.print(f"{output} [dim]{pages} pages[/dim]")
|
|
407
|
+
|
|
408
|
+
if not report.anything_removed:
|
|
409
|
+
out.print(" [green]nothing to remove[/green] — this document was already clean")
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
lines: list[str] = []
|
|
413
|
+
if report.javascript:
|
|
414
|
+
lines.append(f"JavaScript ({report.javascript})")
|
|
415
|
+
if report.open_action:
|
|
416
|
+
lines.append("automatic open action")
|
|
417
|
+
for kind, count in sorted(report.actions_removed.items()):
|
|
418
|
+
lines.append(f"{kind.lstrip('/')} actions ({count})")
|
|
419
|
+
if report.page_actions_removed:
|
|
420
|
+
lines.append(f"page trigger actions ({report.page_actions_removed})")
|
|
421
|
+
if report.embedded_files:
|
|
422
|
+
lines.append(f"embedded files ({report.embedded_files})")
|
|
423
|
+
if report.xfa:
|
|
424
|
+
lines.append("XFA form")
|
|
425
|
+
if report.revisions_collapsed:
|
|
426
|
+
lines.append(f"earlier revisions ({report.revisions_collapsed})")
|
|
427
|
+
if report.metadata_stripped:
|
|
428
|
+
lines.append("metadata")
|
|
429
|
+
|
|
430
|
+
out.print(" removed: " + ", ".join(lines))
|
|
431
|
+
out.print(" [dim]Pages and text are unchanged. This is not redaction:[/dim]")
|
|
432
|
+
out.print(" [dim]anything visible on a page is still there.[/dim]")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@app.command()
|
|
436
|
+
def compress(
|
|
437
|
+
source: Annotated[Path, typer.Argument(help="The PDF to compress.")],
|
|
438
|
+
output: Annotated[Path, typer.Option("--out", "-o", help="The compressed PDF.")],
|
|
439
|
+
lossless: Annotated[
|
|
440
|
+
bool,
|
|
441
|
+
typer.Option("--lossless", help="Structural cleanup only. Not one pixel or glyph changes."),
|
|
442
|
+
] = False,
|
|
443
|
+
level: Annotated[
|
|
444
|
+
Level,
|
|
445
|
+
typer.Option(
|
|
446
|
+
"--level",
|
|
447
|
+
help="How hard to compress: 'low' (best quality), 'balanced' (default), "
|
|
448
|
+
"or 'high' (smallest file; photos may look visibly softer). "
|
|
449
|
+
"Meaningless with --lossless.",
|
|
450
|
+
),
|
|
451
|
+
] = DEFAULT_LEVEL,
|
|
452
|
+
no_verify: Annotated[
|
|
453
|
+
bool,
|
|
454
|
+
typer.Option(
|
|
455
|
+
"--no-verify",
|
|
456
|
+
help="Skip comparing the result against the original. Faster; not recommended.",
|
|
457
|
+
),
|
|
458
|
+
] = False,
|
|
459
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
|
|
460
|
+
password_stdin: Annotated[
|
|
461
|
+
bool, typer.Option("--password-stdin", help="Read the document password from stdin.")
|
|
462
|
+
] = False,
|
|
463
|
+
) -> None:
|
|
464
|
+
"""Shrink a PDF. Oversized images are downsampled; text and vector content
|
|
465
|
+
are never touched.
|
|
466
|
+
|
|
467
|
+
--level picks how hard to push that downsampling: 'low' barely touches
|
|
468
|
+
anything, 'balanced' (the default) is a sensible middle ground, and 'high'
|
|
469
|
+
trades some visible quality for the smallest file, closer to what other
|
|
470
|
+
tools call "extreme" compression.
|
|
471
|
+
|
|
472
|
+
A text-only document will not shrink much either way — there is no image
|
|
473
|
+
data to recompress, and the structural cleanup this still does is usually
|
|
474
|
+
a small fraction of the file. Real savings come from oversized scanned
|
|
475
|
+
images.
|
|
476
|
+
|
|
477
|
+
Every compressed page is checked against the original before being
|
|
478
|
+
accepted. If any page looks different enough to matter, the whole
|
|
479
|
+
document falls back to the lossless result instead, and the report says
|
|
480
|
+
so — the worst case is a file smaller than hoped for, never one that
|
|
481
|
+
looks worse. ('high' accepts more visible difference before falling back;
|
|
482
|
+
that is its whole point.)
|
|
483
|
+
"""
|
|
484
|
+
with reporting():
|
|
485
|
+
warn_if_synced(output)
|
|
486
|
+
mode: Mode = "lossless" if lossless else "visual"
|
|
487
|
+
|
|
488
|
+
def do_compress(password: str | None) -> CompressReport:
|
|
489
|
+
_, report = tasks.compress_file(
|
|
490
|
+
source,
|
|
491
|
+
output,
|
|
492
|
+
mode=mode,
|
|
493
|
+
level=level,
|
|
494
|
+
verify=not no_verify,
|
|
495
|
+
password=password,
|
|
496
|
+
overwrite=force,
|
|
497
|
+
on_damage=damage_reporter(source.name),
|
|
498
|
+
)
|
|
499
|
+
return report
|
|
500
|
+
|
|
501
|
+
report = with_password(source, password_stdin, do_compress)
|
|
502
|
+
_print_compress_report(report, output, level)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _print_compress_report(report: CompressReport, output: Path, level: Level) -> None:
|
|
506
|
+
out.print(
|
|
507
|
+
f"{output.name} {_human_size(report.before_bytes)} → "
|
|
508
|
+
f"{_human_size(report.after_bytes)} [dim]({report.savings_ratio * 100:.0f}% smaller)[/dim]"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
if report.already_optimal:
|
|
512
|
+
out.print(" [green]already optimal[/green] — nothing here was worth rewriting")
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
images = report.images
|
|
516
|
+
if images.recompressed:
|
|
517
|
+
out.print(
|
|
518
|
+
f" images {images.recompressed} recompressed, {images.left_alone} left alone"
|
|
519
|
+
f" {_human_size(images.bytes_before)} → {_human_size(images.bytes_after)}"
|
|
520
|
+
)
|
|
521
|
+
elif images.left_alone:
|
|
522
|
+
out.print(f" images {images.left_alone} already at or below the target, left alone")
|
|
523
|
+
|
|
524
|
+
if report.pages_flattened:
|
|
525
|
+
pages_word = "page" if report.pages_flattened == 1 else "pages"
|
|
526
|
+
out.print(
|
|
527
|
+
f" vector {report.pages_flattened} {pages_word} of complex vector art "
|
|
528
|
+
"flattened to an image"
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
if report.fell_back:
|
|
532
|
+
out.print(f" [yellow]quality[/yellow] fell back to lossless — {report.fallback_reason}")
|
|
533
|
+
elif report.verify_result is not None and report.mode_used == "visual":
|
|
534
|
+
v = report.verify_result
|
|
535
|
+
out.print(
|
|
536
|
+
f" quality text identical · largest visible difference "
|
|
537
|
+
f"{v.differing_fraction * 100:.1f}% of one page [green]PASS[/green]"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
if level == "high" and report.mode_used == "visual":
|
|
541
|
+
note = (
|
|
542
|
+
"maximum compression: image quality was reduced on purpose to shrink the file further"
|
|
543
|
+
)
|
|
544
|
+
if report.pages_flattened:
|
|
545
|
+
note += "; text stays selectable even on a flattened page — only its vector art was"
|
|
546
|
+
out.print(f" [yellow]note[/yellow] {note}")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _preview(pieces: list[Piece], output_dir: Path, page_count: int) -> None:
|
|
550
|
+
out.print("[dim]dry run — nothing written[/dim]")
|
|
551
|
+
out.print(f"{page_count} pages → {len(pieces)} files in {output_dir}")
|
|
552
|
+
for piece in pieces:
|
|
553
|
+
first, last = piece.indices[0] + 1, piece.indices[-1] + 1
|
|
554
|
+
span = f"page {first}" if first == last else f"pages {first}-{last}"
|
|
555
|
+
out.print(f" {piece.filename} [dim]{span}[/dim]")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def main() -> None:
|
|
559
|
+
# Before anything is parsed and before any file is opened, take away this
|
|
560
|
+
# process's ability to reach the network. There is deliberately no flag to
|
|
561
|
+
# skip this; see modpdf.security.netguard for what it does and does not buy.
|
|
562
|
+
netguard.install()
|
|
563
|
+
app()
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
if __name__ == "__main__":
|
|
567
|
+
main()
|