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/registry.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import logging
5
+ import pkgutil
6
+
7
+ from pdftool.core.plugin import PdfTool
8
+
9
+ _REGISTRY: dict[str, type[PdfTool]] = {}
10
+ # Todo lo registrado alguna vez, por módulo: `clear()` vacía el registro pero un
11
+ # módulo ya importado no vuelve a ejecutar sus @register, así que `discover()`
12
+ # los recupera de aquí.
13
+ _SEEN: dict[str, list[type[PdfTool]]] = {}
14
+ _log = logging.getLogger("pdftool.registry")
15
+
16
+
17
+ def register(cls: type[PdfTool]) -> type[PdfTool]:
18
+ tool_id = cls.meta.id
19
+ current = _REGISTRY.get(tool_id)
20
+ if current is not None and current is not cls:
21
+ raise ValueError(
22
+ f"Id de herramienta duplicado: {tool_id!r} "
23
+ f"({current.__qualname__} y {cls.__qualname__})"
24
+ )
25
+ _REGISTRY[tool_id] = cls
26
+ seen = _SEEN.setdefault(cls.__module__, [])
27
+ if cls not in seen:
28
+ seen.append(cls)
29
+ return cls
30
+
31
+
32
+ def clear() -> None:
33
+ _REGISTRY.clear()
34
+
35
+
36
+ def get_tools() -> list[PdfTool]:
37
+ """Instancias ordenadas por `meta.order` y, a igualdad, por nombre."""
38
+ classes = sorted(_REGISTRY.values(), key=lambda c: (c.meta.order, c.meta.name))
39
+ return [cls() for cls in classes]
40
+
41
+
42
+ def discover(package: str = "pdftool.tools") -> list[str]:
43
+ """Importa el `panel.py` de cada subpaquete de `package` para disparar @register.
44
+
45
+ Una herramienta que falla al importarse se registra en el log y se omite:
46
+ el resto de la app sigue funcionando. Devuelve los nombres que fallaron.
47
+ """
48
+ pkg = importlib.import_module(package)
49
+ failed: list[str] = []
50
+ for mod in pkgutil.iter_modules(pkg.__path__):
51
+ name = f"{package}.{mod.name}.panel"
52
+ try:
53
+ importlib.import_module(name)
54
+ except Exception:
55
+ _log.exception("No se pudo cargar la herramienta %s", mod.name)
56
+ failed.append(mod.name)
57
+ for module, classes in list(_SEEN.items()):
58
+ if module == package or module.startswith(f"{package}."):
59
+ for cls in classes:
60
+ _REGISTRY.setdefault(cls.meta.id, cls)
61
+ return failed
pdftool/ui/theme.py ADDED
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import flet as ft
4
+
5
+ from pdftool.core.config import ThemeSetting
6
+
7
+ SEED_COLOR = "#4F46E5" # índigo moderno
8
+
9
+
10
+ def build_theme() -> ft.Theme:
11
+ return ft.Theme(color_scheme_seed=SEED_COLOR)
12
+
13
+
14
+ def resolve_mode(setting: str) -> ft.ThemeMode:
15
+ return {
16
+ "light": ft.ThemeMode.LIGHT,
17
+ "dark": ft.ThemeMode.DARK,
18
+ }.get(setting, ft.ThemeMode.SYSTEM)
19
+
20
+
21
+ _NEXT_MODE: dict[str, ThemeSetting] = {
22
+ "system": "light",
23
+ "light": "dark",
24
+ "dark": "system",
25
+ }
26
+
27
+
28
+ def next_mode(setting: str) -> ThemeSetting:
29
+ """Cicla system -> light -> dark -> system para el toggle."""
30
+ return _NEXT_MODE.get(setting, "light")
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import threading
5
+ import time
6
+ from collections import OrderedDict
7
+ from collections.abc import Callable, Sequence
8
+ from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError
9
+ from pathlib import Path
10
+ from typing import TypeVar
11
+
12
+ from pdftool.core.thumbnails import THUMBNAIL_HEIGHT_PX, render_thumbnail
13
+
14
+ MISSING = object() # nunca se intentó (distinto de None = no renderizable)
15
+ _CACHE_MAX = 512 # tope duro: ~2-5 MB de RAM; al superarlo expulsa el LRU
16
+ _MAX_WORKERS = 2 # renderizar miniaturas es CPU/memoria intensivo
17
+
18
+ _FLUSH_INTERVAL = 0.1 # s entre repintados de la UI mientras llegan miniaturas
19
+
20
+ # Clave: ruta, página, alto, mtime y tamaño. Si el archivo cambia en disco, la
21
+ # clave cambia y la miniatura antigua deja de servirse.
22
+ _Key = tuple[str, int, int, int, int]
23
+ _cache: OrderedDict[_Key, bytes | None] = OrderedDict()
24
+ _lock = threading.Lock()
25
+ _executor_lock = threading.Lock()
26
+ _executor: ThreadPoolExecutor | None = ThreadPoolExecutor(
27
+ max_workers=_MAX_WORKERS, thread_name_prefix="pdftool-thumbnail"
28
+ )
29
+
30
+
31
+ class ThumbnailTask:
32
+ """Handle de una carga repartida entre los workers de miniaturas."""
33
+
34
+ def __init__(self, futures: list[Future]) -> None:
35
+ self._futures = futures
36
+
37
+ def cancel(self) -> bool:
38
+ """Cancela lo que aún no empezó; lo que ya empezó coopera vía is_current."""
39
+ return all([f.cancel() for f in self._futures])
40
+
41
+ def join(self, timeout: float | None = None) -> None:
42
+ """Espera a todos los trozos (para tests y cierre controlado)."""
43
+ for future in self._futures:
44
+ try:
45
+ future.result(timeout=timeout)
46
+ except TimeoutError:
47
+ return
48
+
49
+
50
+ def _get_executor() -> ThreadPoolExecutor:
51
+ global _executor
52
+ with _executor_lock:
53
+ if _executor is None:
54
+ _executor = ThreadPoolExecutor(
55
+ max_workers=_MAX_WORKERS, thread_name_prefix="pdftool-thumbnail"
56
+ )
57
+ return _executor
58
+
59
+
60
+ def shutdown_thumbnail_executor() -> None:
61
+ """Cancela tareas pendientes y libera los workers compartidos."""
62
+ global _executor
63
+ with _executor_lock:
64
+ executor = _executor
65
+ _executor = None
66
+ if executor is not None:
67
+ executor.shutdown(wait=False, cancel_futures=True)
68
+
69
+
70
+ def _key(path: Path, page_index: int = 0) -> _Key:
71
+ try:
72
+ st = os.stat(path)
73
+ stamp, size = st.st_mtime_ns, st.st_size
74
+ except OSError:
75
+ stamp, size = -1, -1
76
+ return (str(path), page_index, THUMBNAIL_HEIGHT_PX, stamp, size)
77
+
78
+
79
+ def get_cached(path: Path, page_index: int = 0):
80
+ """bytes | None (cacheado como no-renderizable) | MISSING (nunca intentado)."""
81
+ key = _key(path, page_index)
82
+ with _lock:
83
+ if key not in _cache:
84
+ return MISSING
85
+ _cache.move_to_end(key)
86
+ return _cache[key]
87
+
88
+
89
+ def _store(key: _Key, value: bytes | None) -> None:
90
+ with _lock:
91
+ _cache[key] = value
92
+ _cache.move_to_end(key)
93
+ while len(_cache) > _CACHE_MAX:
94
+ _cache.popitem(last=False)
95
+
96
+
97
+ Item = Path | tuple[Path, int]
98
+ # El elemento que se pide es el que se devuelve a `on_ready` (ruta o par).
99
+ T = TypeVar("T", bound=Item)
100
+
101
+
102
+ def _split(item: Item) -> tuple[Path, int]:
103
+ return item if isinstance(item, tuple) else (item, 0)
104
+
105
+
106
+ def load_async(
107
+ items: Sequence[T],
108
+ on_ready: Callable[[T, bytes | None], None],
109
+ is_current: Callable[[], bool],
110
+ *,
111
+ on_flush: Callable[[], None] | None = None,
112
+ ) -> ThumbnailTask:
113
+ """Renderiza `items` (rutas o pares (ruta, página)) y notifica cada uno.
114
+
115
+ El trabajo se reparte entre los workers de miniaturas. `on_ready` recibe el
116
+ mismo elemento que se pidió; `on_flush`, si se da, se llama como mucho cada
117
+ `_FLUSH_INTERVAL` segundos y al terminar cada trozo, para que la UI repinte
118
+ por lotes y no una vez por miniatura.
119
+
120
+ `is_current` es el token de generación del panel: si devuelve False la lista
121
+ cambió y el trabajo termina sin notificar (lo ya renderizado queda en caché).
122
+ """
123
+ chunks = [items[i::_MAX_WORKERS] for i in range(_MAX_WORKERS)]
124
+
125
+ def _target(chunk: Sequence[T]) -> None:
126
+ last_flush = time.monotonic()
127
+ pending = False
128
+ for item in chunk:
129
+ if not is_current():
130
+ return
131
+ path, page_index = _split(item)
132
+ value = get_cached(path, page_index)
133
+ if value is MISSING:
134
+ value = render_thumbnail(path, page_index)
135
+ _store(_key(path, page_index), value)
136
+ if not is_current():
137
+ return
138
+ on_ready(item, value)
139
+ pending = True
140
+ now = time.monotonic()
141
+ if on_flush is not None and now - last_flush >= _FLUSH_INTERVAL:
142
+ on_flush()
143
+ last_flush, pending = now, False
144
+ if on_flush is not None and pending and is_current():
145
+ on_flush()
146
+
147
+ executor = _get_executor()
148
+ return ThumbnailTask([executor.submit(_target, c) for c in chunks if c])