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
pdftool/ui/app.py ADDED
@@ -0,0 +1,425 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from collections.abc import Callable
5
+
6
+ import flet as ft
7
+
8
+ from pdftool import __version__
9
+ from pdftool.core.config import load_settings, save_settings
10
+ from pdftool.core.jobs import run_job, shutdown_job_executor
11
+ from pdftool.core.plugin import ToolContext
12
+ from pdftool.core.updater import GITHUB_REPO, check_for_update
13
+ from pdftool.ui import registry
14
+ from pdftool.ui.accessibility import icon_content
15
+ from pdftool.ui.logs import LOG_LABEL, download_log_button, make_log_picker
16
+ from pdftool.ui.theme import build_theme, next_mode, resolve_mode
17
+ from pdftool.ui.thumbnails import shutdown_thumbnail_executor
18
+
19
+ AUTHOR_HANDLE = "@juanmaav92"
20
+ AUTHOR_SITE = "https://juanMaAV92.github.io"
21
+
22
+
23
+ def _build_footer(theme_label, on_theme, on_log, on_author):
24
+ """Pie: firma del autor a la izquierda; versión y utilidades a la derecha.
25
+
26
+ Devuelve (pie, ítem del tema): el ítem se reetiqueta al cambiar el tema.
27
+ """
28
+ theme_item = ft.PopupMenuItem(
29
+ text=theme_label, icon=ft.Icons.BRIGHTNESS_6, on_click=on_theme
30
+ )
31
+ # Visible en todas las pantallas pero discreta: mismo tono apagado y
32
+ # tamaño que la versión, sin competir con el resultado del panel.
33
+ credit = ft.TextButton(
34
+ f"Hecho por {AUTHOR_HANDLE}",
35
+ icon=ft.Icons.OPEN_IN_NEW,
36
+ tooltip="Visitar el sitio del autor",
37
+ on_click=on_author,
38
+ style=ft.ButtonStyle(
39
+ color=ft.Colors.ON_SURFACE_VARIANT,
40
+ icon_size=14,
41
+ text_style=ft.TextStyle(size=12),
42
+ padding=ft.padding.symmetric(horizontal=8, vertical=4),
43
+ ),
44
+ )
45
+ footer = ft.Row(
46
+ [
47
+ credit,
48
+ ft.Container(expand=True),
49
+ ft.Text(
50
+ f"v{__version__}",
51
+ size=11,
52
+ color=ft.Colors.ON_SURFACE_VARIANT,
53
+ tooltip=f"Versión de la aplicación: {__version__}",
54
+ ),
55
+ ft.PopupMenuButton(
56
+ content=icon_content(ft.Icons.MORE_HORIZ, "Opciones de la aplicación"),
57
+ tooltip="Opciones de la aplicación",
58
+ items=[
59
+ theme_item,
60
+ ft.PopupMenuItem(
61
+ text=LOG_LABEL, icon=ft.Icons.DOWNLOAD, on_click=on_log
62
+ ),
63
+ ],
64
+ ),
65
+ ],
66
+ )
67
+ return footer, theme_item
68
+
69
+
70
+ def _tool_card(index, tool, on_open):
71
+ return ft.OutlinedButton(
72
+ content=ft.Column(
73
+ [
74
+ # La categoría va en la esquina, en la misma línea que el icono:
75
+ # agrupa sin añadir altura (la barra lateral ya muestra grupos).
76
+ ft.Row(
77
+ [
78
+ ft.Icon(tool.icon, size=30),
79
+ ft.Text(
80
+ tool.meta.category,
81
+ size=12,
82
+ color=ft.Colors.ON_SURFACE_VARIANT,
83
+ ),
84
+ ],
85
+ alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
86
+ vertical_alignment=ft.CrossAxisAlignment.START,
87
+ ),
88
+ ft.Text(tool.meta.name, weight=ft.FontWeight.BOLD, size=15),
89
+ ft.Text(
90
+ tool.meta.description, size=12, color=ft.Colors.ON_SURFACE_VARIANT
91
+ ),
92
+ ],
93
+ spacing=6,
94
+ tight=True,
95
+ width=208,
96
+ height=108,
97
+ horizontal_alignment=ft.CrossAxisAlignment.START,
98
+ ),
99
+ width=240,
100
+ height=140,
101
+ style=ft.ButtonStyle(
102
+ padding=16,
103
+ alignment=ft.alignment.top_left,
104
+ color=ft.Colors.ON_SURFACE,
105
+ shape=ft.RoundedRectangleBorder(radius=14),
106
+ side={
107
+ ft.ControlState.DEFAULT: ft.BorderSide(1, ft.Colors.OUTLINE_VARIANT),
108
+ ft.ControlState.FOCUSED: ft.BorderSide(2, ft.Colors.PRIMARY),
109
+ },
110
+ bgcolor={ft.ControlState.FOCUSED: ft.Colors.PRIMARY_CONTAINER},
111
+ ),
112
+ on_click=lambda _e: on_open(index),
113
+ )
114
+
115
+
116
+ def _group_by_category(tools):
117
+ """[(categoría, [(índice, herramienta), …]), …] respetando el orden de
118
+ `tools` (ya ordenadas por `meta.order`); cada categoría aparece donde lo
119
+ hace su primera herramienta."""
120
+ groups: dict[str, list] = {}
121
+ for index, tool in enumerate(tools):
122
+ groups.setdefault(tool.meta.category, []).append((index, tool))
123
+ return list(groups.items())
124
+
125
+
126
+ def _build_home(tools, on_open):
127
+ # Una sola cuadrícula que se ajusta al ancho: las herramientas ya llegan
128
+ # ordenadas por tarea, así que las de una misma categoría quedan juntas.
129
+ # (Una fila por categoría dejaba la pantalla medio vacía y forzaba scroll.)
130
+ cards = ft.Row(
131
+ [_tool_card(i, t, on_open) for i, t in enumerate(tools)],
132
+ wrap=True,
133
+ spacing=16,
134
+ run_spacing=16,
135
+ )
136
+ return ft.Column(
137
+ [
138
+ ft.Text("Herramientas PDF", size=28, weight=ft.FontWeight.BOLD),
139
+ ft.Text("Elige una herramienta para empezar."),
140
+ ft.Container(height=8),
141
+ cards,
142
+ ],
143
+ spacing=14,
144
+ scroll=ft.ScrollMode.AUTO,
145
+ expand=True,
146
+ )
147
+
148
+
149
+ def _nav_style(selected: bool) -> ft.ButtonStyle:
150
+ return ft.ButtonStyle(
151
+ bgcolor=ft.Colors.SECONDARY_CONTAINER if selected else None,
152
+ color=ft.Colors.ON_SECONDARY_CONTAINER if selected else ft.Colors.ON_SURFACE,
153
+ alignment=ft.alignment.center_left,
154
+ padding=ft.padding.symmetric(horizontal=12, vertical=10),
155
+ shape=ft.RoundedRectangleBorder(radius=10),
156
+ )
157
+
158
+
159
+ def _nav_button(icon, label: str, on_click) -> ft.TextButton:
160
+ return ft.TextButton(
161
+ content=ft.Row(
162
+ [ft.Icon(icon, size=20), ft.Text(label, size=14)],
163
+ spacing=12,
164
+ ),
165
+ tooltip=label,
166
+ style=_nav_style(False),
167
+ on_click=on_click,
168
+ )
169
+
170
+
171
+ class _Sidebar(ft.Container):
172
+ """Navegación lateral: Inicio y herramientas agrupadas por categoría.
173
+
174
+ Sustituye al NavigationRail, que no tiene scroll: con más herramientas las
175
+ entradas dejarían de caber en la ventana. Botones normales, así que se
176
+ recorre con el teclado como el resto de la app.
177
+ """
178
+
179
+ def __init__(self, tools, on_home, on_tool) -> None:
180
+ self.home_button = _nav_button(
181
+ ft.Icons.GRID_VIEW, "Inicio", lambda _e: on_home()
182
+ )
183
+ self.tool_buttons: list[ft.TextButton] = [
184
+ _nav_button(tool.icon, tool.meta.name, lambda _e, i=index: on_tool(i))
185
+ for index, tool in enumerate(tools)
186
+ ]
187
+ controls: list[ft.Control] = [self.home_button]
188
+ for category, items in _group_by_category(tools):
189
+ controls.append(
190
+ ft.Container(
191
+ ft.Text(
192
+ category,
193
+ size=12,
194
+ weight=ft.FontWeight.W_600,
195
+ color=ft.Colors.ON_SURFACE_VARIANT,
196
+ ),
197
+ padding=ft.padding.only(left=12, top=12, bottom=2),
198
+ )
199
+ )
200
+ controls.extend(self.tool_buttons[i] for i, _tool in items)
201
+ self.list = ft.ListView(controls, spacing=2, padding=8, expand=True)
202
+ super().__init__(content=self.list, width=208)
203
+
204
+ def select(self, index: int | None) -> None:
205
+ """Marca Inicio (None) o la herramienta `index` como actual."""
206
+ self.home_button.style = _nav_style(index is None)
207
+ for i, button in enumerate(self.tool_buttons):
208
+ button.style = _nav_style(i == index)
209
+
210
+
211
+ class _UpdateBanner(ft.Container):
212
+ """Aviso de nueva versión como barra dentro de la ventana, encima del contenido.
213
+
214
+ No es un `ft.Banner`: abierto con `page.open` se dibujaba como capa encima,
215
+ oscurecido y tapando el título. Aquí empuja el contenido hacia abajo, y los
216
+ pares PRIMARY_CONTAINER / ON_PRIMARY_CONTAINER dan buen contraste en tema claro
217
+ y oscuro. Siempre se puede cerrar.
218
+ """
219
+
220
+ def __init__(self, page) -> None:
221
+ self._app_page = page
222
+ self._url: str | None = None
223
+ self.text = ft.Text(
224
+ "Hay una nueva versión disponible. ¿Descargarla ahora?",
225
+ color=ft.Colors.ON_PRIMARY_CONTAINER,
226
+ weight=ft.FontWeight.W_500,
227
+ expand=True,
228
+ )
229
+ self.download_button = ft.FilledButton("Descargar", on_click=self._download)
230
+ self.dismiss_button = ft.TextButton(
231
+ "Ahora no",
232
+ on_click=self._dismiss,
233
+ style=ft.ButtonStyle(color=ft.Colors.ON_PRIMARY_CONTAINER),
234
+ )
235
+ super().__init__(
236
+ content=ft.Row(
237
+ [
238
+ ft.Icon(
239
+ ft.Icons.SYSTEM_UPDATE, color=ft.Colors.ON_PRIMARY_CONTAINER
240
+ ),
241
+ self.text,
242
+ self.dismiss_button,
243
+ self.download_button,
244
+ ],
245
+ spacing=12,
246
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
247
+ ),
248
+ bgcolor=ft.Colors.PRIMARY_CONTAINER,
249
+ padding=ft.padding.symmetric(horizontal=20, vertical=10),
250
+ visible=False,
251
+ )
252
+
253
+ def show(self, url: str) -> None:
254
+ self._url = url
255
+ self.visible = True
256
+ self._app_page.update()
257
+
258
+ def _download(self, _e) -> None:
259
+ if self._url:
260
+ self._app_page.launch_url(self._url)
261
+ self._dismiss(_e)
262
+
263
+ def _dismiss(self, _e) -> None:
264
+ self.visible = False
265
+ self._app_page.update()
266
+
267
+
268
+ def _build_update_banner(page) -> _UpdateBanner:
269
+ return _UpdateBanner(page)
270
+
271
+
272
+ def _start_update_check(
273
+ on_found: Callable[[str], None], check: Callable[[], str | None]
274
+ ) -> threading.Thread:
275
+ """Busca una versión nueva en un hilo propio.
276
+
277
+ No usa el pool de trabajos: con solo dos workers, una red lenta no debe
278
+ retrasar la primera tarea del usuario. `check` nunca lanza (ver updater).
279
+ """
280
+
281
+ def target() -> None:
282
+ url = check()
283
+ if url:
284
+ on_found(url)
285
+
286
+ thread = threading.Thread(target=target, name="pdftool-update-check", daemon=True)
287
+ thread.start()
288
+ return thread
289
+
290
+
291
+ _MODE_NAMES = {"system": "Sistema", "light": "Claro", "dark": "Oscuro"}
292
+
293
+
294
+ def _theme_label(mode: str) -> str:
295
+ return f"Tema: {_MODE_NAMES[mode]} · cambiar a {_MODE_NAMES[next_mode(mode)]}"
296
+
297
+
298
+ def _configure_window(page) -> None:
299
+ page.window.width = 980
300
+ page.window.height = 680
301
+ # Barra lateral (208) + contenido: por debajo de esto los controles de los
302
+ # paneles dejan de caber.
303
+ page.window.min_width = 820
304
+ page.window.min_height = 560
305
+
306
+
307
+ def _handle_shortcut(event, tool) -> bool:
308
+ """⌘/Ctrl+O añade archivos y ⌘/Ctrl+Enter ejecuta en la herramienta abierta.
309
+
310
+ Devuelve True si el evento era un atajo (aunque no hubiera nada que hacer).
311
+ """
312
+ if tool is None or not (event.ctrl or event.meta):
313
+ return False
314
+ if event.key.upper() == "O":
315
+ tool.shortcut_open()
316
+ return True
317
+ if event.key == "Enter":
318
+ tool.shortcut_run()
319
+ return True
320
+ return False
321
+
322
+
323
+ def _install_shutdown_hooks(page, shutdown: Callable[[], None]) -> None:
324
+ """Engancha `shutdown` al cierre y a la desconexión de la página.
325
+
326
+ Como corrutinas: Flet ejecuta los manejadores síncronos en su pool de
327
+ hilos, que al cerrar la ventana ya está apagado, y el cierre acababa con
328
+ «cannot schedule new futures after shutdown». `shutdown` no bloquea.
329
+ """
330
+
331
+ async def on_shutdown(_e) -> None:
332
+ shutdown()
333
+
334
+ page.on_close = on_shutdown
335
+ page.on_disconnect = on_shutdown
336
+
337
+
338
+ def _notify(page, message: str) -> None:
339
+ page.open(ft.SnackBar(ft.Text(message)))
340
+
341
+
342
+ def build_app(page: ft.Page) -> None:
343
+ registry.discover()
344
+ tools = registry.get_tools()
345
+ settings = load_settings()
346
+
347
+ # Libera los workers compartidos cuando Flet cierra la sesión de escritorio
348
+ # o desconecta la vista web. Las tareas en curso ya tienen además su propio
349
+ # token de generación y dejan de notificar a la UI obsoleta.
350
+ def shutdown_resources() -> None:
351
+ shutdown_job_executor()
352
+ shutdown_thumbnail_executor()
353
+
354
+ _install_shutdown_hooks(page, shutdown_resources)
355
+
356
+ page.title = f"pdf-tool · v{__version__}"
357
+ page.theme = build_theme()
358
+ page.theme_mode = resolve_mode(settings.theme_mode)
359
+ _configure_window(page)
360
+
361
+ ctx = ToolContext(page=page, run_job=run_job, settings=settings)
362
+ content = ft.Container(expand=True, padding=24)
363
+
364
+ current: list = [None] # herramienta abierta (None = Inicio)
365
+
366
+ def open_tool(index: int) -> None:
367
+ content.content = tools[index].build_panel(ctx)
368
+ current[0] = tools[index]
369
+ sidebar.select(index)
370
+ page.update()
371
+
372
+ def open_home() -> None:
373
+ content.content = _build_home(tools, open_tool)
374
+ current[0] = None
375
+ sidebar.select(None)
376
+ page.update()
377
+
378
+ page.on_keyboard_event = lambda e: _handle_shortcut(e, current[0])
379
+
380
+ sidebar = _Sidebar(tools, on_home=open_home, on_tool=open_tool)
381
+
382
+ def toggle_theme(_e) -> None:
383
+ settings.theme_mode = next_mode(settings.theme_mode)
384
+ save_settings(settings)
385
+ page.theme_mode = resolve_mode(settings.theme_mode)
386
+ refresh_theme_label()
387
+ page.update()
388
+
389
+ def refresh_theme_label() -> None:
390
+ theme_item.text = _theme_label(settings.theme_mode)
391
+
392
+ update_banner = _build_update_banner(page)
393
+
394
+ log_picker = make_log_picker(
395
+ on_saved=lambda path: _notify(page, f"Informe guardado en «{path.name}».")
396
+ )
397
+ page.overlay.append(log_picker)
398
+
399
+ footer, theme_item = _build_footer(
400
+ "Cambiar tema",
401
+ toggle_theme,
402
+ download_log_button(log_picker).on_click,
403
+ lambda _e: page.launch_url(AUTHOR_SITE),
404
+ )
405
+ refresh_theme_label()
406
+
407
+ page.add(
408
+ ft.Column(
409
+ [
410
+ update_banner,
411
+ ft.Row([sidebar, ft.VerticalDivider(width=1), content], expand=True),
412
+ ft.Divider(height=1),
413
+ footer,
414
+ ],
415
+ expand=True,
416
+ )
417
+ )
418
+ open_home()
419
+
420
+ def _on_update(url: str) -> None:
421
+ update_banner.show(url)
422
+
423
+ _start_update_check(
424
+ _on_update, lambda: check_for_update(current=__version__, repo=GITHUB_REPO)
425
+ )
pdftool/ui/errors.py ADDED
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from pdftool.core.errors import user_message
4
+
5
+
6
+ def humanize_error(exc: Exception) -> tuple[str, str | None]:
7
+ """Traduce una excepción a (mensaje para el usuario, detalle técnico | None).
8
+
9
+ La regla vive en `core.errors` para que la lógica por lotes la aplique
10
+ también a cada archivo; aquí solo se expone a la UI.
11
+ """
12
+ return user_message(exc)