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/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")
|
pdftool/ui/thumbnails.py
ADDED
|
@@ -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])
|