pdfxz 0.2.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.
- pdfxz/__init__.py +7 -0
- pdfxz/__main__.py +6 -0
- pdfxz/app.py +629 -0
- pdfxz/app.tcss +177 -0
- pdfxz/cli.py +284 -0
- pdfxz/compressor.py +299 -0
- pdfxz/formatting.py +95 -0
- pdfxz/models.py +156 -0
- pdfxz/profiles.py +138 -0
- pdfxz/scanner.py +82 -0
- pdfxz/utils.py +200 -0
- pdfxz/workers.py +108 -0
- pdfxz-0.2.0.dist-info/METADATA +237 -0
- pdfxz-0.2.0.dist-info/RECORD +17 -0
- pdfxz-0.2.0.dist-info/WHEEL +4 -0
- pdfxz-0.2.0.dist-info/entry_points.txt +2 -0
- pdfxz-0.2.0.dist-info/licenses/LICENSE +21 -0
pdfxz/__init__.py
ADDED
pdfxz/__main__.py
ADDED
pdfxz/app.py
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
"""The interactive Textual TUI.
|
|
2
|
+
|
|
3
|
+
Three screens, pushed in sequence: `ConfigScreen` (input/output/quality)
|
|
4
|
+
-> `ProcessingScreen` (live progress, cancellable) -> `ReportScreen`
|
|
5
|
+
(summary + per-file table), plus a `DirectoryPicker` modal used by both
|
|
6
|
+
the input and output fields on `ConfigScreen`. The TUI is a thin
|
|
7
|
+
consumer of `BatchRunner` and `PDFCompressor` - all business logic lives
|
|
8
|
+
in `workers.py` and `compressor.py`, so the CLI and the TUI never
|
|
9
|
+
duplicate it.
|
|
10
|
+
|
|
11
|
+
The TUI intentionally exposes fewer knobs than the CLI (no
|
|
12
|
+
recursive/overwrite/create-directory toggles): directories are always
|
|
13
|
+
scanned recursively, missing output directories are always created, and
|
|
14
|
+
an existing output is never overwritten - a fresh, uniquely-named file
|
|
15
|
+
is written alongside it instead (see `utils.unique_output_path`). The
|
|
16
|
+
CLI is unaffected and keeps its explicit flags for scripting.
|
|
17
|
+
|
|
18
|
+
Ghostscript runs inside a Textual thread worker (`@work(thread=True)`),
|
|
19
|
+
never on the UI event loop, so the interface stays responsive and a
|
|
20
|
+
determinate progress bar can be driven by real per-file completion
|
|
21
|
+
events rather than fabricated intra-file percentages.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Iterable
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import ClassVar
|
|
32
|
+
|
|
33
|
+
from textual import work
|
|
34
|
+
from textual.app import App, ComposeResult, SystemCommand
|
|
35
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
36
|
+
from textual.message import Message
|
|
37
|
+
from textual.reactive import reactive
|
|
38
|
+
from textual.screen import ModalScreen, Screen
|
|
39
|
+
from textual.widgets import (
|
|
40
|
+
Button,
|
|
41
|
+
DataTable,
|
|
42
|
+
DirectoryTree,
|
|
43
|
+
Footer,
|
|
44
|
+
Header,
|
|
45
|
+
Input,
|
|
46
|
+
Static,
|
|
47
|
+
)
|
|
48
|
+
from textual.widgets.data_table import RowKey
|
|
49
|
+
|
|
50
|
+
from .compressor import PDFCompressor
|
|
51
|
+
from .formatting import (
|
|
52
|
+
format_duration,
|
|
53
|
+
format_mb,
|
|
54
|
+
format_size_change,
|
|
55
|
+
truncate_filename,
|
|
56
|
+
)
|
|
57
|
+
from .models import BatchResult, CompressionResult, CompressionStatus
|
|
58
|
+
from .profiles import DEFAULT_QUALITY, QUALITY_PROFILES, get_profile
|
|
59
|
+
from .scanner import discover_pdfs
|
|
60
|
+
from .utils import plan_batch_outputs, plan_single_output, unique_output_path
|
|
61
|
+
from .workers import BatchJob, BatchRunner
|
|
62
|
+
|
|
63
|
+
logger = logging.getLogger("pdfxz.app")
|
|
64
|
+
|
|
65
|
+
# NOTE ON LOGGING SCOPE: per-file and per-batch compression events are
|
|
66
|
+
# already logged by `compressor.py`/`workers.py`, which the TUI calls into
|
|
67
|
+
# via the same `BatchRunner`/`PDFCompressor` used by the CLI - duplicating
|
|
68
|
+
# those log lines here would just be noise. The handful of `logger` calls
|
|
69
|
+
# in this module cover events the TUI alone knows about (e.g. the user
|
|
70
|
+
# pressing Cancel, or Ghostscript being unavailable before a batch even
|
|
71
|
+
# starts), not compression outcomes themselves.
|
|
72
|
+
|
|
73
|
+
_STATUS_LABELS = {
|
|
74
|
+
CompressionStatus.SUCCESS: "OK",
|
|
75
|
+
CompressionStatus.UNCHANGED: "UNCHANGED",
|
|
76
|
+
CompressionStatus.LARGER: "LARGER",
|
|
77
|
+
CompressionStatus.FAILED: "FAILED",
|
|
78
|
+
CompressionStatus.SKIPPED: "SKIPPED",
|
|
79
|
+
CompressionStatus.CANCELLED: "CANCELLED",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_FILENAME_COLUMN_WIDTH = 30
|
|
83
|
+
|
|
84
|
+
# Letter-spaced wordmark. Terminal cells can't be individually scaled up
|
|
85
|
+
# the way a GUI font can, so "noticeably larger" is achieved through
|
|
86
|
+
# spacing + weight + a background band (see app.tcss) rather than
|
|
87
|
+
# unusual Unicode glyph forms, which render inconsistently (or as tofu
|
|
88
|
+
# boxes) across terminal fonts.
|
|
89
|
+
_TITLE_TEXT = "P D F X Z"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class QualitySlider(Static, can_focus=True):
|
|
93
|
+
"""A compact, volume-control-style widget for picking a compression profile.
|
|
94
|
+
|
|
95
|
+
Backed by the same five `QUALITY_PROFILES` keys used by the CLI's
|
|
96
|
+
`-q/--quality` flag - only the presentation differs.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
|
|
100
|
+
("left", "decrease", "Less compression"),
|
|
101
|
+
("right", "increase", "More compression"),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
KEYS: ClassVar[list[str]] = list(QUALITY_PROFILES.keys())
|
|
105
|
+
|
|
106
|
+
index: reactive[int] = reactive(0)
|
|
107
|
+
|
|
108
|
+
class Changed(Message):
|
|
109
|
+
"""Posted whenever the selected profile changes."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, key: str) -> None:
|
|
112
|
+
self.key = key
|
|
113
|
+
super().__init__()
|
|
114
|
+
|
|
115
|
+
def __init__(self, *, id: str | None = None) -> None:
|
|
116
|
+
super().__init__(id=id)
|
|
117
|
+
self.index = self.KEYS.index(DEFAULT_QUALITY)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def value(self) -> str:
|
|
121
|
+
return self.KEYS[self.index]
|
|
122
|
+
|
|
123
|
+
def on_mount(self) -> None:
|
|
124
|
+
self._render_text()
|
|
125
|
+
|
|
126
|
+
def watch_index(self) -> None:
|
|
127
|
+
self._render_text()
|
|
128
|
+
|
|
129
|
+
def _render_text(self) -> None:
|
|
130
|
+
profile = QUALITY_PROFILES[self.value]
|
|
131
|
+
blocks = "".join(
|
|
132
|
+
"\u25ae" if i <= self.index else "\u25af" for i in range(len(self.KEYS))
|
|
133
|
+
)
|
|
134
|
+
self.update(f"{blocks} {profile.label}")
|
|
135
|
+
|
|
136
|
+
def action_decrease(self) -> None:
|
|
137
|
+
self._set_index(self.index - 1)
|
|
138
|
+
|
|
139
|
+
def action_increase(self) -> None:
|
|
140
|
+
self._set_index(self.index + 1)
|
|
141
|
+
|
|
142
|
+
def _set_index(self, new_index: int) -> None:
|
|
143
|
+
new_index = max(0, min(len(self.KEYS) - 1, new_index))
|
|
144
|
+
if new_index != self.index:
|
|
145
|
+
self.index = new_index
|
|
146
|
+
self.post_message(self.Changed(self.value))
|
|
147
|
+
|
|
148
|
+
def on_click(self, event) -> None:
|
|
149
|
+
self.focus()
|
|
150
|
+
midpoint = self.size.width / 2
|
|
151
|
+
if event.x < midpoint:
|
|
152
|
+
self.action_decrease()
|
|
153
|
+
else:
|
|
154
|
+
self.action_increase()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class DirectoryPicker(ModalScreen[Path | None]):
|
|
158
|
+
"""A file/folder browser built on Textual's built-in `DirectoryTree`.
|
|
159
|
+
|
|
160
|
+
`select_files=True` lets the user pick a `.pdf` file or a folder
|
|
161
|
+
(used for INPUT). `select_files=False` restricts picking to folders
|
|
162
|
+
only (used for OUTPUT, which is always a destination directory - see
|
|
163
|
+
the module docstring for why filenames are handled automatically).
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
|
|
167
|
+
("escape", "cancel", "Cancel"),
|
|
168
|
+
("u", "use_folder", "Use this folder"),
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
def __init__(self, start_path: Path, select_files: bool = True) -> None:
|
|
172
|
+
super().__init__()
|
|
173
|
+
self.start_path = start_path if start_path.is_dir() else start_path.parent
|
|
174
|
+
self.select_files = select_files
|
|
175
|
+
|
|
176
|
+
def compose(self) -> ComposeResult:
|
|
177
|
+
with Vertical(id="picker-body"):
|
|
178
|
+
title = (
|
|
179
|
+
"Select a PDF file or folder"
|
|
180
|
+
if self.select_files
|
|
181
|
+
else "Select a destination folder"
|
|
182
|
+
)
|
|
183
|
+
yield Static(title, id="picker-title")
|
|
184
|
+
yield DirectoryTree(str(self.start_path), id="picker-tree")
|
|
185
|
+
hint = (
|
|
186
|
+
"enter: choose u: use folder esc: cancel"
|
|
187
|
+
if self.select_files
|
|
188
|
+
else "enter: open folder u: use folder esc: cancel"
|
|
189
|
+
)
|
|
190
|
+
yield Static(hint, id="picker-hint")
|
|
191
|
+
with Horizontal(id="picker-actions"):
|
|
192
|
+
yield Button("Use this folder", id="use-folder-button")
|
|
193
|
+
yield Button("Cancel", id="picker-cancel-button")
|
|
194
|
+
|
|
195
|
+
def on_mount(self) -> None:
|
|
196
|
+
self.query_one(DirectoryTree).focus()
|
|
197
|
+
|
|
198
|
+
def on_directory_tree_file_selected(
|
|
199
|
+
self, event: DirectoryTree.FileSelected
|
|
200
|
+
) -> None:
|
|
201
|
+
path = Path(event.path)
|
|
202
|
+
if self.select_files and path.suffix.lower() == ".pdf":
|
|
203
|
+
self.dismiss(path)
|
|
204
|
+
# Non-PDF files are simply not selectable inputs; ignore the event
|
|
205
|
+
# rather than surfacing an error inside a modal.
|
|
206
|
+
|
|
207
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
208
|
+
if event.button.id == "use-folder-button":
|
|
209
|
+
self.action_use_folder()
|
|
210
|
+
elif event.button.id == "picker-cancel-button":
|
|
211
|
+
self.dismiss(None)
|
|
212
|
+
|
|
213
|
+
def action_cancel(self) -> None:
|
|
214
|
+
self.dismiss(None)
|
|
215
|
+
|
|
216
|
+
def action_use_folder(self) -> None:
|
|
217
|
+
tree = self.query_one(DirectoryTree)
|
|
218
|
+
node = tree.cursor_node
|
|
219
|
+
if node is None or node.data is None:
|
|
220
|
+
self.dismiss(self.start_path)
|
|
221
|
+
return
|
|
222
|
+
path = Path(node.data.path)
|
|
223
|
+
if path.is_file():
|
|
224
|
+
path = path.parent
|
|
225
|
+
self.dismiss(path)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class ConfigScreen(Screen):
|
|
229
|
+
"""Screen 1: input, output, and compression settings."""
|
|
230
|
+
|
|
231
|
+
# Textual's App already binds ctrl+q (to its own quit action, hidden
|
|
232
|
+
# from the footer) and ctrl+c (to help_quit) with priority=True, so
|
|
233
|
+
# neither key ever reaches a Screen-level binding of the same name -
|
|
234
|
+
# "q" is used here instead so the shortcut is both functional and
|
|
235
|
+
# visible in the footer.
|
|
236
|
+
BINDINGS: ClassVar[list[tuple[str, str, str]]] = [("q", "quit_app", "Quit")]
|
|
237
|
+
|
|
238
|
+
def compose(self) -> ComposeResult:
|
|
239
|
+
yield Header(show_clock=False)
|
|
240
|
+
with VerticalScroll(id="config-body"):
|
|
241
|
+
yield Static(_TITLE_TEXT, id="title")
|
|
242
|
+
yield Static("PDF Compression Utility", id="subtitle")
|
|
243
|
+
|
|
244
|
+
yield Static("INPUT", classes="field-label")
|
|
245
|
+
with Horizontal(classes="field-row"):
|
|
246
|
+
yield Input(placeholder="PDF file or folder…", id="input-field")
|
|
247
|
+
yield Button(
|
|
248
|
+
"Browse…", id="browse-input-button", classes="browse-button"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
yield Static("OUTPUT", classes="field-label")
|
|
252
|
+
with Horizontal(classes="field-row"):
|
|
253
|
+
yield Input(
|
|
254
|
+
placeholder="Destination folder (optional)…", id="output-field"
|
|
255
|
+
)
|
|
256
|
+
yield Button(
|
|
257
|
+
"Browse…", id="browse-output-button", classes="browse-button"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
yield Static("QUALITY", classes="field-label")
|
|
261
|
+
yield QualitySlider(id="quality-slider")
|
|
262
|
+
|
|
263
|
+
yield Static("", id="validation-message")
|
|
264
|
+
yield Button(
|
|
265
|
+
"Compress PDFs", id="compress-button", variant="primary", disabled=True
|
|
266
|
+
)
|
|
267
|
+
yield Footer()
|
|
268
|
+
|
|
269
|
+
def on_mount(self) -> None:
|
|
270
|
+
self.query_one("#input-field", Input).focus()
|
|
271
|
+
if not PDFCompressor().is_available():
|
|
272
|
+
logger.warning(
|
|
273
|
+
"Ghostscript not found on PATH; compression is disabled until it's installed."
|
|
274
|
+
)
|
|
275
|
+
self.query_one("#validation-message", Static).update(
|
|
276
|
+
"[red]Ghostscript was not found on PATH. Install it before compressing.[/red]"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
280
|
+
self._validate()
|
|
281
|
+
|
|
282
|
+
def _validate(self) -> bool:
|
|
283
|
+
input_text = self.query_one("#input-field", Input).value.strip()
|
|
284
|
+
message = self.query_one("#validation-message", Static)
|
|
285
|
+
button = self.query_one("#compress-button", Button)
|
|
286
|
+
|
|
287
|
+
if not PDFCompressor().is_available():
|
|
288
|
+
button.disabled = True
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
if not input_text:
|
|
292
|
+
message.update("")
|
|
293
|
+
button.disabled = True
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
path = Path(input_text).expanduser()
|
|
297
|
+
if not path.exists():
|
|
298
|
+
message.update(f"[red]Input does not exist: {path}[/red]")
|
|
299
|
+
button.disabled = True
|
|
300
|
+
return False
|
|
301
|
+
if path.is_file() and path.suffix.lower() != ".pdf":
|
|
302
|
+
message.update("[red]Input file must be a .pdf[/red]")
|
|
303
|
+
button.disabled = True
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
message.update("")
|
|
307
|
+
button.disabled = False
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
311
|
+
if event.button.id == "compress-button":
|
|
312
|
+
self._start_compression()
|
|
313
|
+
elif event.button.id == "browse-input-button":
|
|
314
|
+
self._browse_input()
|
|
315
|
+
elif event.button.id == "browse-output-button":
|
|
316
|
+
self._browse_output()
|
|
317
|
+
|
|
318
|
+
def _browse_input(self) -> None:
|
|
319
|
+
current = self.query_one("#input-field", Input).value.strip()
|
|
320
|
+
start = Path(current).expanduser() if current else Path.home()
|
|
321
|
+
if not start.exists():
|
|
322
|
+
start = Path.home()
|
|
323
|
+
self.app.push_screen(
|
|
324
|
+
DirectoryPicker(start, select_files=True), self._on_input_picked
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def _on_input_picked(self, path: Path | None) -> None:
|
|
328
|
+
if path is None:
|
|
329
|
+
return
|
|
330
|
+
self.query_one("#input-field", Input).value = str(path)
|
|
331
|
+
self._validate()
|
|
332
|
+
|
|
333
|
+
def _browse_output(self) -> None:
|
|
334
|
+
current = self.query_one("#output-field", Input).value.strip()
|
|
335
|
+
start = Path(current).expanduser() if current else Path.home()
|
|
336
|
+
if not start.exists():
|
|
337
|
+
start = Path.home()
|
|
338
|
+
self.app.push_screen(
|
|
339
|
+
DirectoryPicker(start, select_files=False), self._on_output_picked
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def _on_output_picked(self, path: Path | None) -> None:
|
|
343
|
+
if path is None:
|
|
344
|
+
return
|
|
345
|
+
self.query_one("#output-field", Input).value = str(path)
|
|
346
|
+
|
|
347
|
+
def _start_compression(self) -> None:
|
|
348
|
+
if not self._validate():
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
input_text = self.query_one("#input-field", Input).value.strip()
|
|
352
|
+
output_text = self.query_one("#output-field", Input).value.strip()
|
|
353
|
+
quality = self.query_one("#quality-slider", QualitySlider).value
|
|
354
|
+
|
|
355
|
+
input_path = Path(input_text).expanduser()
|
|
356
|
+
output_path = Path(output_text).expanduser() if output_text else None
|
|
357
|
+
message = self.query_one("#validation-message", Static)
|
|
358
|
+
|
|
359
|
+
# Directories are always scanned recursively and missing output
|
|
360
|
+
# directories are always created - see the module docstring for
|
|
361
|
+
# why the TUI drops those toggles in favour of these defaults.
|
|
362
|
+
if input_path.is_file():
|
|
363
|
+
target = plan_single_output(input_path, output_path)
|
|
364
|
+
jobs = [
|
|
365
|
+
BatchJob(input_path=input_path, output_path=unique_output_path(target))
|
|
366
|
+
]
|
|
367
|
+
else:
|
|
368
|
+
discovered = discover_pdfs(input_path, recursive=True)
|
|
369
|
+
if not discovered:
|
|
370
|
+
message.update("[red]No PDF files found in that folder.[/red]")
|
|
371
|
+
return
|
|
372
|
+
output_root = output_path or (
|
|
373
|
+
input_path.parent / f"{input_path.name}_compressed"
|
|
374
|
+
)
|
|
375
|
+
mapping = plan_batch_outputs(discovered, input_path, output_root)
|
|
376
|
+
jobs = [
|
|
377
|
+
BatchJob(input_path=src_path, output_path=unique_output_path(dst_path))
|
|
378
|
+
for src_path, dst_path in mapping.items()
|
|
379
|
+
]
|
|
380
|
+
|
|
381
|
+
self.app.push_screen(ProcessingScreen(jobs=jobs, quality=quality))
|
|
382
|
+
|
|
383
|
+
def action_quit_app(self) -> None:
|
|
384
|
+
self.app.exit()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class ProcessingScreen(Screen):
|
|
388
|
+
"""Screen 2: live progress for a running batch. Cancellable."""
|
|
389
|
+
|
|
390
|
+
# Note: ctrl+c is NOT bound here - Textual's App intercepts it first
|
|
391
|
+
# (priority binding to help_quit), so a Screen-level ctrl+c binding
|
|
392
|
+
# would never fire. Escape is the one real, working shortcut, which
|
|
393
|
+
# is why it's the only one advertised (button label, footer, docs).
|
|
394
|
+
BINDINGS: ClassVar[list[tuple[str, str, str]]] = [("escape", "cancel", "Cancel")]
|
|
395
|
+
|
|
396
|
+
def __init__(self, jobs: list[BatchJob], quality: str) -> None:
|
|
397
|
+
super().__init__()
|
|
398
|
+
self.jobs = jobs
|
|
399
|
+
self.quality = quality
|
|
400
|
+
self.cancel_event = threading.Event()
|
|
401
|
+
self._successful = 0
|
|
402
|
+
self._failed = 0
|
|
403
|
+
self._start_time = 0.0
|
|
404
|
+
|
|
405
|
+
def compose(self) -> ComposeResult:
|
|
406
|
+
yield Header(show_clock=False)
|
|
407
|
+
with Vertical(id="processing-body"):
|
|
408
|
+
yield Static("Compressing PDFs", id="processing-title")
|
|
409
|
+
yield Static("-", id="current-file")
|
|
410
|
+
yield Static(f"0 / {len(self.jobs)} files", id="files-counter")
|
|
411
|
+
yield Static("0 succeeded · 0 failed", id="result-counters")
|
|
412
|
+
yield Static("", id="current-result")
|
|
413
|
+
yield Static("Elapsed 00:00", id="elapsed-counter")
|
|
414
|
+
yield Button("Cancel (Esc)", id="cancel-button", variant="error")
|
|
415
|
+
yield Footer()
|
|
416
|
+
|
|
417
|
+
def on_mount(self) -> None:
|
|
418
|
+
self._start_time = time.perf_counter()
|
|
419
|
+
self.set_interval(1.0, self._tick_elapsed)
|
|
420
|
+
self.run_compression()
|
|
421
|
+
|
|
422
|
+
def _tick_elapsed(self) -> None:
|
|
423
|
+
elapsed = time.perf_counter() - self._start_time
|
|
424
|
+
self.query_one("#elapsed-counter", Static).update(
|
|
425
|
+
f"Elapsed {format_duration(elapsed)}"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
@work(thread=True, exclusive=True)
|
|
429
|
+
def run_compression(self) -> None:
|
|
430
|
+
compressor = PDFCompressor()
|
|
431
|
+
runner = BatchRunner(compressor)
|
|
432
|
+
profile = get_profile(self.quality)
|
|
433
|
+
|
|
434
|
+
def on_progress(result: CompressionResult, index: int, total: int) -> None:
|
|
435
|
+
self.app.call_from_thread(self._update_progress, result, index, total)
|
|
436
|
+
|
|
437
|
+
batch = runner.run(
|
|
438
|
+
self.jobs,
|
|
439
|
+
profile,
|
|
440
|
+
# Output paths are pre-uniquified by ConfigScreen, and missing
|
|
441
|
+
# parent directories are always created - see module docstring.
|
|
442
|
+
overwrite=False,
|
|
443
|
+
create_parents=True,
|
|
444
|
+
on_progress=on_progress,
|
|
445
|
+
cancel_event=self.cancel_event,
|
|
446
|
+
)
|
|
447
|
+
self.app.call_from_thread(self._finish, batch)
|
|
448
|
+
|
|
449
|
+
def _update_progress(
|
|
450
|
+
self, result: CompressionResult, index: int, total: int
|
|
451
|
+
) -> None:
|
|
452
|
+
if result.status == CompressionStatus.CANCELLED:
|
|
453
|
+
pass
|
|
454
|
+
elif result.status.is_completed:
|
|
455
|
+
self._successful += 1
|
|
456
|
+
else:
|
|
457
|
+
self._failed += 1
|
|
458
|
+
|
|
459
|
+
file_widget = self.query_one("#current-file", Static)
|
|
460
|
+
display_name = truncate_filename(result.input_path.name, _FILENAME_COLUMN_WIDTH)
|
|
461
|
+
file_widget.update(display_name)
|
|
462
|
+
file_widget.tooltip = str(result.input_path)
|
|
463
|
+
|
|
464
|
+
self.query_one("#files-counter", Static).update(f"{index} / {total} files")
|
|
465
|
+
self.query_one("#result-counters", Static).update(
|
|
466
|
+
f"{self._successful} succeeded · {self._failed} failed"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
result_widget = self.query_one("#current-result", Static)
|
|
470
|
+
if result.status.is_completed:
|
|
471
|
+
change = format_size_change(
|
|
472
|
+
result.original_size, result.compressed_size or 0
|
|
473
|
+
)
|
|
474
|
+
result_widget.update(
|
|
475
|
+
f"{format_mb(result.original_size)} \u2192 {format_mb(result.compressed_size or 0)} ({change})"
|
|
476
|
+
)
|
|
477
|
+
elif result.error:
|
|
478
|
+
result_widget.update(f"[red]{result.error}[/red]")
|
|
479
|
+
|
|
480
|
+
def _finish(self, batch: BatchResult) -> None:
|
|
481
|
+
self.app.pop_screen()
|
|
482
|
+
self.app.push_screen(ReportScreen(batch))
|
|
483
|
+
|
|
484
|
+
def action_cancel(self) -> None:
|
|
485
|
+
if not self.cancel_event.is_set():
|
|
486
|
+
logger.info(
|
|
487
|
+
"User requested batch cancellation (%d job(s) queued).", len(self.jobs)
|
|
488
|
+
)
|
|
489
|
+
self.cancel_event.set()
|
|
490
|
+
self.query_one("#current-result", Static).update(
|
|
491
|
+
"[yellow]Cancelling after the current file…[/yellow]"
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
495
|
+
if event.button.id == "cancel-button":
|
|
496
|
+
self.action_cancel()
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class ReportScreen(Screen):
|
|
500
|
+
"""Screen 3: final summary and a per-file results table."""
|
|
501
|
+
|
|
502
|
+
BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
|
|
503
|
+
("q", "quit_app", "Quit"),
|
|
504
|
+
("n", "new_batch", "New batch"),
|
|
505
|
+
]
|
|
506
|
+
|
|
507
|
+
def __init__(self, batch: BatchResult) -> None:
|
|
508
|
+
super().__init__()
|
|
509
|
+
self.batch = batch
|
|
510
|
+
self._results_by_row: dict[RowKey, CompressionResult] = {}
|
|
511
|
+
|
|
512
|
+
def compose(self) -> ComposeResult:
|
|
513
|
+
yield Header(show_clock=False)
|
|
514
|
+
with VerticalScroll(id="report-body"):
|
|
515
|
+
yield Static("PDFXZ REPORT", id="report-title")
|
|
516
|
+
yield Static(self._summary_text(), id="report-summary")
|
|
517
|
+
yield DataTable(id="report-table")
|
|
518
|
+
yield Static("", id="selected-file-path")
|
|
519
|
+
with Horizontal(id="report-actions"):
|
|
520
|
+
yield Button("New batch", id="new-batch-button", variant="primary")
|
|
521
|
+
yield Button("Quit", id="quit-button", variant="error")
|
|
522
|
+
yield Footer()
|
|
523
|
+
|
|
524
|
+
def _summary_text(self) -> str:
|
|
525
|
+
batch = self.batch
|
|
526
|
+
lines = [
|
|
527
|
+
f"Files discovered {batch.total}",
|
|
528
|
+
f"Successful {batch.successful}",
|
|
529
|
+
f"Failed {batch.failed}",
|
|
530
|
+
]
|
|
531
|
+
if batch.skipped:
|
|
532
|
+
lines.append(f"Skipped (invalid) {batch.skipped}")
|
|
533
|
+
if batch.cancelled_count:
|
|
534
|
+
lines.append(f"Cancelled {batch.cancelled_count}")
|
|
535
|
+
lines += [
|
|
536
|
+
"",
|
|
537
|
+
f"Original size {format_mb(batch.original_size)}",
|
|
538
|
+
f"Compressed size {format_mb(batch.compressed_size)}",
|
|
539
|
+
f"Total saved {format_mb(batch.saved_bytes)}",
|
|
540
|
+
f"Reduction {batch.reduction_percent:.2f}%",
|
|
541
|
+
f"Elapsed time {format_duration(batch.elapsed_seconds)}",
|
|
542
|
+
]
|
|
543
|
+
largest = batch.largest_saving
|
|
544
|
+
if largest and largest.saved_bytes > 0:
|
|
545
|
+
lines += [
|
|
546
|
+
"",
|
|
547
|
+
f"Largest saving: {truncate_filename(largest.input_path.name)} -{format_mb(largest.saved_bytes)}",
|
|
548
|
+
]
|
|
549
|
+
return "\n".join(lines)
|
|
550
|
+
|
|
551
|
+
def on_mount(self) -> None:
|
|
552
|
+
table = self.query_one(DataTable)
|
|
553
|
+
table.cursor_type = "row"
|
|
554
|
+
table.add_column("File", width=_FILENAME_COLUMN_WIDTH)
|
|
555
|
+
table.add_column("Status", width=10)
|
|
556
|
+
table.add_column("Before", width=10)
|
|
557
|
+
table.add_column("After", width=10)
|
|
558
|
+
table.add_column("Saved", width=12)
|
|
559
|
+
for result in self.batch.results:
|
|
560
|
+
if result.status.is_completed:
|
|
561
|
+
saved = f"{result.reduction_percent:.0f}%"
|
|
562
|
+
elif result.status == CompressionStatus.SKIPPED:
|
|
563
|
+
saved = "invalid"
|
|
564
|
+
elif result.status == CompressionStatus.CANCELLED:
|
|
565
|
+
saved = "--"
|
|
566
|
+
else:
|
|
567
|
+
saved = "error"
|
|
568
|
+
row_key = table.add_row(
|
|
569
|
+
truncate_filename(result.input_path.name, _FILENAME_COLUMN_WIDTH),
|
|
570
|
+
_STATUS_LABELS[result.status],
|
|
571
|
+
format_mb(result.original_size) if result.original_size else "--",
|
|
572
|
+
format_mb(result.compressed_size)
|
|
573
|
+
if result.compressed_size is not None
|
|
574
|
+
else "--",
|
|
575
|
+
saved,
|
|
576
|
+
)
|
|
577
|
+
self._results_by_row[row_key] = result
|
|
578
|
+
if self.batch.results:
|
|
579
|
+
self._show_full_path(self.batch.results[0])
|
|
580
|
+
|
|
581
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
582
|
+
result = self._results_by_row.get(event.row_key)
|
|
583
|
+
if result is not None:
|
|
584
|
+
self._show_full_path(result)
|
|
585
|
+
|
|
586
|
+
def _show_full_path(self, result: CompressionResult) -> None:
|
|
587
|
+
# DataTable has no per-cell tooltip support, so the full path (and,
|
|
588
|
+
# for a failed/skipped file, the reason) is surfaced here instead -
|
|
589
|
+
# the nearest equivalent this framework offers to a hover tooltip.
|
|
590
|
+
text = str(result.input_path)
|
|
591
|
+
if not result.status.is_completed and result.error:
|
|
592
|
+
text = f"{text} \u2014 {result.error}"
|
|
593
|
+
self.query_one("#selected-file-path", Static).update(text)
|
|
594
|
+
|
|
595
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
596
|
+
if event.button.id == "quit-button":
|
|
597
|
+
self.app.exit()
|
|
598
|
+
elif event.button.id == "new-batch-button":
|
|
599
|
+
self.app.pop_screen()
|
|
600
|
+
|
|
601
|
+
def action_quit_app(self) -> None:
|
|
602
|
+
self.app.exit()
|
|
603
|
+
|
|
604
|
+
def action_new_batch(self) -> None:
|
|
605
|
+
self.app.pop_screen()
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
class PDFXZApp(App):
|
|
609
|
+
"""The pdfxz Textual application."""
|
|
610
|
+
|
|
611
|
+
CSS_PATH = "app.tcss"
|
|
612
|
+
TITLE = "pdfxz"
|
|
613
|
+
|
|
614
|
+
def on_mount(self) -> None:
|
|
615
|
+
self.push_screen(ConfigScreen())
|
|
616
|
+
|
|
617
|
+
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
|
618
|
+
# Drop the built-in "Screenshot" command - it has no relevance to
|
|
619
|
+
# pdfxz and isn't something we want to support/maintain. Every
|
|
620
|
+
# other default system command (Theme, Quit, Keys, Maximize/...)
|
|
621
|
+
# is left intact.
|
|
622
|
+
for command in super().get_system_commands(screen):
|
|
623
|
+
if command.title == "Screenshot":
|
|
624
|
+
continue
|
|
625
|
+
yield command
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def run() -> None: # pragma: no cover - thin convenience wrapper
|
|
629
|
+
PDFXZApp().run()
|