pdf-tool-cli 1.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.
Files changed (66) hide show
  1. pdf_tool_cli-1.1.0.dist-info/METADATA +179 -0
  2. pdf_tool_cli-1.1.0.dist-info/RECORD +66 -0
  3. pdf_tool_cli-1.1.0.dist-info/WHEEL +4 -0
  4. pdf_tool_cli-1.1.0.dist-info/entry_points.txt +4 -0
  5. pdf_tool_cli-1.1.0.dist-info/licenses/LICENSE +21 -0
  6. pdftool/__init__.py +3 -0
  7. pdftool/cli/__init__.py +179 -0
  8. pdftool/cli/__main__.py +5 -0
  9. pdftool/cli/args.py +138 -0
  10. pdftool/cli/report.py +71 -0
  11. pdftool/core/__init__.py +0 -0
  12. pdftool/core/atomic.py +43 -0
  13. pdftool/core/batch.py +111 -0
  14. pdftool/core/config.py +89 -0
  15. pdftool/core/errors.py +36 -0
  16. pdftool/core/jobs.py +188 -0
  17. pdftool/core/logger.py +84 -0
  18. pdftool/core/naming.py +91 -0
  19. pdftool/core/plugin.py +96 -0
  20. pdftool/core/registry.py +59 -0
  21. pdftool/core/thumbnails.py +37 -0
  22. pdftool/core/updater.py +67 -0
  23. pdftool/main.py +30 -0
  24. pdftool/tools/__init__.py +0 -0
  25. pdftool/tools/compress/__init__.py +0 -0
  26. pdftool/tools/compress/logic.py +387 -0
  27. pdftool/tools/compress/panel.py +72 -0
  28. pdftool/tools/compress/params.py +18 -0
  29. pdftool/tools/compress/spec.py +18 -0
  30. pdftool/tools/images2pdf/__init__.py +0 -0
  31. pdftool/tools/images2pdf/logic.py +127 -0
  32. pdftool/tools/images2pdf/panel.py +22 -0
  33. pdftool/tools/images2pdf/params.py +6 -0
  34. pdftool/tools/images2pdf/spec.py +19 -0
  35. pdftool/tools/merge/__init__.py +0 -0
  36. pdftool/tools/merge/logic.py +54 -0
  37. pdftool/tools/merge/panel.py +44 -0
  38. pdftool/tools/merge/params.py +20 -0
  39. pdftool/tools/merge/spec.py +19 -0
  40. pdftool/tools/protect/__init__.py +0 -0
  41. pdftool/tools/protect/logic.py +85 -0
  42. pdftool/tools/protect/panel.py +72 -0
  43. pdftool/tools/protect/params.py +18 -0
  44. pdftool/tools/protect/spec.py +18 -0
  45. pdftool/tools/split/__init__.py +0 -0
  46. pdftool/tools/split/logic.py +139 -0
  47. pdftool/tools/split/panel.py +129 -0
  48. pdftool/tools/split/params.py +28 -0
  49. pdftool/tools/split/spec.py +19 -0
  50. pdftool/tools/watermark/__init__.py +0 -0
  51. pdftool/tools/watermark/logic.py +110 -0
  52. pdftool/tools/watermark/panel.py +67 -0
  53. pdftool/tools/watermark/params.py +19 -0
  54. pdftool/tools/watermark/spec.py +19 -0
  55. pdftool/ui/__init__.py +0 -0
  56. pdftool/ui/accessibility.py +44 -0
  57. pdftool/ui/app.py +425 -0
  58. pdftool/ui/errors.py +12 -0
  59. pdftool/ui/file_list.py +275 -0
  60. pdftool/ui/logs.py +58 -0
  61. pdftool/ui/output_dir.py +127 -0
  62. pdftool/ui/panel_base.py +631 -0
  63. pdftool/ui/platform.py +30 -0
  64. pdftool/ui/registry.py +61 -0
  65. pdftool/ui/theme.py +30 -0
  66. pdftool/ui/thumbnails.py +148 -0
