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