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/core/__init__.py
ADDED
|
File without changes
|
pdftool/core/atomic.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@contextmanager
|
|
12
|
+
def atomic_output(target: Path) -> Iterator[Path]:
|
|
13
|
+
"""Escribe una salida en el mismo directorio y publícala al terminar.
|
|
14
|
+
|
|
15
|
+
El archivo final no se crea ni se reemplaza hasta que el bloque termina sin
|
|
16
|
+
excepción. Al usar el mismo directorio, `os.replace` conserva la operación
|
|
17
|
+
como un cambio atómico dentro del mismo sistema de archivos.
|
|
18
|
+
"""
|
|
19
|
+
target = Path(target)
|
|
20
|
+
fd, temporary_name = tempfile.mkstemp(
|
|
21
|
+
prefix=".pdf-tool-", suffix=target.suffix, dir=target.parent
|
|
22
|
+
)
|
|
23
|
+
os.close(fd)
|
|
24
|
+
temporary = Path(temporary_name)
|
|
25
|
+
try:
|
|
26
|
+
yield temporary
|
|
27
|
+
os.replace(temporary, target)
|
|
28
|
+
finally:
|
|
29
|
+
# Si el bloque falla, elimina el temporal; `missing_ok` también cubre
|
|
30
|
+
# el caso exitoso, donde os.replace ya lo movió al destino final.
|
|
31
|
+
temporary.unlink(missing_ok=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def atomic_copy(source: Path, target: Path) -> None:
|
|
35
|
+
"""Copia `source` sin dejar una salida parcial si la copia falla."""
|
|
36
|
+
with atomic_output(target) as temporary:
|
|
37
|
+
shutil.copy2(source, temporary)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def atomic_write_text(target: Path, text: str, *, encoding: str = "utf-8") -> None:
|
|
41
|
+
"""Escribe texto y publica el archivo solo cuando la escritura termina."""
|
|
42
|
+
with atomic_output(target) as temporary:
|
|
43
|
+
temporary.write_text(text, encoding=encoding)
|
pdftool/core/batch.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Bucle común de las herramientas por lote.
|
|
2
|
+
|
|
3
|
+
Un archivo que falla no detiene el lote: queda reportado en su `FileResult`.
|
|
4
|
+
La cancelación (`JobCancelled`) sí lo detiene, porque nunca se trata como un
|
|
5
|
+
fallo de archivo.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from pdftool.core.errors import UserError, user_message
|
|
16
|
+
from pdftool.core.jobs import JobCancelled
|
|
17
|
+
from pdftool.core.plugin import FileResult, Progress
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Outcome:
|
|
22
|
+
"""Resultado de un archivo con avisos y cifras, además del mensaje."""
|
|
23
|
+
|
|
24
|
+
output: Path
|
|
25
|
+
message: str
|
|
26
|
+
warnings: tuple[str, ...] = ()
|
|
27
|
+
metrics: Mapping[str, float] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Procesa una entrada y devuelve (salida, mensaje corto) o un `Outcome`.
|
|
31
|
+
ProcessOne = Callable[[Path, Progress], "tuple[Path, str] | Outcome"]
|
|
32
|
+
|
|
33
|
+
_log = logging.getLogger("pdftool.batch")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def noop_progress(_p: float, _m: str) -> None:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate_inputs(inputs: Sequence[Path | str]) -> list[Path]:
|
|
41
|
+
"""Normaliza las entradas y exige que haya al menos una y que existan."""
|
|
42
|
+
if not inputs:
|
|
43
|
+
raise ValueError("inputs está vacío")
|
|
44
|
+
paths = [Path(p) for p in inputs]
|
|
45
|
+
for path in paths:
|
|
46
|
+
if not path.exists():
|
|
47
|
+
raise FileNotFoundError(path)
|
|
48
|
+
return paths
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class BatchResult:
|
|
53
|
+
outputs: list[Path]
|
|
54
|
+
items: list[FileResult]
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def total(self) -> int:
|
|
58
|
+
return len(self.items)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_batch(
|
|
62
|
+
inputs: Sequence[Path | str],
|
|
63
|
+
process_one: ProcessOne,
|
|
64
|
+
progress: Progress = noop_progress,
|
|
65
|
+
*,
|
|
66
|
+
all_failed: str,
|
|
67
|
+
) -> BatchResult:
|
|
68
|
+
"""Procesa cada entrada aislando sus errores.
|
|
69
|
+
|
|
70
|
+
Con un solo archivo, su error se relanza tal cual (así el mensaje humano
|
|
71
|
+
del host lo reconoce). Con varios, solo se lanza `UserError(all_failed)`
|
|
72
|
+
si no salió ninguno. El mensaje de cada fallo ya viene traducido para el
|
|
73
|
+
usuario; el detalle técnico queda en el log.
|
|
74
|
+
"""
|
|
75
|
+
paths = validate_inputs(inputs)
|
|
76
|
+
total = len(paths)
|
|
77
|
+
outputs: list[Path] = []
|
|
78
|
+
items: list[FileResult] = []
|
|
79
|
+
|
|
80
|
+
for index, path in enumerate(paths):
|
|
81
|
+
|
|
82
|
+
def scoped(
|
|
83
|
+
pct: float, msg: str, _i: int = index, _name: str = path.name
|
|
84
|
+
) -> None:
|
|
85
|
+
label = f"[{_i + 1}/{total}] {_name}: {msg}" if total > 1 else msg
|
|
86
|
+
progress((_i + pct) / total, label)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
done = process_one(path, scoped)
|
|
90
|
+
except JobCancelled:
|
|
91
|
+
raise
|
|
92
|
+
except Exception as exc: # este archivo falla; el lote continúa
|
|
93
|
+
if total == 1:
|
|
94
|
+
raise
|
|
95
|
+
message, detail = user_message(exc)
|
|
96
|
+
if detail is not None:
|
|
97
|
+
_log.warning("archivo %d/%d falló", index + 1, total, exc_info=exc)
|
|
98
|
+
items.append(FileResult(path, None, False, message))
|
|
99
|
+
continue
|
|
100
|
+
if not isinstance(done, Outcome):
|
|
101
|
+
done = Outcome(*done)
|
|
102
|
+
outputs.append(done.output)
|
|
103
|
+
items.append(
|
|
104
|
+
FileResult(
|
|
105
|
+
path, done.output, True, done.message, done.warnings, done.metrics
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if not outputs:
|
|
110
|
+
raise UserError(all_failed)
|
|
111
|
+
return BatchResult(outputs=outputs, items=items)
|
pdftool/core/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from platformdirs import user_data_dir
|
|
9
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
10
|
+
|
|
11
|
+
from pdftool.core.atomic import atomic_write_text
|
|
12
|
+
|
|
13
|
+
APP_NAME = "pdf-tool"
|
|
14
|
+
APP_AUTHOR = "juanmaAV"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def data_dir() -> Path:
|
|
18
|
+
d = Path(user_data_dir(APP_NAME, APP_AUTHOR))
|
|
19
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
return d
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def settings_path() -> Path:
|
|
24
|
+
return data_dir() / "settings.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
ThemeSetting = Literal["system", "light", "dark"]
|
|
28
|
+
|
|
29
|
+
# Versión del formato de settings.json. Subirla cuando un cambio necesite
|
|
30
|
+
# migrar datos guardados; los archivos sin versión son la versión 1.
|
|
31
|
+
SCHEMA_VERSION = 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Settings(BaseModel):
|
|
35
|
+
schema_version: int = SCHEMA_VERSION
|
|
36
|
+
theme_mode: ThemeSetting = "system"
|
|
37
|
+
# Carpeta de salida global; None → junto al original. Se guarda como texto
|
|
38
|
+
# porque el JSON no tiene tipo ruta.
|
|
39
|
+
output_dir: str | None = None
|
|
40
|
+
# Preferencias propias de cada herramienta, por `meta.id`.
|
|
41
|
+
tools: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
def tool(self, tool_id: str) -> dict[str, Any]:
|
|
44
|
+
"""Preferencias de una herramienta (se crean vacías al pedirlas)."""
|
|
45
|
+
return self.tools.setdefault(tool_id, {})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _recover(raw: dict[str, Any]) -> Settings:
|
|
49
|
+
"""Conserva cada campo válido y deja en su default solo los inválidos."""
|
|
50
|
+
defaults = Settings()
|
|
51
|
+
kept: dict[str, Any] = {}
|
|
52
|
+
for name in Settings.model_fields:
|
|
53
|
+
if name not in raw:
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
Settings.model_validate({name: raw[name]})
|
|
57
|
+
except ValidationError:
|
|
58
|
+
continue
|
|
59
|
+
kept[name] = raw[name]
|
|
60
|
+
return Settings.model_validate({**defaults.model_dump(), **kept})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_settings(path: Path | None = None) -> Settings:
|
|
64
|
+
path = path or settings_path()
|
|
65
|
+
try:
|
|
66
|
+
text = path.read_text(encoding="utf-8")
|
|
67
|
+
except OSError:
|
|
68
|
+
return Settings()
|
|
69
|
+
try:
|
|
70
|
+
raw = json.loads(text)
|
|
71
|
+
if not isinstance(raw, dict):
|
|
72
|
+
raise ValueError("settings.json no es un objeto")
|
|
73
|
+
except ValueError:
|
|
74
|
+
# Ilegible: se guarda una copia antes de que el próximo guardado lo
|
|
75
|
+
# sustituya, y la app arranca con los valores por defecto.
|
|
76
|
+
with contextlib.suppress(OSError):
|
|
77
|
+
atomic_write_text(path.with_name(path.name + ".bak"), text)
|
|
78
|
+
return Settings()
|
|
79
|
+
try:
|
|
80
|
+
return Settings.model_validate(raw)
|
|
81
|
+
except ValidationError:
|
|
82
|
+
# Un campo inválido no debe llevarse por delante el resto.
|
|
83
|
+
return _recover(raw)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save_settings(settings: Settings, path: Path | None = None) -> None:
|
|
87
|
+
path = path or settings_path()
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
atomic_write_text(path, settings.model_dump_json(indent=2))
|
pdftool/core/errors.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Traducción de excepciones a mensajes para el usuario (sin Flet).
|
|
2
|
+
|
|
3
|
+
Solo `UserError` se muestra tal cual: es el canal de los mensajes de dominio,
|
|
4
|
+
ya escritos en español para el usuario. Cualquier otra excepción, incluido un
|
|
5
|
+
`ValueError` de una librería, recibe un mensaje genérico y conserva el detalle
|
|
6
|
+
técnico para diagnóstico.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
_FILE_NOT_FOUND = (
|
|
12
|
+
"No se encontró el archivo. ¿Lo moviste o borraste? Vuelve a elegirlo."
|
|
13
|
+
)
|
|
14
|
+
_PERMISSION = (
|
|
15
|
+
"No hay permiso para leer o guardar el archivo. Ciérralo si lo tienes "
|
|
16
|
+
"abierto o elige otra carpeta en «Guardar en»."
|
|
17
|
+
)
|
|
18
|
+
GENERIC_MESSAGE = (
|
|
19
|
+
"No se pudo procesar el archivo. Puede estar dañado o tener contraseña "
|
|
20
|
+
"(puedes quitarla con «Proteger PDF»)."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UserError(ValueError):
|
|
25
|
+
"""Error de dominio con un mensaje listo para mostrar al usuario."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def user_message(exc: BaseException) -> tuple[str, str | None]:
|
|
29
|
+
"""(mensaje para el usuario, detalle técnico | None)."""
|
|
30
|
+
if isinstance(exc, UserError):
|
|
31
|
+
return str(exc), None
|
|
32
|
+
if isinstance(exc, FileNotFoundError):
|
|
33
|
+
return _FILE_NOT_FOUND, None
|
|
34
|
+
if isinstance(exc, PermissionError):
|
|
35
|
+
return _PERMISSION, None
|
|
36
|
+
return GENERIC_MESSAGE, f"{type(exc).__name__}: {exc}"
|
pdftool/core/jobs.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from concurrent.futures import CancelledError, Future, ThreadPoolExecutor, TimeoutError
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
_MAX_WORKERS = 2
|
|
11
|
+
_executor_lock = threading.Lock()
|
|
12
|
+
_active_jobs_lock = threading.Lock()
|
|
13
|
+
_executor: ThreadPoolExecutor | None = None
|
|
14
|
+
_log = logging.getLogger("pdftool.jobs")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JobCancelled(Exception):
|
|
18
|
+
"""Señala que el resultado de un job ya no debe continuar."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(eq=False)
|
|
22
|
+
class JobHandle:
|
|
23
|
+
"""Control cooperativo de un trabajo ejecutado en segundo plano."""
|
|
24
|
+
|
|
25
|
+
_cancel_event: threading.Event = field(default_factory=threading.Event)
|
|
26
|
+
_future: Future | None = None
|
|
27
|
+
|
|
28
|
+
def cancel(self) -> None:
|
|
29
|
+
"""Cancela una tarea en cola o pide parar una que ya está ejecutándose."""
|
|
30
|
+
self._cancel_event.set()
|
|
31
|
+
if self._future is not None and self._future.cancel():
|
|
32
|
+
_unregister(self)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def cancelled(self) -> bool:
|
|
36
|
+
return self._cancel_event.is_set()
|
|
37
|
+
|
|
38
|
+
def _set_future(self, future: Future) -> None:
|
|
39
|
+
self._future = future
|
|
40
|
+
if self.cancelled and future.cancel():
|
|
41
|
+
_unregister(self)
|
|
42
|
+
|
|
43
|
+
def join(self, timeout: float | None = None) -> None:
|
|
44
|
+
"""Espera al trabajo; útil para tests y cierre controlado."""
|
|
45
|
+
if self._future is None:
|
|
46
|
+
return
|
|
47
|
+
try:
|
|
48
|
+
self._future.result(timeout=timeout)
|
|
49
|
+
except (CancelledError, TimeoutError):
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_active_jobs: set[JobHandle] = set()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_executor() -> ThreadPoolExecutor:
|
|
57
|
+
global _executor
|
|
58
|
+
with _executor_lock:
|
|
59
|
+
if _executor is None:
|
|
60
|
+
_executor = ThreadPoolExecutor(
|
|
61
|
+
max_workers=_MAX_WORKERS, thread_name_prefix="pdftool-job"
|
|
62
|
+
)
|
|
63
|
+
return _executor
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _register(handle: JobHandle) -> None:
|
|
67
|
+
with _active_jobs_lock:
|
|
68
|
+
_active_jobs.add(handle)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _unregister(handle: JobHandle) -> None:
|
|
72
|
+
with _active_jobs_lock:
|
|
73
|
+
_active_jobs.discard(handle)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def shutdown_job_executor() -> None:
|
|
77
|
+
"""Cancela trabajos pendientes y libera los workers compartidos.
|
|
78
|
+
|
|
79
|
+
Los trabajos que ya están dentro de una llamada pesada no se pueden matar de
|
|
80
|
+
forma segura. Conservan la cancelación cooperativa y no volverán a notificar
|
|
81
|
+
a la UI cuando el cierre haya invalidado su handle.
|
|
82
|
+
"""
|
|
83
|
+
global _executor
|
|
84
|
+
with _executor_lock:
|
|
85
|
+
executor = _executor
|
|
86
|
+
_executor = None
|
|
87
|
+
with _active_jobs_lock:
|
|
88
|
+
handles = list(_active_jobs)
|
|
89
|
+
for handle in handles:
|
|
90
|
+
handle.cancel()
|
|
91
|
+
if executor is not None:
|
|
92
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _notify(callback: Callable, value: object) -> None:
|
|
96
|
+
# Un fallo aquí es un bug de la UI: sin este registro se perdería en un
|
|
97
|
+
# Future que nadie lee y el panel quedaría en «Procesando…».
|
|
98
|
+
try:
|
|
99
|
+
callback(value)
|
|
100
|
+
except Exception:
|
|
101
|
+
_log.exception("Error en el callback de un trabajo")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(eq=False)
|
|
105
|
+
class _Job:
|
|
106
|
+
"""Estado de una ejecución: callbacks, vigencia y ritmo del progreso."""
|
|
107
|
+
|
|
108
|
+
handle: JobHandle
|
|
109
|
+
work: Callable
|
|
110
|
+
on_progress: Callable
|
|
111
|
+
on_done: Callable
|
|
112
|
+
on_error: Callable
|
|
113
|
+
is_current: Callable[[], bool] | None
|
|
114
|
+
progress_interval: float
|
|
115
|
+
clock: Callable[[], float]
|
|
116
|
+
_last_progress: float | None = None
|
|
117
|
+
|
|
118
|
+
def still_current(self) -> bool:
|
|
119
|
+
if self.handle.cancelled:
|
|
120
|
+
return False
|
|
121
|
+
return self.is_current is None or self.is_current()
|
|
122
|
+
|
|
123
|
+
def progress(self, pct: float, message: str) -> None:
|
|
124
|
+
if not self.still_current():
|
|
125
|
+
raise JobCancelled()
|
|
126
|
+
now = self.clock()
|
|
127
|
+
previous = self._last_progress
|
|
128
|
+
if previous is None or pct >= 1.0 or now - previous >= self.progress_interval:
|
|
129
|
+
self._last_progress = now
|
|
130
|
+
self.on_progress(pct, message)
|
|
131
|
+
|
|
132
|
+
def run(self) -> None:
|
|
133
|
+
try:
|
|
134
|
+
result = self.work(self.progress)
|
|
135
|
+
except JobCancelled:
|
|
136
|
+
return
|
|
137
|
+
except Exception as exc: # se reenvía a on_error
|
|
138
|
+
if self.still_current():
|
|
139
|
+
_notify(self.on_error, exc)
|
|
140
|
+
else:
|
|
141
|
+
if self.still_current():
|
|
142
|
+
_notify(self.on_done, result)
|
|
143
|
+
finally:
|
|
144
|
+
_unregister(self.handle)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def run_job(
|
|
148
|
+
work: Callable,
|
|
149
|
+
on_progress: Callable,
|
|
150
|
+
on_done: Callable,
|
|
151
|
+
on_error: Callable,
|
|
152
|
+
*,
|
|
153
|
+
is_current: Callable[[], bool] | None = None,
|
|
154
|
+
progress_interval: float = 0.1,
|
|
155
|
+
clock: Callable[[], float] = time.monotonic,
|
|
156
|
+
) -> JobHandle:
|
|
157
|
+
"""Ejecuta un trabajo en el executor compartido con cancelación cooperativa.
|
|
158
|
+
|
|
159
|
+
`work` recibe el callback de progreso y devuelve un resultado. La cola está
|
|
160
|
+
acotada en concurrencia para no crear un hilo por cada interacción; cancelar
|
|
161
|
+
un handle descarta una tarea pendiente y silencia los callbacks de una tarea
|
|
162
|
+
que ya haya empezado.
|
|
163
|
+
|
|
164
|
+
`on_progress` se limita a una llamada cada `progress_interval` segundos
|
|
165
|
+
(cada una cuesta un repintado de la UI); la primera y la final (pct >= 1)
|
|
166
|
+
siempre pasan. La cancelación se comprueba en cada llamada igualmente.
|
|
167
|
+
"""
|
|
168
|
+
handle = JobHandle()
|
|
169
|
+
_register(handle)
|
|
170
|
+
job = _Job(
|
|
171
|
+
handle,
|
|
172
|
+
work,
|
|
173
|
+
on_progress,
|
|
174
|
+
on_done,
|
|
175
|
+
on_error,
|
|
176
|
+
is_current,
|
|
177
|
+
progress_interval,
|
|
178
|
+
clock,
|
|
179
|
+
)
|
|
180
|
+
try:
|
|
181
|
+
future = _get_executor().submit(job.run)
|
|
182
|
+
except RuntimeError as exc:
|
|
183
|
+
_unregister(handle)
|
|
184
|
+
if job.still_current():
|
|
185
|
+
on_error(exc)
|
|
186
|
+
else:
|
|
187
|
+
handle._set_future(future)
|
|
188
|
+
return handle
|
pdftool/core/logger.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from logging.handlers import RotatingFileHandler
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pdftool.core.config import data_dir
|
|
9
|
+
|
|
10
|
+
_LOGGER_NAME = "pdftool"
|
|
11
|
+
_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
|
|
12
|
+
|
|
13
|
+
_DOC_EXT = r"pdf|jpe?g|png"
|
|
14
|
+
|
|
15
|
+
# Redacción por capas, aplicadas EN ORDEN sobre el texto ya formateado
|
|
16
|
+
# (incluye tracebacks). El orden importa:
|
|
17
|
+
# 1) rutas/archivos entre comillas simples primero: los mensajes de
|
|
18
|
+
# excepción suelen citarlas y las comillas delimitan nombres con
|
|
19
|
+
# espacios que un patrón sin comillas no podría acotar.
|
|
20
|
+
# 2) rutas sin comillas, consumiendo la ruta COMPLETA (no solo el
|
|
21
|
+
# segmento de usuario) para no dejar carpetas/nombres de archivo tras
|
|
22
|
+
# el "~".
|
|
23
|
+
# 3) nombres de archivo de documento sueltos (sin ruta ni comillas),
|
|
24
|
+
# incluida la cola de un nombre partido por espacios.
|
|
25
|
+
_REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
26
|
+
# 1) rutas o nombres citados
|
|
27
|
+
(re.compile(r"'[^'\n]*[/\\][^'\n]*'"), "'[ruta]'"),
|
|
28
|
+
(re.compile(rf"'[^'\n]*\.({_DOC_EXT})'", re.IGNORECASE), "'[archivo]'"),
|
|
29
|
+
# 2) rutas completas sin comillas
|
|
30
|
+
(re.compile(re.escape(str(Path.home())) + r"\S*"), "~"),
|
|
31
|
+
(re.compile(r"/Users/\S+"), "~"),
|
|
32
|
+
(re.compile(r"[A-Za-z]:[\\/]Users[\\/]\S+"), "~"),
|
|
33
|
+
# 3) nombres de archivo sueltos (sin ruta)
|
|
34
|
+
(re.compile(rf"\S+\.({_DOC_EXT})\b", re.IGNORECASE), "[archivo]"),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def sanitize(text: str) -> str:
|
|
39
|
+
"""Redacta rutas y nombres de documentos personales de cualquier texto.
|
|
40
|
+
|
|
41
|
+
Aplica las capas de `_REDACTION_PATTERNS` en orden: primero lo citado
|
|
42
|
+
entre comillas, luego rutas completas sin comillas, y por último
|
|
43
|
+
nombres de archivo de documento sueltos. Ninguna capa debe dejar
|
|
44
|
+
filtrar carpetas ni nombres de archivo del usuario.
|
|
45
|
+
"""
|
|
46
|
+
for pattern, replacement in _REDACTION_PATTERNS:
|
|
47
|
+
text = pattern.sub(replacement, text)
|
|
48
|
+
return text
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _SanitizingFormatter(logging.Formatter):
|
|
52
|
+
"""Sanitiza la línea completa ya formateada (cubre también el traceback)."""
|
|
53
|
+
|
|
54
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
55
|
+
return sanitize(super().format(record))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def log_dir() -> Path:
|
|
59
|
+
d = data_dir() / "logs"
|
|
60
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
return d
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def log_paths() -> list[Path]:
|
|
65
|
+
"""Archivos de log existentes, backup primero (orden cronológico)."""
|
|
66
|
+
base = log_dir() / "pdf-tool.log"
|
|
67
|
+
backup = base.parent / (base.name + ".1") # como lo nombra RotatingFileHandler
|
|
68
|
+
return [p for p in (backup, base) if p.exists()]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def setup_logging() -> None:
|
|
72
|
+
"""Configura el logger "pdftool" una sola vez (idempotente)."""
|
|
73
|
+
logger = logging.getLogger(_LOGGER_NAME)
|
|
74
|
+
if logger.handlers:
|
|
75
|
+
return
|
|
76
|
+
logger.setLevel(logging.INFO)
|
|
77
|
+
logger.propagate = False # sin ruido de/para el root (pymupdf, flet)
|
|
78
|
+
# Histórico mínimo a propósito (~32 KB total): basta para diagnosticar el
|
|
79
|
+
# último fallo sin acumular una bitácora que parezca vigilancia del usuario.
|
|
80
|
+
handler = RotatingFileHandler(
|
|
81
|
+
log_dir() / "pdf-tool.log", maxBytes=16_000, backupCount=1, encoding="utf-8"
|
|
82
|
+
)
|
|
83
|
+
handler.setFormatter(_SanitizingFormatter(_FORMAT))
|
|
84
|
+
logger.addHandler(handler)
|
pdftool/core/naming.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pdftool.core.errors import UserError
|
|
7
|
+
|
|
8
|
+
_FORBIDDEN_NAME_CHARS = set('/\\:?|<>*"\x00')
|
|
9
|
+
|
|
10
|
+
# Nombres que Windows no permite como archivo, con o sin extensión.
|
|
11
|
+
_WINDOWS_RESERVED_NAMES = frozenset(
|
|
12
|
+
{"CON", "PRN", "AUX", "NUL"}
|
|
13
|
+
| {f"COM{i}" for i in range(1, 10)}
|
|
14
|
+
| {f"LPT{i}" for i in range(1, 10)}
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_output_name(value: str | None) -> str | None:
|
|
19
|
+
"""Sanitiza el nombre de salida escrito por el usuario.
|
|
20
|
+
|
|
21
|
+
None si quedó vacío (→ nombre default); sin extensión .pdf (se añade sola
|
|
22
|
+
en la lógica). Lanza `UserError` si trae separadores de ruta o un nombre
|
|
23
|
+
reservado por Windows.
|
|
24
|
+
"""
|
|
25
|
+
name = (value or "").strip()
|
|
26
|
+
if name.lower().endswith(".pdf"):
|
|
27
|
+
name = name[:-4].strip()
|
|
28
|
+
if not name:
|
|
29
|
+
return None
|
|
30
|
+
if any(c in _FORBIDDEN_NAME_CHARS for c in name):
|
|
31
|
+
raise UserError(
|
|
32
|
+
'Nombre de salida inválido: no puede contener / \\ : ? | < > * "'
|
|
33
|
+
)
|
|
34
|
+
if name.upper() in _WINDOWS_RESERVED_NAMES:
|
|
35
|
+
raise UserError(
|
|
36
|
+
f"Nombre de salida inválido: «{name}» está reservado por Windows."
|
|
37
|
+
)
|
|
38
|
+
return name
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def unique_path(candidate: Path, taken: Iterable[Path] = ()) -> Path:
|
|
42
|
+
"""Primera ruta libre a partir de `candidate`, con sufijo « (n)».
|
|
43
|
+
|
|
44
|
+
Libre = no existe en disco y no está en `taken`. `taken` cubre rutas que
|
|
45
|
+
aún no se han escrito (p. ej. los archivos de entrada de un lote).
|
|
46
|
+
"""
|
|
47
|
+
candidate = Path(candidate)
|
|
48
|
+
blocked = {Path(p) for p in taken}
|
|
49
|
+
|
|
50
|
+
def is_free(p: Path) -> bool:
|
|
51
|
+
return p not in blocked and not p.exists()
|
|
52
|
+
|
|
53
|
+
if is_free(candidate):
|
|
54
|
+
return candidate
|
|
55
|
+
n = 1
|
|
56
|
+
while True:
|
|
57
|
+
alt = candidate.with_name(f"{candidate.stem} ({n}){candidate.suffix}")
|
|
58
|
+
if is_free(alt):
|
|
59
|
+
return alt
|
|
60
|
+
n += 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def output_path(
|
|
64
|
+
input_path: Path,
|
|
65
|
+
suffix: str,
|
|
66
|
+
*,
|
|
67
|
+
stem: str | None = None,
|
|
68
|
+
out_dir: Path | None = None,
|
|
69
|
+
) -> Path:
|
|
70
|
+
"""`<stem>_<suffix>.pdf` en `out_dir` si se indica, o junto al original; sin pisar nada.
|
|
71
|
+
|
|
72
|
+
`stem` sobrescribe el del archivo de entrada (Comprimir lo usa para no
|
|
73
|
+
acumular sufijos al reprocesar); `None` significa "usa el del archivo de
|
|
74
|
+
entrada", pero una cadena vacía es un valor válido y se respeta tal cual.
|
|
75
|
+
Es el único punto donde se decide dónde va una salida de nombre
|
|
76
|
+
automático; `taken` garantiza que nunca sea la entrada, con independencia
|
|
77
|
+
de lo que haya en disco.
|
|
78
|
+
|
|
79
|
+
`out_dir` es `None` → junto al original; una ruta → todas las salidas van
|
|
80
|
+
allí.
|
|
81
|
+
|
|
82
|
+
`taken` solo cubre la propia entrada, no el resto de un lote: eso es
|
|
83
|
+
seguro porque cada herramienta valida que todos los archivos de entrada
|
|
84
|
+
existan en disco antes de calcular ninguna ruta de salida, y porque cada
|
|
85
|
+
salida se escribe antes de resolver la siguiente ruta, así que
|
|
86
|
+
`unique_path` ya la ve en disco.
|
|
87
|
+
"""
|
|
88
|
+
p = Path(input_path)
|
|
89
|
+
carpeta = Path(out_dir) if out_dir is not None else p.parent
|
|
90
|
+
stem_final = stem if stem is not None else p.stem
|
|
91
|
+
return unique_path(carpeta / f"{stem_final}_{suffix}.pdf", taken=(p,))
|