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.
- pdf_tool_cli-1.1.0.dist-info/METADATA +179 -0
- pdf_tool_cli-1.1.0.dist-info/RECORD +66 -0
- pdf_tool_cli-1.1.0.dist-info/WHEEL +4 -0
- pdf_tool_cli-1.1.0.dist-info/entry_points.txt +4 -0
- pdf_tool_cli-1.1.0.dist-info/licenses/LICENSE +21 -0
- pdftool/__init__.py +3 -0
- pdftool/cli/__init__.py +179 -0
- pdftool/cli/__main__.py +5 -0
- pdftool/cli/args.py +138 -0
- pdftool/cli/report.py +71 -0
- pdftool/core/__init__.py +0 -0
- pdftool/core/atomic.py +43 -0
- pdftool/core/batch.py +111 -0
- pdftool/core/config.py +89 -0
- pdftool/core/errors.py +36 -0
- pdftool/core/jobs.py +188 -0
- pdftool/core/logger.py +84 -0
- pdftool/core/naming.py +91 -0
- pdftool/core/plugin.py +96 -0
- pdftool/core/registry.py +59 -0
- pdftool/core/thumbnails.py +37 -0
- pdftool/core/updater.py +67 -0
- pdftool/main.py +30 -0
- pdftool/tools/__init__.py +0 -0
- pdftool/tools/compress/__init__.py +0 -0
- pdftool/tools/compress/logic.py +387 -0
- pdftool/tools/compress/panel.py +72 -0
- pdftool/tools/compress/params.py +18 -0
- pdftool/tools/compress/spec.py +18 -0
- pdftool/tools/images2pdf/__init__.py +0 -0
- pdftool/tools/images2pdf/logic.py +127 -0
- pdftool/tools/images2pdf/panel.py +22 -0
- pdftool/tools/images2pdf/params.py +6 -0
- pdftool/tools/images2pdf/spec.py +19 -0
- pdftool/tools/merge/__init__.py +0 -0
- pdftool/tools/merge/logic.py +54 -0
- pdftool/tools/merge/panel.py +44 -0
- pdftool/tools/merge/params.py +20 -0
- pdftool/tools/merge/spec.py +19 -0
- pdftool/tools/protect/__init__.py +0 -0
- pdftool/tools/protect/logic.py +85 -0
- pdftool/tools/protect/panel.py +72 -0
- pdftool/tools/protect/params.py +18 -0
- pdftool/tools/protect/spec.py +18 -0
- pdftool/tools/split/__init__.py +0 -0
- pdftool/tools/split/logic.py +139 -0
- pdftool/tools/split/panel.py +129 -0
- pdftool/tools/split/params.py +28 -0
- pdftool/tools/split/spec.py +19 -0
- pdftool/tools/watermark/__init__.py +0 -0
- pdftool/tools/watermark/logic.py +110 -0
- pdftool/tools/watermark/panel.py +67 -0
- pdftool/tools/watermark/params.py +19 -0
- pdftool/tools/watermark/spec.py +19 -0
- pdftool/ui/__init__.py +0 -0
- pdftool/ui/accessibility.py +44 -0
- pdftool/ui/app.py +425 -0
- pdftool/ui/errors.py +12 -0
- pdftool/ui/file_list.py +275 -0
- pdftool/ui/logs.py +58 -0
- pdftool/ui/output_dir.py +127 -0
- pdftool/ui/panel_base.py +631 -0
- pdftool/ui/platform.py +30 -0
- pdftool/ui/registry.py +61 -0
- pdftool/ui/theme.py +30 -0
- pdftool/ui/thumbnails.py +148 -0
pdftool/ui/panel_base.py
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, ClassVar
|
|
8
|
+
|
|
9
|
+
import flet as ft
|
|
10
|
+
|
|
11
|
+
from pdftool.core.errors import UserError
|
|
12
|
+
from pdftool.core.jobs import JobHandle
|
|
13
|
+
from pdftool.core.naming import parse_output_name
|
|
14
|
+
from pdftool.core.plugin import BaseParams, PdfTool, ToolContext, ToolResult, ToolSpec
|
|
15
|
+
from pdftool.ui.errors import humanize_error
|
|
16
|
+
from pdftool.ui.logs import download_log_button, make_log_picker
|
|
17
|
+
from pdftool.ui.output_dir import OutputDirField
|
|
18
|
+
from pdftool.ui.platform import open_file, open_folder, shortcut_label
|
|
19
|
+
|
|
20
|
+
WEB_MODE_MSG = "El modo navegador no da rutas locales; usa la app de escritorio."
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InvalidParams(UserError):
|
|
24
|
+
"""Params inválidos; el panel muestra str(exc) en el status.
|
|
25
|
+
|
|
26
|
+
Si se indica `field`, el mensaje también se marca en ese campo. Cualquier
|
|
27
|
+
`UserError` lanzado por `make_params` se trata igual, sin campo.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, field: ft.TextField | None = None) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.field = field
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_HELPER_OK_COLOR = ft.Colors.ON_SURFACE_VARIANT
|
|
36
|
+
# Token del tema (no un ámbar fijo): legible en claro y en oscuro.
|
|
37
|
+
_HELPER_WARN_COLOR = ft.Colors.TERTIARY
|
|
38
|
+
|
|
39
|
+
# Tipo de mensaje de estado → (icono, color del icono, color del texto).
|
|
40
|
+
# El color nunca va solo: el icono y el propio texto llevan el significado.
|
|
41
|
+
_STATUS_STYLES: dict[str, tuple[str | None, str | None, str | None]] = {
|
|
42
|
+
"info": (None, None, None),
|
|
43
|
+
"success": (ft.Icons.CHECK_CIRCLE, ft.Colors.PRIMARY, None),
|
|
44
|
+
"partial": (ft.Icons.WARNING_AMBER_ROUNDED, ft.Colors.ERROR, None),
|
|
45
|
+
"error": (ft.Icons.ERROR_OUTLINE, ft.Colors.ERROR, ft.Colors.ERROR),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class OutputNameField(ft.TextField):
|
|
50
|
+
"""Campo opcional para la base del archivo de salida.
|
|
51
|
+
|
|
52
|
+
Muestra en vivo el nombre final que se usará. `resolve` devuelve la ruta
|
|
53
|
+
que la herramienta escribiría para esa base, o None si aún no hay archivos;
|
|
54
|
+
así la UI no reimplementa la regla de colisión, la consulta.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, resolve: Callable[[str | None], Path | None]) -> None:
|
|
58
|
+
super().__init__(hint_text="Nombre de salida (opcional)", width=280, dense=True)
|
|
59
|
+
self._resolve = resolve
|
|
60
|
+
self.on_change = lambda _e: self.refresh(update=True)
|
|
61
|
+
|
|
62
|
+
def refresh(self, update: bool = False) -> None:
|
|
63
|
+
text, color = self._helper()
|
|
64
|
+
self.helper_text = text
|
|
65
|
+
self.helper_style = ft.TextStyle(size=11, color=color) if text else None
|
|
66
|
+
if update and self.page:
|
|
67
|
+
self.update()
|
|
68
|
+
|
|
69
|
+
def _helper(self) -> tuple[str | None, str | None]:
|
|
70
|
+
try:
|
|
71
|
+
base = parse_output_name(self.value)
|
|
72
|
+
except UserError:
|
|
73
|
+
return None, None
|
|
74
|
+
if base is None:
|
|
75
|
+
return None, None
|
|
76
|
+
out = self._resolve(base)
|
|
77
|
+
if out is None:
|
|
78
|
+
return None, None
|
|
79
|
+
if out.name == f"{base}.pdf":
|
|
80
|
+
return f"Se guardará como «{out.name}»", _HELPER_OK_COLOR
|
|
81
|
+
return f"Ya existe — se guardará como «{out.name}»", _HELPER_WARN_COLOR
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class BaseToolPanel(PdfTool):
|
|
85
|
+
# Rellenados por cada herramienta:
|
|
86
|
+
icon: str = ""
|
|
87
|
+
run_label: str = "Aplicar"
|
|
88
|
+
run_icon = ft.Icons.PLAY_ARROW
|
|
89
|
+
pick_label: str = "Elegir PDF"
|
|
90
|
+
pick_icon = ft.Icons.UPLOAD_FILE
|
|
91
|
+
allowed_extensions: tuple[str, ...] = ("pdf",)
|
|
92
|
+
min_files: int = 1
|
|
93
|
+
# Las listas de lotes necesitan ocupar el espacio disponible cuando hay
|
|
94
|
+
# archivos. Los paneles de un solo archivo no: un cuerpo vacío solo aleja la
|
|
95
|
+
# acción principal de los controles que la preparan.
|
|
96
|
+
expand_body_when_ready = True
|
|
97
|
+
# Función pura de la herramienta: run(inputs, params, progress) -> ToolResult.
|
|
98
|
+
logic: Callable[..., ToolResult] | None = None
|
|
99
|
+
# Definición sin Flet de la herramienta (`tools/<id>/spec.py`). Al
|
|
100
|
+
# declararla, `meta` y `logic` salen de ella; un panel sin spec (p. ej. un
|
|
101
|
+
# stub de test) los declara a mano.
|
|
102
|
+
spec: ClassVar[ToolSpec | None] = None
|
|
103
|
+
|
|
104
|
+
def __init_subclass__(cls, **kwargs) -> None:
|
|
105
|
+
super().__init_subclass__(**kwargs)
|
|
106
|
+
spec = cls.__dict__.get("spec")
|
|
107
|
+
if spec is not None:
|
|
108
|
+
cls.meta = spec.meta
|
|
109
|
+
cls.logic = staticmethod(spec.run)
|
|
110
|
+
cls.min_files = spec.min_inputs
|
|
111
|
+
cls.allowed_extensions = spec.extensions
|
|
112
|
+
|
|
113
|
+
# Controles que `build_panel` crea una vez por página (ver ese método).
|
|
114
|
+
_root: ft.Column | None = None
|
|
115
|
+
# True mientras corre un trabajo: las entradas quedan bloqueadas (cambiar
|
|
116
|
+
# archivos a mitad cancelaría el trabajo sin aviso; primero se cancela).
|
|
117
|
+
_running = False
|
|
118
|
+
_out_dir: OutputDirField
|
|
119
|
+
_ctx: ToolContext
|
|
120
|
+
_page: Any # ft.Page; los tests usan un sustituto mínimo
|
|
121
|
+
progress: ft.ProgressBar
|
|
122
|
+
status: ft.Text
|
|
123
|
+
_status_icon: ft.Icon
|
|
124
|
+
_status_row: ft.Row
|
|
125
|
+
_warnings: ft.Text
|
|
126
|
+
_counter: ft.Text
|
|
127
|
+
_error_toggle: ft.TextButton
|
|
128
|
+
_error_detail: ft.Text
|
|
129
|
+
_log_btn: ft.Control
|
|
130
|
+
_error_actions: ft.Row
|
|
131
|
+
open_btn: ft.FilledButton
|
|
132
|
+
open_file_btn: ft.FilledButton
|
|
133
|
+
run_btn: ft.FilledButton
|
|
134
|
+
_pick_btn: ft.FilledTonalButton # lo crea `build_input` de la subclase
|
|
135
|
+
cancel_btn: ft.OutlinedButton
|
|
136
|
+
_result_actions: ft.Row
|
|
137
|
+
_feedback: ft.Column
|
|
138
|
+
_body: ft.Control
|
|
139
|
+
_empty_hint: ft.Text
|
|
140
|
+
|
|
141
|
+
def __init__(self) -> None:
|
|
142
|
+
super().__init__()
|
|
143
|
+
# Un único FilePicker reutilizado entre renders (evita fugas en overlay).
|
|
144
|
+
self._picker = ft.FilePicker()
|
|
145
|
+
# Ídem, para el informe de diagnóstico; confirma dónde quedó guardado.
|
|
146
|
+
self._log_picker = make_log_picker(
|
|
147
|
+
on_saved=lambda path: self._notify(f"Informe guardado en «{path.name}».")
|
|
148
|
+
)
|
|
149
|
+
self._invalid_field: ft.TextField | None = None
|
|
150
|
+
self._job: JobHandle | None = None
|
|
151
|
+
self._generation = 0
|
|
152
|
+
|
|
153
|
+
def _invalidate_active_job(self, *, reset_ui: bool = True) -> None:
|
|
154
|
+
"""Invalida callbacks antiguos y solicita cancelar su trabajo."""
|
|
155
|
+
self._generation += 1
|
|
156
|
+
if self._job is not None:
|
|
157
|
+
self._job.cancel()
|
|
158
|
+
self._job = None
|
|
159
|
+
if reset_ui and hasattr(self, "progress"):
|
|
160
|
+
self.progress.visible = False
|
|
161
|
+
self.progress.value = 0
|
|
162
|
+
self._set_running(False)
|
|
163
|
+
self.run_btn.disabled = not self.can_run()
|
|
164
|
+
|
|
165
|
+
# ---- hooks de la herramienta ----
|
|
166
|
+
def extra_controls(self) -> list[ft.Control]:
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
def on_inputs_changed(self) -> None:
|
|
170
|
+
"""Hook: la lista de archivos cambió. Los paneles con campos que
|
|
171
|
+
dependen de las entradas lo sobrescriben."""
|
|
172
|
+
|
|
173
|
+
def on_output_dir_changed(self) -> None:
|
|
174
|
+
"""Hook: cambió la carpeta de salida. Los paneles que predicen el
|
|
175
|
+
nombre final lo sobrescriben."""
|
|
176
|
+
|
|
177
|
+
def make_params(self):
|
|
178
|
+
raise NotImplementedError
|
|
179
|
+
|
|
180
|
+
def run_logic(self, inputs: list[Path], params, progress) -> ToolResult:
|
|
181
|
+
"""Ejecuta la lógica pura declarada en `logic` (en segundo plano)."""
|
|
182
|
+
if self.logic is None:
|
|
183
|
+
raise NotImplementedError("Declara `logic = staticmethod(<función>)`")
|
|
184
|
+
return self.logic(inputs, params, progress=progress)
|
|
185
|
+
|
|
186
|
+
def on_result(self, result: ToolResult) -> None:
|
|
187
|
+
"""Hook opcional tras un run correcto (p. ej. anotar la lista de archivos)."""
|
|
188
|
+
|
|
189
|
+
# ---- hooks de la subclase (1 archivo / N archivos) ----
|
|
190
|
+
def build_input(self, page) -> ft.Control:
|
|
191
|
+
"""Barra de entrada de la zona superior (botones de elegir/limpiar)."""
|
|
192
|
+
raise NotImplementedError
|
|
193
|
+
|
|
194
|
+
def build_body(self) -> ft.Control:
|
|
195
|
+
"""Zona flexible del medio; el único control con expand=True."""
|
|
196
|
+
raise NotImplementedError
|
|
197
|
+
|
|
198
|
+
def collect_inputs(self) -> list[Path]:
|
|
199
|
+
raise NotImplementedError
|
|
200
|
+
|
|
201
|
+
def can_run(self) -> bool:
|
|
202
|
+
raise NotImplementedError
|
|
203
|
+
|
|
204
|
+
def _empty_action_hint(self) -> str:
|
|
205
|
+
"""Explica junto al botón qué falta para poder ejecutar."""
|
|
206
|
+
required = self.min_files
|
|
207
|
+
selected = sum(path is not None for path in self.collect_inputs())
|
|
208
|
+
remaining = max(required - selected, 0)
|
|
209
|
+
if remaining == 0:
|
|
210
|
+
return ""
|
|
211
|
+
if required == 1:
|
|
212
|
+
return "Añade un archivo para continuar."
|
|
213
|
+
if selected == 0:
|
|
214
|
+
return f"Añade al menos {required} archivos para continuar."
|
|
215
|
+
suffix = "archivo" if remaining == 1 else "archivos"
|
|
216
|
+
return f"Añade {remaining} {suffix} más para continuar."
|
|
217
|
+
|
|
218
|
+
def _set_running(self, running: bool) -> None:
|
|
219
|
+
self._running = running
|
|
220
|
+
self.cancel_btn.visible = running
|
|
221
|
+
self._lock_inputs(running)
|
|
222
|
+
|
|
223
|
+
def _lock_inputs(self, locked: bool) -> None:
|
|
224
|
+
"""Hook: (des)habilita los controles que cambian las entradas."""
|
|
225
|
+
|
|
226
|
+
def _sync_ready_state(self) -> None:
|
|
227
|
+
"""Sincroniza acción, ayuda y densidad con las entradas actuales."""
|
|
228
|
+
ready = self.can_run()
|
|
229
|
+
self.run_btn.disabled = not ready or self._running
|
|
230
|
+
self._empty_hint.value = "" if ready else self._empty_action_hint()
|
|
231
|
+
self._empty_hint.visible = not ready
|
|
232
|
+
self._body.expand = ready and self.expand_body_when_ready
|
|
233
|
+
|
|
234
|
+
# ---- registro (log de diagnóstico; sin datos del usuario) ----
|
|
235
|
+
def _logger(self) -> logging.Logger:
|
|
236
|
+
return logging.getLogger(f"pdftool.{self.meta.id}")
|
|
237
|
+
|
|
238
|
+
def _elapsed(self) -> float:
|
|
239
|
+
started = getattr(self, "_run_started", None)
|
|
240
|
+
return 0.0 if started is None else time.monotonic() - started
|
|
241
|
+
|
|
242
|
+
def _notify(self, message: str, **snack) -> None:
|
|
243
|
+
self._page.open(ft.SnackBar(ft.Text(message), **snack))
|
|
244
|
+
|
|
245
|
+
def _mark_invalid(self, field: ft.TextField | None, message: str) -> None:
|
|
246
|
+
self._clear_invalid()
|
|
247
|
+
if field is not None:
|
|
248
|
+
field.error_text = message
|
|
249
|
+
self._invalid_field = field
|
|
250
|
+
|
|
251
|
+
def _clear_invalid(self) -> None:
|
|
252
|
+
if self._invalid_field is not None:
|
|
253
|
+
self._invalid_field.error_text = None
|
|
254
|
+
self._invalid_field = None
|
|
255
|
+
|
|
256
|
+
# ---- estado (mensaje bajo la acción principal) ----
|
|
257
|
+
def _set_status(self, text: str, kind: str = "info") -> None:
|
|
258
|
+
icon, icon_color, text_color = _STATUS_STYLES[kind]
|
|
259
|
+
self.status.value = text
|
|
260
|
+
self.status.color = text_color
|
|
261
|
+
self._status_icon.name = icon
|
|
262
|
+
self._status_icon.color = icon_color
|
|
263
|
+
self._status_icon.visible = bool(text) and icon is not None
|
|
264
|
+
|
|
265
|
+
# ---- acciones post-run (footer) ----
|
|
266
|
+
def _show_result_actions(self, result: ToolResult) -> None:
|
|
267
|
+
self._hide_result_actions()
|
|
268
|
+
if not result.outputs:
|
|
269
|
+
return
|
|
270
|
+
self._result_actions.visible = True
|
|
271
|
+
# Tras completar, abrir el resultado prima sobre volver a procesarlo.
|
|
272
|
+
self.run_btn.style = ft.ButtonStyle(
|
|
273
|
+
bgcolor=ft.Colors.SURFACE_CONTAINER_HIGHEST, color=ft.Colors.ON_SURFACE
|
|
274
|
+
)
|
|
275
|
+
self.open_btn.visible = True
|
|
276
|
+
self.open_btn.style = (
|
|
277
|
+
ft.ButtonStyle(
|
|
278
|
+
bgcolor=ft.Colors.SECONDARY_CONTAINER,
|
|
279
|
+
color=ft.Colors.ON_SECONDARY_CONTAINER,
|
|
280
|
+
)
|
|
281
|
+
if len(result.outputs) == 1
|
|
282
|
+
else None
|
|
283
|
+
)
|
|
284
|
+
self.open_btn.data = result.outputs[0].parent
|
|
285
|
+
self.open_file_btn.visible = len(result.outputs) == 1
|
|
286
|
+
self.open_file_btn.data = result.outputs[0]
|
|
287
|
+
|
|
288
|
+
def _hide_result_actions(self) -> None:
|
|
289
|
+
self._warnings.visible = False
|
|
290
|
+
self._warnings.value = ""
|
|
291
|
+
self._result_actions.visible = False
|
|
292
|
+
self.open_btn.visible = False
|
|
293
|
+
self.open_file_btn.visible = False
|
|
294
|
+
self.open_btn.data = None
|
|
295
|
+
self.open_file_btn.data = None
|
|
296
|
+
self.run_btn.style = None
|
|
297
|
+
|
|
298
|
+
def _cancel_run(self, _e) -> None:
|
|
299
|
+
"""Detiene el trabajo en curso; lo ya publicado se queda en disco."""
|
|
300
|
+
if self._job is None:
|
|
301
|
+
return
|
|
302
|
+
self._logger().info("cancelado · %.1fs", self._elapsed())
|
|
303
|
+
self._invalidate_active_job()
|
|
304
|
+
self._set_status("Cancelado. Los archivos ya terminados se conservan.", "info")
|
|
305
|
+
self._page.update()
|
|
306
|
+
|
|
307
|
+
# ---- errores (mensajes para el usuario) ----
|
|
308
|
+
def _clear_error(self) -> None:
|
|
309
|
+
self._error_toggle.visible = False
|
|
310
|
+
self._error_toggle.text = "Ver detalle técnico"
|
|
311
|
+
self._error_detail.visible = False
|
|
312
|
+
self._error_detail.value = ""
|
|
313
|
+
self._log_btn.visible = False
|
|
314
|
+
self._error_actions.visible = False
|
|
315
|
+
|
|
316
|
+
def _toggle_error_detail(self, _e) -> None:
|
|
317
|
+
self._error_detail.visible = not self._error_detail.visible
|
|
318
|
+
self._error_toggle.text = (
|
|
319
|
+
"Ocultar detalle" if self._error_detail.visible else "Ver detalle técnico"
|
|
320
|
+
)
|
|
321
|
+
self._page.update()
|
|
322
|
+
|
|
323
|
+
def _on_error(self, exc: Exception, generation: int | None = None) -> None:
|
|
324
|
+
if generation is not None and generation != self._generation:
|
|
325
|
+
return
|
|
326
|
+
self._job = None
|
|
327
|
+
self._hide_result_actions()
|
|
328
|
+
self._logger().error("error · %.1fs", self._elapsed(), exc_info=exc)
|
|
329
|
+
self.progress.visible = False
|
|
330
|
+
self._set_running(False)
|
|
331
|
+
message, detail = humanize_error(exc)
|
|
332
|
+
self._set_status(message, "error")
|
|
333
|
+
if detail:
|
|
334
|
+
self._error_detail.value = detail
|
|
335
|
+
self._error_detail.visible = False # arranca plegado
|
|
336
|
+
self._error_toggle.visible = True
|
|
337
|
+
self._log_btn.visible = True
|
|
338
|
+
self._error_actions.visible = True
|
|
339
|
+
else:
|
|
340
|
+
self._clear_error()
|
|
341
|
+
self.run_btn.disabled = not self.can_run()
|
|
342
|
+
self._page.update()
|
|
343
|
+
|
|
344
|
+
# ---- atajos de teclado (los enruta el host) ----
|
|
345
|
+
def shortcut_open(self) -> None:
|
|
346
|
+
"""⌘/Ctrl+O: el mismo efecto que pulsar el botón de añadir/elegir."""
|
|
347
|
+
if not self._running and self._pick_btn.on_click is not None:
|
|
348
|
+
self._pick_btn.on_click(None)
|
|
349
|
+
|
|
350
|
+
def shortcut_run(self) -> None:
|
|
351
|
+
"""⌘/Ctrl+Enter: ejecuta solo si el botón está disponible."""
|
|
352
|
+
if not self.run_btn.disabled:
|
|
353
|
+
self._on_run_click(None)
|
|
354
|
+
|
|
355
|
+
# ---- ejecución ----
|
|
356
|
+
def _on_run_click(self, _e) -> None:
|
|
357
|
+
params = self._prepare_run()
|
|
358
|
+
if params is not None:
|
|
359
|
+
self._start_job(params)
|
|
360
|
+
|
|
361
|
+
def _prepare_run(self) -> BaseParams | None:
|
|
362
|
+
"""Valida destino y parámetros; None (con el error a la vista) si no."""
|
|
363
|
+
if not self.can_run():
|
|
364
|
+
return None
|
|
365
|
+
self._clear_error()
|
|
366
|
+
self._clear_invalid()
|
|
367
|
+
self._hide_result_actions()
|
|
368
|
+
if self._out_dir.destination_missing():
|
|
369
|
+
self._set_status(
|
|
370
|
+
"La carpeta de destino ya no está disponible. Elige otra "
|
|
371
|
+
"o vuelve a «junto al original».",
|
|
372
|
+
"error",
|
|
373
|
+
)
|
|
374
|
+
self._page.update()
|
|
375
|
+
return None
|
|
376
|
+
try:
|
|
377
|
+
params: BaseParams = self.make_params()
|
|
378
|
+
except UserError as exc:
|
|
379
|
+
self._set_status(str(exc), "error")
|
|
380
|
+
self._mark_invalid(getattr(exc, "field", None), str(exc))
|
|
381
|
+
self._page.update()
|
|
382
|
+
return None
|
|
383
|
+
return params.model_copy(update={"output_dir": self._out_dir.value})
|
|
384
|
+
|
|
385
|
+
def _start_job(self, params: BaseParams) -> None:
|
|
386
|
+
self.run_btn.disabled = True
|
|
387
|
+
self._set_running(True)
|
|
388
|
+
self.progress.visible = True
|
|
389
|
+
self.progress.value = 0
|
|
390
|
+
self._set_status("Procesando…")
|
|
391
|
+
self._page.update()
|
|
392
|
+
inputs = self.collect_inputs()
|
|
393
|
+
self._invalidate_active_job(reset_ui=False)
|
|
394
|
+
generation = self._generation
|
|
395
|
+
self._run_started = time.monotonic()
|
|
396
|
+
self._logger().info("inicio · %d archivo(s)", len(inputs))
|
|
397
|
+
self._job = self._ctx.run_job(
|
|
398
|
+
work=lambda progress: self.run_logic(inputs, params, progress),
|
|
399
|
+
on_progress=lambda pct, msg: self._on_run_progress(generation, pct, msg),
|
|
400
|
+
on_done=lambda result: self._on_run_done(generation, result),
|
|
401
|
+
on_error=lambda exc: self._on_error(exc, generation),
|
|
402
|
+
is_current=lambda: self._generation == generation,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def _on_run_progress(self, generation: int, pct: float, msg: str) -> None:
|
|
406
|
+
if generation != self._generation:
|
|
407
|
+
return
|
|
408
|
+
self.progress.value = pct
|
|
409
|
+
self._set_status(msg)
|
|
410
|
+
self._page.update()
|
|
411
|
+
|
|
412
|
+
def _on_run_done(self, generation: int, result: ToolResult) -> None:
|
|
413
|
+
if generation != self._generation:
|
|
414
|
+
return
|
|
415
|
+
self._job = None
|
|
416
|
+
self._logger().info("ok · %.1fs", self._elapsed())
|
|
417
|
+
self._clear_error()
|
|
418
|
+
self.progress.visible = False
|
|
419
|
+
self._set_running(False)
|
|
420
|
+
failed = any(not item.ok for item in result.items or [])
|
|
421
|
+
self._set_status(result.summary, "partial" if failed else "success")
|
|
422
|
+
self._show_result_actions(result)
|
|
423
|
+
self._warnings.value = "\n".join(result.warnings)
|
|
424
|
+
self._warnings.visible = bool(result.warnings)
|
|
425
|
+
self.run_btn.disabled = not self.can_run()
|
|
426
|
+
self.on_result(result)
|
|
427
|
+
self._page.update()
|
|
428
|
+
|
|
429
|
+
# ---- común ----
|
|
430
|
+
def build_panel(self, ctx: ToolContext) -> ft.Control:
|
|
431
|
+
# El panel se construye una vez por página y se reutiliza: navegar a
|
|
432
|
+
# otra herramienta y volver conserva archivos, campos, resultado y el
|
|
433
|
+
# trabajo en curso (sus callbacks siguen actualizando estos controles).
|
|
434
|
+
root = self._root
|
|
435
|
+
if root is not None and self._ctx.page is ctx.page:
|
|
436
|
+
self._ctx = ctx
|
|
437
|
+
# El destino es global (vive en Settings): otra herramienta pudo
|
|
438
|
+
# cambiarlo mientras este panel estaba oculto. `sync` no avisa, así
|
|
439
|
+
# que se refresca lo que depende de él (p. ej. el nombre previsto).
|
|
440
|
+
self._out_dir.sync()
|
|
441
|
+
self.on_output_dir_changed()
|
|
442
|
+
return root
|
|
443
|
+
# Página nueva (otra sesión): nada del panel anterior debe notificar.
|
|
444
|
+
self._invalidate_active_job()
|
|
445
|
+
self._ctx = ctx
|
|
446
|
+
page: Any = ctx.page
|
|
447
|
+
self._page = page
|
|
448
|
+
|
|
449
|
+
self.progress = ft.ProgressBar(value=0, visible=False)
|
|
450
|
+
self.status = ft.Text("", weight=ft.FontWeight.W_500, expand=True)
|
|
451
|
+
self._status_icon = ft.Icon(visible=False, size=20)
|
|
452
|
+
self._status_row = ft.Row(
|
|
453
|
+
[self._status_icon, self.status],
|
|
454
|
+
spacing=8,
|
|
455
|
+
vertical_alignment=ft.CrossAxisAlignment.START,
|
|
456
|
+
)
|
|
457
|
+
# Avisos del resultado (p. ej. páginas rasterizadas): bajo el resumen,
|
|
458
|
+
# sin diálogo ni clic extra.
|
|
459
|
+
self._warnings = ft.Text(
|
|
460
|
+
"", size=14, color=ft.Colors.ON_SURFACE_VARIANT, visible=False
|
|
461
|
+
)
|
|
462
|
+
self._counter = ft.Text("", size=12, color=ft.Colors.ON_SURFACE_VARIANT)
|
|
463
|
+
self._error_toggle = ft.TextButton(
|
|
464
|
+
"Ver detalle técnico", visible=False, on_click=self._toggle_error_detail
|
|
465
|
+
)
|
|
466
|
+
self._error_detail = ft.Text(
|
|
467
|
+
"",
|
|
468
|
+
visible=False,
|
|
469
|
+
selectable=True,
|
|
470
|
+
size=12,
|
|
471
|
+
color=ft.Colors.ON_SURFACE_VARIANT,
|
|
472
|
+
)
|
|
473
|
+
self._log_btn = download_log_button(self._log_picker)
|
|
474
|
+
self._log_btn.visible = False
|
|
475
|
+
self._error_actions = ft.Row(
|
|
476
|
+
[self._error_toggle, self._log_btn], visible=False, wrap=True
|
|
477
|
+
)
|
|
478
|
+
self.open_btn = ft.FilledButton(
|
|
479
|
+
"Abrir carpeta", icon=ft.Icons.FOLDER_OPEN, visible=False
|
|
480
|
+
)
|
|
481
|
+
self.open_file_btn = ft.FilledButton(
|
|
482
|
+
"Abrir archivo", icon=ft.Icons.OPEN_IN_NEW, visible=False
|
|
483
|
+
)
|
|
484
|
+
self.run_btn = ft.FilledButton(
|
|
485
|
+
self.run_label,
|
|
486
|
+
icon=self.run_icon,
|
|
487
|
+
disabled=True,
|
|
488
|
+
tooltip=f"Ejecutar ({shortcut_label('Enter')})",
|
|
489
|
+
)
|
|
490
|
+
self.cancel_btn = ft.OutlinedButton(
|
|
491
|
+
"Cancelar", icon=ft.Icons.STOP, visible=False, on_click=self._cancel_run
|
|
492
|
+
)
|
|
493
|
+
self._result_actions = ft.Row(
|
|
494
|
+
[self.open_file_btn, self.open_btn], visible=False, wrap=True, spacing=8
|
|
495
|
+
)
|
|
496
|
+
self._feedback = ft.Column(
|
|
497
|
+
[
|
|
498
|
+
self.progress,
|
|
499
|
+
self._status_row,
|
|
500
|
+
self._warnings,
|
|
501
|
+
self._result_actions,
|
|
502
|
+
self._error_actions,
|
|
503
|
+
self._error_detail,
|
|
504
|
+
],
|
|
505
|
+
spacing=8,
|
|
506
|
+
tight=True,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
# Una única instancia por panel: cada OutputDirField trae su propio
|
|
510
|
+
# FilePicker, que se quedaría en page.overlay. Mismo motivo que
|
|
511
|
+
# self._picker.
|
|
512
|
+
if not hasattr(self, "_out_dir"):
|
|
513
|
+
self._out_dir = OutputDirField(
|
|
514
|
+
ctx.settings, on_change=self.on_output_dir_changed
|
|
515
|
+
)
|
|
516
|
+
self._out_dir.attach(page)
|
|
517
|
+
self._out_dir.sync()
|
|
518
|
+
|
|
519
|
+
input_bar = self.build_input(page) # subclase; fija self._picker.on_result
|
|
520
|
+
body = self.build_body()
|
|
521
|
+
self._body = body
|
|
522
|
+
self._empty_hint = ft.Text(
|
|
523
|
+
"",
|
|
524
|
+
size=14,
|
|
525
|
+
color=ft.Colors.ON_SURFACE_VARIANT,
|
|
526
|
+
visible=False,
|
|
527
|
+
)
|
|
528
|
+
self._sync_ready_state()
|
|
529
|
+
|
|
530
|
+
if self._picker not in page.overlay:
|
|
531
|
+
page.overlay.append(self._picker)
|
|
532
|
+
|
|
533
|
+
if self._log_picker not in page.overlay:
|
|
534
|
+
page.overlay.append(self._log_picker)
|
|
535
|
+
|
|
536
|
+
self.run_btn.on_click = self._on_run_click
|
|
537
|
+
self.open_btn.on_click = lambda _e: open_folder(Path(self.open_btn.data))
|
|
538
|
+
self.open_file_btn.on_click = lambda _e: open_file(self.open_file_btn.data)
|
|
539
|
+
|
|
540
|
+
# Tres zonas: superior fija · cuerpo flexible · footer anclado.
|
|
541
|
+
# `body` es el único hijo con expand=True: todo lo posterior queda
|
|
542
|
+
# pegado al fondo sin importar cuántos archivos haya en la lista.
|
|
543
|
+
self._root = ft.Column(
|
|
544
|
+
[
|
|
545
|
+
ft.Text(self.meta.name, size=24, weight=ft.FontWeight.BOLD),
|
|
546
|
+
ft.Text(self.meta.description),
|
|
547
|
+
ft.Divider(),
|
|
548
|
+
input_bar,
|
|
549
|
+
*self.extra_controls(),
|
|
550
|
+
body,
|
|
551
|
+
ft.Divider(),
|
|
552
|
+
self._out_dir,
|
|
553
|
+
self._empty_hint,
|
|
554
|
+
ft.Row(
|
|
555
|
+
[
|
|
556
|
+
self.run_btn,
|
|
557
|
+
self.cancel_btn,
|
|
558
|
+
ft.Container(expand=True),
|
|
559
|
+
self._counter,
|
|
560
|
+
]
|
|
561
|
+
),
|
|
562
|
+
self._feedback,
|
|
563
|
+
],
|
|
564
|
+
spacing=16,
|
|
565
|
+
expand=True,
|
|
566
|
+
)
|
|
567
|
+
return self._root
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class SingleFileToolPanel(BaseToolPanel):
|
|
571
|
+
expand_body_when_ready = False
|
|
572
|
+
|
|
573
|
+
def build_input(self, page) -> ft.Control:
|
|
574
|
+
self._file: Path | None = None
|
|
575
|
+
self._file_label = ft.Text("Ningún archivo seleccionado", italic=True)
|
|
576
|
+
self._picker.on_result = self._on_pick
|
|
577
|
+
self._pick_btn = ft.FilledTonalButton(
|
|
578
|
+
self.pick_label,
|
|
579
|
+
icon=self.pick_icon,
|
|
580
|
+
tooltip=f"{self.pick_label} ({shortcut_label('O')})",
|
|
581
|
+
on_click=lambda _e: self._picker.pick_files(
|
|
582
|
+
allow_multiple=False,
|
|
583
|
+
allowed_extensions=list(self.allowed_extensions),
|
|
584
|
+
),
|
|
585
|
+
)
|
|
586
|
+
return ft.Row([self._pick_btn, self._file_label])
|
|
587
|
+
|
|
588
|
+
def _lock_inputs(self, locked: bool) -> None:
|
|
589
|
+
self._pick_btn.disabled = locked
|
|
590
|
+
|
|
591
|
+
def build_body(self) -> ft.Control:
|
|
592
|
+
return ft.Container(expand=True) # empuja el footer al fondo
|
|
593
|
+
|
|
594
|
+
def _on_pick(self, e) -> None:
|
|
595
|
+
if e.files and e.files[0].path:
|
|
596
|
+
self._invalidate_active_job()
|
|
597
|
+
self._file = Path(e.files[0].path)
|
|
598
|
+
self._file_label.value = self._file.name
|
|
599
|
+
self._file_label.italic = False
|
|
600
|
+
self._hide_result_actions()
|
|
601
|
+
self._set_status("")
|
|
602
|
+
self._clear_error()
|
|
603
|
+
self._sync_ready_state()
|
|
604
|
+
try:
|
|
605
|
+
self.after_pick(self._file)
|
|
606
|
+
except Exception as exc: # p. ej. un PDF dañado al leer sus páginas
|
|
607
|
+
self._reject_pick(exc)
|
|
608
|
+
elif e.files:
|
|
609
|
+
self._set_status(WEB_MODE_MSG)
|
|
610
|
+
self._clear_error()
|
|
611
|
+
self._page.update()
|
|
612
|
+
|
|
613
|
+
def after_pick(self, path: Path) -> None:
|
|
614
|
+
"""Hook opcional tras elegir un archivo válido (p.ej. leer nº de páginas).
|
|
615
|
+
|
|
616
|
+
Si lanza, la selección se descarta y el error se muestra al usuario.
|
|
617
|
+
"""
|
|
618
|
+
|
|
619
|
+
def _reject_pick(self, exc: Exception) -> None:
|
|
620
|
+
self._logger().warning("no se pudo leer la entrada", exc_info=exc)
|
|
621
|
+
self._file = None
|
|
622
|
+
self._file_label.value = "Ningún archivo seleccionado"
|
|
623
|
+
self._file_label.italic = True
|
|
624
|
+
self._sync_ready_state()
|
|
625
|
+
self._set_status(humanize_error(exc)[0], "error")
|
|
626
|
+
|
|
627
|
+
def collect_inputs(self) -> list[Path]:
|
|
628
|
+
return [] if self._file is None else [self._file]
|
|
629
|
+
|
|
630
|
+
def can_run(self) -> bool:
|
|
631
|
+
return self._file is not None
|
pdftool/ui/platform.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _open_with_system(path: Path) -> None:
|
|
10
|
+
if sys.platform == "darwin":
|
|
11
|
+
subprocess.run(["open", str(path)], check=False)
|
|
12
|
+
elif sys.platform == "win32":
|
|
13
|
+
os.startfile(str(path)) # solo Windows
|
|
14
|
+
else:
|
|
15
|
+
subprocess.run(["xdg-open", str(path)], check=False)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def shortcut_label(key: str) -> str:
|
|
19
|
+
"""Atajo legible en la plataforma actual: «⌘O» en macOS, «Ctrl+O» en el resto."""
|
|
20
|
+
return f"⌘{key}" if sys.platform == "darwin" else f"Ctrl+{key}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def open_folder(path: Path) -> None:
|
|
24
|
+
"""Abre el gestor de archivos del sistema en `path`."""
|
|
25
|
+
_open_with_system(path)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def open_file(path: Path) -> None:
|
|
29
|
+
"""Abre `path` con la aplicación por defecto del sistema."""
|
|
30
|
+
_open_with_system(path)
|