@@ -0,0 +1,275 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from pathlib import Path
5
+
6
+ import flet as ft
7
+
8
+ from pdftool.core.plugin import ToolResult
9
+ from pdftool.core.thumbnails import THUMBNAIL_HEIGHT_PX
10
+ from pdftool.ui.accessibility import NamedIconButton
11
+ from pdftool.ui.panel_base import WEB_MODE_MSG, BaseToolPanel
12
+ from pdftool.ui.platform import open_file, shortcut_label
13
+ from pdftool.ui.thumbnails import MISSING, ThumbnailTask, get_cached, load_async
14
+
15
+
16
+ class MultiFileToolPanel(BaseToolPanel):
17
+ """Panel de lotes con lista, orden, resultados y miniaturas opcionales."""
18
+
19
+ show_thumbnails: bool = False # solo tools donde el orden/identidad importa
20
+ # Flechas de orden solo donde el orden cambia el resultado (Unir): en las
21
+ # herramientas 1→1 sugieren algo que no ocurre.
22
+ reorderable: bool = False
23
+
24
+ def build_input(self, page) -> ft.Control:
25
+ previous_task = getattr(self, "_thumb_task", None)
26
+ if previous_task is not None:
27
+ previous_task.cancel()
28
+ self._files: list[Path] = []
29
+ self._results: list[str] = [] # etiqueta por archivo tras un run
30
+ self._failed: list[bool] = [] # fila cuyo archivo falló en el run
31
+ self._row_warnings: list[tuple[str, ...]] = [] # avisos por fila
32
+ self._row_paths: list[Path | None] = [] # ruta por fila exitosa tras un run
33
+ self._thumb_boxes: dict[str, ft.Container] = {}
34
+ self._thumb_task: ThumbnailTask | None = None
35
+ self._thumb_generation = getattr(self, "_thumb_generation", -1) + 1
36
+ self._picker.on_result = self._on_pick
37
+ self._clear_btn = ft.OutlinedButton(
38
+ "Limpiar lista",
39
+ icon=ft.Icons.CLEAR_ALL,
40
+ disabled=True,
41
+ on_click=self._clear_all,
42
+ )
43
+ self._pick_btn = ft.FilledTonalButton(
44
+ self.pick_label,
45
+ icon=self.pick_icon,
46
+ tooltip=f"{self.pick_label} ({shortcut_label('O')})",
47
+ on_click=lambda _e: self._picker.pick_files(
48
+ allow_multiple=True,
49
+ allowed_extensions=list(self.allowed_extensions),
50
+ ),
51
+ )
52
+ return ft.Row([self._pick_btn, self._clear_btn])
53
+
54
+ def _lock_inputs(self, locked: bool) -> None:
55
+ self._pick_btn.disabled = locked
56
+ if hasattr(self, "_file_list"):
57
+ self._refresh()
58
+
59
+ def build_body(self) -> ft.Control:
60
+ # La lista rellena el cuerpo y scrollea sola cuando no cabe; el footer
61
+ # (ejecutar, progreso, status) queda siempre visible.
62
+ # ListView construye solo las filas visibles: con cientos de archivos
63
+ # el scroll sigue siendo fluido.
64
+ self._file_list = ft.ListView(spacing=4, expand=True)
65
+ return self._file_list
66
+
67
+ @staticmethod
68
+ def _thumb_image(png: bytes) -> ft.Image:
69
+ return ft.Image(
70
+ src_base64=base64.b64encode(png).decode(),
71
+ height=THUMBNAIL_HEIGHT_PX,
72
+ fit=ft.ImageFit.CONTAIN,
73
+ )
74
+
75
+ def _thumb_control(self, path: Path) -> ft.Container:
76
+ cached = get_cached(path)
77
+ if cached is not MISSING and cached is not None:
78
+ content: ft.Control = self._thumb_image(cached)
79
+ else:
80
+ icon = (
81
+ ft.Icons.PICTURE_AS_PDF
82
+ if path.suffix.lower() == ".pdf"
83
+ else ft.Icons.IMAGE
84
+ )
85
+ content = ft.Icon(icon, size=28, color=ft.Colors.ON_SURFACE_VARIANT)
86
+ box = ft.Container(
87
+ content=content,
88
+ height=THUMBNAIL_HEIGHT_PX,
89
+ width=44,
90
+ alignment=ft.alignment.center,
91
+ )
92
+ self._thumb_boxes[str(path)] = box
93
+ return box
94
+
95
+ def _on_thumb_ready(self, path: Path, png: bytes | None) -> None:
96
+ # Llega desde el hilo del loader; si la fila ya no existe o el render
97
+ # falló (None), el placeholder/icono se queda como está.
98
+ box = self._thumb_boxes.get(str(path))
99
+ if box is None or png is None:
100
+ return
101
+ box.content = self._thumb_image(png)
102
+ # Sin page.update aquí: el loader llama a on_flush por lotes.
103
+
104
+ def _refresh(self) -> None:
105
+ if self._thumb_task is not None:
106
+ self._thumb_task.cancel()
107
+ self._thumb_task = None
108
+ self._thumb_generation += 1
109
+ generation = self._thumb_generation
110
+ self._thumb_boxes = {}
111
+ self._file_list.controls.clear()
112
+ for index, path in enumerate(self._files):
113
+ result = self._results[index] if index < len(self._results) else None
114
+ row_path = self._row_paths[index] if index < len(self._row_paths) else None
115
+ failed = index < len(self._failed) and self._failed[index]
116
+ warnings = (
117
+ self._row_warnings[index] if index < len(self._row_warnings) else ()
118
+ )
119
+ controls: list[ft.Control] = [ft.Text(f"{index + 1}.", width=28)]
120
+ if self.show_thumbnails:
121
+ controls.append(self._thumb_control(path))
122
+ controls += [
123
+ ft.Text(path.name, expand=True, overflow=ft.TextOverflow.ELLIPSIS),
124
+ ft.Text(
125
+ result or "",
126
+ size=14,
127
+ no_wrap=True,
128
+ color=ft.Colors.ERROR if failed else ft.Colors.ON_SURFACE_VARIANT,
129
+ ),
130
+ ]
131
+ if warnings:
132
+ text = " ".join(warnings)
133
+ controls.append(
134
+ ft.Icon(
135
+ ft.Icons.WARNING_AMBER_ROUNDED,
136
+ size=20,
137
+ color=ft.Colors.TERTIARY,
138
+ tooltip=text,
139
+ semantics_label=f"Aviso: {text}",
140
+ )
141
+ )
142
+ controls += [
143
+ NamedIconButton(
144
+ ft.Icons.OPEN_IN_NEW,
145
+ f"Abrir resultado de {path.name}",
146
+ visible=row_path is not None,
147
+ on_click=lambda _e, p=row_path: open_file(p),
148
+ ),
149
+ ]
150
+ if self.reorderable:
151
+ controls += [
152
+ NamedIconButton(
153
+ ft.Icons.ARROW_UPWARD,
154
+ f"Subir {path.name}",
155
+ disabled=index == 0 or self._running,
156
+ on_click=lambda _e, i=index: self._move(i, -1),
157
+ ),
158
+ NamedIconButton(
159
+ ft.Icons.ARROW_DOWNWARD,
160
+ f"Bajar {path.name}",
161
+ disabled=index == len(self._files) - 1 or self._running,
162
+ on_click=lambda _e, i=index: self._move(i, 1),
163
+ ),
164
+ ]
165
+ controls += [
166
+ NamedIconButton(
167
+ ft.Icons.CLOSE,
168
+ f"Quitar {path.name} de la lista",
169
+ disabled=self._running,
170
+ on_click=lambda _e, i=index: self._remove(i),
171
+ ),
172
+ ]
173
+ self._file_list.controls.append(
174
+ ft.Row(controls, alignment=ft.MainAxisAlignment.SPACE_BETWEEN)
175
+ )
176
+ if self.show_thumbnails:
177
+ pending = [p for p in self._files if get_cached(p) is MISSING]
178
+ if pending:
179
+ self._thumb_task = load_async(
180
+ pending,
181
+ self._on_thumb_ready,
182
+ is_current=lambda: self._thumb_generation == generation,
183
+ on_flush=self._page.update,
184
+ )
185
+ self.on_inputs_changed()
186
+ self._sync_ready_state()
187
+ self._clear_btn.disabled = not self._files or self._running
188
+ n = len(self._files)
189
+ self._counter.value = (
190
+ "" if n == 0 else "1 archivo" if n == 1 else f"{n} archivos"
191
+ )
192
+ self._page.update()
193
+
194
+ def _clear_results(self) -> None:
195
+ self._results = []
196
+ self._row_paths = []
197
+ self._failed = []
198
+ self._row_warnings = []
199
+
200
+ def _move(self, index: int, delta: int) -> None:
201
+ new_index = index + delta
202
+ if 0 <= new_index < len(self._files):
203
+ self._invalidate_active_job()
204
+ self._files[index], self._files[new_index] = (
205
+ self._files[new_index],
206
+ self._files[index],
207
+ )
208
+ self._clear_results()
209
+ self._refresh()
210
+
211
+ def _remove(self, index: int) -> None:
212
+ self._invalidate_active_job()
213
+ self._files.pop(index)
214
+ self._clear_results()
215
+ self._refresh()
216
+
217
+ def _clear_all(self, _e) -> None:
218
+ self._invalidate_active_job()
219
+ removed = list(self._files)
220
+ self._files = []
221
+ self._clear_results()
222
+ self._clear_error()
223
+ self._set_status("")
224
+ self._hide_result_actions()
225
+ self._refresh()
226
+ if removed:
227
+ self._notify(
228
+ "Lista vaciada.",
229
+ action="Deshacer",
230
+ on_action=lambda _e: self._restore(removed),
231
+ )
232
+
233
+ def _restore(self, files: list[Path]) -> None:
234
+ """Deshace «Limpiar lista» si el usuario no añadió nada mientras."""
235
+ if self._files:
236
+ return
237
+ self._files = list(files)
238
+ self._refresh()
239
+
240
+ def _on_pick(self, e) -> None:
241
+ if not e.files:
242
+ return
243
+ self._invalidate_active_job()
244
+ added = 0
245
+ known = set(self._files) # O(1) por archivo, también con miles
246
+ for f in e.files:
247
+ if not f.path: # navegador (modo web): sin ruta local
248
+ continue
249
+ path = Path(f.path)
250
+ if path not in known:
251
+ known.add(path)
252
+ self._files.append(path)
253
+ added += 1
254
+ if added == 0 and e.files:
255
+ self._set_status(WEB_MODE_MSG)
256
+ else:
257
+ self._hide_result_actions()
258
+ self._set_status("")
259
+ self._clear_error()
260
+ self._clear_results()
261
+ self._refresh()
262
+
263
+ def on_result(self, result: ToolResult) -> None:
264
+ items = result.items or []
265
+ self._results = [item.message for item in items]
266
+ self._row_paths = [item.output_path if item.ok else None for item in items]
267
+ self._failed = [not item.ok for item in items]
268
+ self._row_warnings = [item.warnings for item in items]
269
+ self._refresh()
270
+
271
+ def collect_inputs(self) -> list[Path]:
272
+ return list(self._files)
273
+
274
+ def can_run(self) -> bool:
275
+ return len(self._files) >= self.min_files
pdftool/ui/logs.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+
6
+ import flet as ft
7
+
8
+ from pdftool.core.logger import log_paths
9
+
10
+ _EMPTY = "(registro vacío)\n"
11
+
12
+
13
+ def _log_content() -> str:
14
+ chunks: list[str] = []
15
+ for p in log_paths():
16
+ try:
17
+ chunks.append(p.read_text(encoding="utf-8"))
18
+ except OSError: # rotación entre exists() y read_text(): archivo ilegible
19
+ continue
20
+ return "".join(chunks) if chunks else _EMPTY
21
+
22
+
23
+ LOG_LABEL = "Guardar informe de diagnóstico"
24
+
25
+
26
+ def make_log_picker(
27
+ on_saved: Callable[[Path], None] | None = None,
28
+ ) -> ft.FilePicker:
29
+ """FilePicker de "guardar como" que escribe el log en la ruta elegida.
30
+
31
+ El llamador debe añadirlo a page.overlay (una sola vez). `on_saved` recibe
32
+ la ruta final para confirmar al usuario dónde quedó el informe.
33
+ """
34
+
35
+ def on_save(e: ft.FilePickerResultEvent) -> None:
36
+ if not e.path: # diálogo cancelado (o modo web sin ruta)
37
+ return
38
+ target = Path(e.path)
39
+ if target.suffix.lower() != ".txt":
40
+ target = target.with_suffix(".txt")
41
+ target.write_text(_log_content(), encoding="utf-8")
42
+ if on_saved is not None:
43
+ on_saved(target)
44
+
45
+ return ft.FilePicker(on_result=on_save)
46
+
47
+
48
+ def download_log_button(picker: ft.FilePicker) -> ft.Control:
49
+ """Botón del informe de diagnóstico: abre el diálogo de guardado."""
50
+ return ft.TextButton(
51
+ LOG_LABEL,
52
+ icon=ft.Icons.DOWNLOAD,
53
+ on_click=lambda _e: picker.save_file(
54
+ dialog_title=LOG_LABEL,
55
+ file_name="pdf-tool-log.txt",
56
+ allowed_extensions=["txt"],
57
+ ),
58
+ )
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+
6
+ import flet as ft
7
+
8
+ from pdftool.core.config import Settings, save_settings
9
+ from pdftool.ui.accessibility import NamedIconButton
10
+
11
+ _DEFAULT_LABEL = "Junto al original"
12
+
13
+
14
+ def abbreviate_home(path: Path) -> str:
15
+ """Ruta legible: «~/Desktop» en vez de «/Users/quien/Desktop»."""
16
+ try:
17
+ rel = path.relative_to(Path.home())
18
+ except ValueError:
19
+ return str(path)
20
+ return "~" if rel == Path(".") else f"~/{rel}"
21
+
22
+
23
+ class OutputDirField(ft.Row):
24
+ """Selector del destino de las salidas: junto al original o una carpeta.
25
+
26
+ El estado es global y vive en `Settings`; se persiste en cuanto cambia, no
27
+ al ejecutar. Si la carpeta guardada ya no existe (disco desconectado,
28
+ carpeta borrada), se vuelve al default en silencio.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ settings: Settings | None,
34
+ on_change: Callable[[], None] | None = None,
35
+ settings_path: Path | None = None,
36
+ ) -> None:
37
+ # Sin Settings real (p. ej. tests que no pasan ctx.settings), el
38
+ # widget usa una privada solo para no romper pero nunca persiste:
39
+ # de lo contrario `set_dir` escribiría en el settings.json real.
40
+ self._persist = settings is not None
41
+ self._settings = settings if settings is not None else Settings()
42
+ self._on_change = on_change
43
+ # Público a propósito: los tests lo redirigen a un tmp_path para no
44
+ # escribir en el settings.json real del usuario. None → el de la app.
45
+ self.settings_path = settings_path
46
+ self._dir = self._restore()
47
+
48
+ self.label = ft.Text(
49
+ _DEFAULT_LABEL, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS, expand=True
50
+ )
51
+ self.change_btn = ft.TextButton(
52
+ "Cambiar…", icon=ft.Icons.FOLDER_OPEN, on_click=self._pick
53
+ )
54
+ self.reset_btn = NamedIconButton(
55
+ ft.Icons.UNDO,
56
+ "Volver a «junto al original»",
57
+ on_click=lambda _e: self.set_dir(None),
58
+ )
59
+ self._picker = ft.FilePicker(on_result=self._on_pick_result)
60
+
61
+ super().__init__(
62
+ [ft.Text("Guardar en:"), self.label, self.change_btn, self.reset_btn]
63
+ )
64
+ self._render()
65
+
66
+ # ---- estado ----
67
+ @property
68
+ def value(self) -> Path | None:
69
+ return self._dir
70
+
71
+ def set_dir(self, path: Path | None) -> None:
72
+ """Fija el destino, lo persiste y avisa al panel."""
73
+ self._dir = path
74
+ self._settings.output_dir = str(path) if path is not None else None
75
+ if self._persist:
76
+ save_settings(self._settings, self.settings_path)
77
+ self._render()
78
+ if self.page:
79
+ self.update()
80
+ if self._on_change:
81
+ self._on_change()
82
+
83
+ def attach(self, page) -> None:
84
+ """Registra el picker en el overlay una sola vez."""
85
+ if self._picker not in page.overlay:
86
+ page.overlay.append(self._picker)
87
+
88
+ def sync(self) -> None:
89
+ """Re-lee el destino de `Settings`. Lo llama el panel en cada render,
90
+ porque el widget sobrevive entre navegaciones y otro panel puede haber
91
+ cambiado el destino mientras tanto."""
92
+ self._dir = self._restore()
93
+ self._render()
94
+ if self.page:
95
+ self.update()
96
+
97
+ def destination_missing(self) -> bool:
98
+ """True si hay un destino elegido y ya no es una carpeta accesible."""
99
+ return self._dir is not None and not self._dir.is_dir()
100
+
101
+ # ---- interno ----
102
+ def _restore(self) -> Path | None:
103
+ guardado = self._settings.output_dir
104
+ if not guardado:
105
+ return None
106
+ path = Path(guardado)
107
+ if path.is_dir():
108
+ return path
109
+ # La carpeta ya no está: al default, sin molestar al usuario.
110
+ self._settings.output_dir = None
111
+ if self._persist:
112
+ save_settings(self._settings, self.settings_path)
113
+ return None
114
+
115
+ def _render(self) -> None:
116
+ self.label.value = (
117
+ _DEFAULT_LABEL if self._dir is None else abbreviate_home(self._dir)
118
+ )
119
+ self.label.tooltip = None if self._dir is None else str(self._dir)
120
+ self.reset_btn.visible = self._dir is not None
121
+
122
+ def _pick(self, _e) -> None:
123
+ self._picker.get_directory_path(dialog_title="Guardar las salidas en…")
124
+
125
+ def _on_pick_result(self, e) -> None:
126
+ if e.path:
127
+ self.set_dir(Path(e.path))