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 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()