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