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/core/plugin.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Callable, Mapping
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from pdftool.core.config import Settings
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class FileResult:
15
+ """Resultado estructurado de una entrada individual de un lote."""
16
+
17
+ input_path: Path
18
+ output_path: Path | None
19
+ ok: bool
20
+ message: str
21
+ # Avisos para el usuario (p. ej. «se rasterizaron páginas») y cifras del
22
+ # resultado (p. ej. original_mb, final_mb, saved_pct) sin parsear texto.
23
+ warnings: tuple[str, ...] = ()
24
+ metrics: Mapping[str, float] = field(default_factory=dict)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ToolMeta:
29
+ id: str
30
+ name: str
31
+ description: str
32
+ category: str
33
+ # Posición en la navegación (menor primero); a igualdad, por nombre.
34
+ order: int = 100
35
+
36
+
37
+ @dataclass
38
+ class ToolResult:
39
+ outputs: list[Path]
40
+ summary: str
41
+ # Resultados por entrada para herramientas por lote. None si no aplica
42
+ # (p. ej. una herramienta de archivo único).
43
+ items: list[FileResult] | None = None
44
+ # Avisos del run completo (se muestran bajo el resumen) y cifras agregadas.
45
+ warnings: list[str] = field(default_factory=list)
46
+ metrics: dict[str, float] = field(default_factory=dict)
47
+
48
+
49
+ class BaseParams(BaseModel):
50
+ """Campos comunes a toda ejecución.
51
+
52
+ `output_dir` None → la salida va junto al archivo de entrada (el default de
53
+ siempre); una ruta → todas las salidas van a esa carpeta.
54
+ """
55
+
56
+ output_dir: Path | None = Field(
57
+ default=None,
58
+ description="Carpeta de salida (por defecto, junto al original).",
59
+ )
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ToolSpec:
64
+ """Definición de una herramienta sin UI: la comparten la app y la CLI."""
65
+
66
+ meta: ToolMeta
67
+ params: type[BaseParams]
68
+ # run(inputs: list[Path], params, progress=...) -> ToolResult
69
+ run: Callable[..., ToolResult]
70
+ # Reglas de entrada que comparten la app y la CLI: cuántos archivos
71
+ # (max None = sin límite) y con qué extensiones.
72
+ min_inputs: int = 1
73
+ max_inputs: int | None = None
74
+ extensions: tuple[str, ...] = ("pdf",)
75
+
76
+
77
+ # Reporta avance: fracción 0..1 y un mensaje de estado.
78
+ Progress = Callable[[float, str], None]
79
+
80
+
81
+ @dataclass
82
+ class ToolContext:
83
+ """Lo que el host presta a cada panel."""
84
+
85
+ page: object # ft.Page (evitamos importar flet en el core)
86
+ run_job: Callable # (work, on_progress, on_done, on_error) -> None
87
+ settings: Settings | None = None
88
+
89
+
90
+ class PdfTool(ABC):
91
+ meta: ToolMeta
92
+
93
+ @abstractmethod
94
+ def build_panel(self, ctx: ToolContext):
95
+ """Devuelve un control Flet con la UI de la herramienta."""
96
+ raise NotImplementedError
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import logging
5
+ import pkgutil
6
+
7
+ from pdftool.core.plugin import ToolSpec
8
+
9
+ # Registro de herramientas sin Flet: lo comparten la app y la CLI. Los paneles
10
+ # de la app tienen su propio registro en `pdftool.ui.registry`.
11
+ _REGISTRY: dict[str, ToolSpec] = {}
12
+ # Todo lo registrado alguna vez, por módulo: `clear()` vacía el registro pero un
13
+ # módulo ya importado no vuelve a ejecutar su register(), así que `discover()`
14
+ # las recupera de aquí.
15
+ _SEEN: dict[str, list[ToolSpec]] = {}
16
+ _log = logging.getLogger("pdftool.registry")
17
+
18
+
19
+ def register(spec: ToolSpec) -> ToolSpec:
20
+ tool_id = spec.meta.id
21
+ current = _REGISTRY.get(tool_id)
22
+ if current is not None and current is not spec:
23
+ raise ValueError(f"Id de herramienta duplicado: {tool_id!r}")
24
+ _REGISTRY[tool_id] = spec
25
+ module = getattr(spec.run, "__module__", None) or ""
26
+ seen = _SEEN.setdefault(module, [])
27
+ if spec not in seen:
28
+ seen.append(spec)
29
+ return spec
30
+
31
+
32
+ def clear() -> None:
33
+ _REGISTRY.clear()
34
+
35
+
36
+ def get_specs() -> list[ToolSpec]:
37
+ """Specs ordenadas por `meta.order` y, a igualdad, por nombre."""
38
+ return sorted(_REGISTRY.values(), key=lambda s: (s.meta.order, s.meta.name))
39
+
40
+
41
+ def discover(package: str = "pdftool.tools") -> list[str]:
42
+ """Importa el `spec.py` de cada subpaquete de `package` para registrarlo.
43
+
44
+ Una herramienta que falla al importarse se registra en el log y se omite.
45
+ Devuelve los nombres que fallaron.
46
+ """
47
+ pkg = importlib.import_module(package)
48
+ failed: list[str] = []
49
+ for mod in pkgutil.iter_modules(pkg.__path__):
50
+ try:
51
+ importlib.import_module(f"{package}.{mod.name}.spec")
52
+ except Exception:
53
+ _log.exception("No se pudo cargar la herramienta %s", mod.name)
54
+ failed.append(mod.name)
55
+ for module, specs in list(_SEEN.items()):
56
+ if module == package or module.startswith(f"{package}."):
57
+ for spec in specs:
58
+ _REGISTRY.setdefault(spec.meta.id, spec)
59
+ return failed
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pymupdf
6
+
7
+ THUMBNAIL_HEIGHT_PX = 56
8
+ # Tope de proporción: una página degenerada (p. ej. 14400×1 pt) pediría un
9
+ # pixmap de cientos de miles de píxeles de ancho al escalar solo por el alto.
10
+ _MAX_ASPECT = 4
11
+
12
+
13
+ def render_thumbnail(
14
+ path: Path, page_index: int = 0, height_px: int = THUMBNAIL_HEIGHT_PX
15
+ ) -> bytes | None:
16
+ """PNG de la página `page_index` a `height_px` de alto; None si no se puede.
17
+
18
+ El fallo (protegido, corrupto, página inexistente) es un estado esperado del
19
+ dominio — el consumidor muestra un icono genérico — por eso devuelve None en
20
+ vez de lanzar. `page_index` existe para las futuras vistas de páginas
21
+ (rotar/reordenar/dividir); hoy solo se consume con 0.
22
+ """
23
+ try:
24
+ with pymupdf.open(str(path)) as doc:
25
+ if doc.needs_pass or not 0 <= page_index < doc.page_count:
26
+ return None
27
+ page = doc[page_index]
28
+ if page.rect.height <= 0 or page.rect.width <= 0:
29
+ return None
30
+ zoom = min(
31
+ height_px / page.rect.height,
32
+ height_px * _MAX_ASPECT / page.rect.width,
33
+ )
34
+ pix = page.get_pixmap(matrix=pymupdf.Matrix(zoom, zoom), alpha=False)
35
+ return pix.tobytes("png")
36
+ except Exception: # corrupto/ilegible: estado esperado, no excepción
37
+ return None
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import urllib.request
6
+ from collections.abc import Callable
7
+
8
+ GITHUB_REPO = "juanMaAV92/pdf-tool"
9
+
10
+ # La respuesta de /releases/latest pesa unos KB; un tope evita leer sin límite.
11
+ _MAX_RESPONSE_BYTES = 1_000_000
12
+ # vX.Y.Z con sufijo opcional de preversión/metadatos.
13
+ _TAG = re.compile(r"^v?\d+(\.\d+){0,3}([-+][0-9A-Za-z.-]+)?$")
14
+
15
+
16
+ def _parse(version: str) -> tuple[int, ...]:
17
+ # Toma los dígitos iniciales de cada segmento e ignora sufijos (p.ej.
18
+ # "0-beta" -> 0), para no reventar con tags de preversión.
19
+ parts = version.lstrip("vV").split(".")
20
+ return tuple(
21
+ (int(m.group()) if (m := re.match(r"\d+", part)) else 0) for part in parts
22
+ )
23
+
24
+
25
+ def is_newer(latest: str, current: str) -> bool:
26
+ a, b = _parse(latest), _parse(current)
27
+ # Rellena con ceros para comparar "1.2" y "1.2.0" como iguales.
28
+ n = max(len(a), len(b))
29
+ a += (0,) * (n - len(a))
30
+ b += (0,) * (n - len(b))
31
+ return a > b
32
+
33
+
34
+ def _default_get(url: str) -> dict:
35
+ req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"})
36
+ with urllib.request.urlopen(req, timeout=5) as resp: # URL https fija
37
+ body = resp.read(_MAX_RESPONSE_BYTES + 1)
38
+ if len(body) > _MAX_RESPONSE_BYTES:
39
+ raise ValueError("Respuesta de GitHub demasiado grande")
40
+ return json.loads(body.decode("utf-8"))
41
+
42
+
43
+ def check_for_update(
44
+ current: str, repo: str, http_get: Callable[[str], dict] = _default_get
45
+ ) -> str | None:
46
+ """Devuelve la URL del release si hay una versión más nueva, si no None.
47
+
48
+ Nunca lanza: ante cualquier error (offline, formato raro) devuelve None.
49
+ La URL solo se acepta si es una página de releases de `repo` en GitHub:
50
+ la app la abre en el navegador, así que no debe poder ser otra cosa.
51
+ """
52
+ url = f"https://api.github.com/repos/{repo}/releases/latest"
53
+ try:
54
+ data = http_get(url)
55
+ tag, html_url = data["tag_name"], data["html_url"]
56
+ if not (isinstance(tag, str) and _TAG.match(tag)):
57
+ return None
58
+ if not (
59
+ isinstance(html_url, str)
60
+ and html_url.startswith(f"https://github.com/{repo}/releases/")
61
+ ):
62
+ return None
63
+ if is_newer(tag, current):
64
+ return html_url
65
+ return None
66
+ except Exception: # el chequeo de update nunca debe romper la app
67
+ return None
pdftool/main.py ADDED
@@ -0,0 +1,30 @@
1
+ import logging
2
+ import sys
3
+
4
+ from pdftool import __version__
5
+ from pdftool.core.logger import setup_logging
6
+
7
+ GUI_MISSING = (
8
+ "La app de escritorio necesita Flet, que no está instalado.\n"
9
+ "Instálala con: pipx install 'pdf-tool-cli[gui]'\n"
10
+ "Para la línea de comandos usa: pdf-tool --help"
11
+ )
12
+
13
+
14
+ def main() -> None:
15
+ # Flet es opcional (extra [gui]): `pip install pdf-tool-cli` trae solo la CLI.
16
+ try:
17
+ import flet as ft
18
+
19
+ from pdftool.ui.app import build_app
20
+ except ImportError:
21
+ print(GUI_MISSING, file=sys.stderr)
22
+ sys.exit(1)
23
+
24
+ setup_logging()
25
+ logging.getLogger("pdftool").info("app v%s iniciada", __version__)
26
+ ft.app(target=build_app)
27
+
28
+
29
+ if __name__ == "__main__":
30
+ main()
File without changes
File without changes
@@ -0,0 +1,387 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import tempfile
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from functools import partial
9
+ from pathlib import Path
10
+
11
+ import pymupdf
12
+
13
+ from pdftool.core.atomic import atomic_copy
14
+ from pdftool.core.batch import Outcome, noop_progress, run_batch
15
+ from pdftool.core.jobs import JobCancelled
16
+ from pdftool.core.naming import output_path
17
+ from pdftool.core.plugin import Progress, ToolResult
18
+ from pdftool.tools.compress.params import CompressParams
19
+
20
+ _ATTEMPTS = [
21
+ {"max_dimension": 2000, "jpg_quality": 85},
22
+ {"max_dimension": 1800, "jpg_quality": 75},
23
+ {"max_dimension": 1500, "jpg_quality": 65},
24
+ {"max_dimension": 1200, "jpg_quality": 55},
25
+ {"max_dimension": 1000, "jpg_quality": 45},
26
+ {"max_dimension": 800, "jpg_quality": 35},
27
+ {"max_dimension": 600, "jpg_quality": 25},
28
+ ]
29
+
30
+ _PRESERVE_ATTEMPTS = [
31
+ {"dpi_threshold": 150, "dpi_target": 120, "quality": 75},
32
+ {"dpi_threshold": 120, "dpi_target": 96, "quality": 60},
33
+ {"dpi_threshold": 96, "dpi_target": 72, "quality": 45},
34
+ ]
35
+
36
+
37
+ def _size_mb(path: Path) -> float:
38
+ return path.stat().st_size / (1024 * 1024)
39
+
40
+
41
+ def _target_label(target_mb: float) -> str:
42
+ if target_mb.is_integer():
43
+ return f"{int(target_mb)}MB"
44
+ return f"{target_mb:g}MB".replace(".", "_")
45
+
46
+
47
+ # También absorbe un « (n)» final (ver _clean_stem). Esto puede recortar de
48
+ # más un nombre de usuario legítimo que combine un token «_NMB» con un « (n)»
49
+ # final (p. ej. «presupuesto_2MB (1).pdf» -> «presupuesto»), pero nunca causa
50
+ # pérdida de archivos: `output_path` pasa `taken=(p,)`, así que el nombre
51
+ # limpio jamás puede resolver de vuelta a la propia entrada. Como mucho el
52
+ # resultado es un nombre cosméticamente raro.
53
+ _STEM_NOISE = re.compile(r"(_compressed|_\d+(?:_\d+)?MB)+( \(\d+\))?$")
54
+
55
+
56
+ def _clean_stem(stem: str) -> str:
57
+ """Quita sufijos de compresiones previas para que no se acumulen.
58
+
59
+ Absorbe también un « (n)» final, para que recomprimir una salida ya
60
+ desambiguada no produzca «doc_5MB (1)_2MB.pdf».
61
+ """
62
+ return _STEM_NOISE.sub("", stem)
63
+
64
+
65
+ def output_path_for(
66
+ input_path: Path, target_mb: float, out_dir: Path | None = None
67
+ ) -> Path:
68
+ """Política de nombre de Comprimir; la colisión la resuelve el helper."""
69
+ p = Path(input_path)
70
+ return output_path(
71
+ p, _target_label(target_mb), stem=_clean_stem(p.stem), out_dir=out_dir
72
+ )
73
+
74
+
75
+ def _simple_compress(src: Path, dst: Path) -> None:
76
+ with pymupdf.open(src) as doc:
77
+ doc.save(str(dst), garbage=4, deflate=True, clean=True)
78
+
79
+
80
+ # Páginas que se acumulan en memoria antes de volcar el PDF de salida a disco.
81
+ # Sin este volcado, el documento entero (todas sus imágenes JPEG) vive en RAM
82
+ # hasta el final: ~0,3 MB por página en un A4.
83
+ _RERENDER_CHUNK = 50
84
+
85
+
86
+ def _rerender(
87
+ src: Path,
88
+ dst: Path,
89
+ *,
90
+ max_dimension: int,
91
+ jpg_quality: int,
92
+ progress: Progress = noop_progress,
93
+ ) -> None:
94
+ with pymupdf.open(src) as source:
95
+ total = len(source)
96
+ doc = pymupdf.open()
97
+ flushed = False
98
+ try:
99
+ for page_num in range(total):
100
+ src_page = source[page_num]
101
+ rect = src_page.rect
102
+ if rect.width > 0 and rect.height > 0:
103
+ # Sin zoom mínimo: el lado mayor nunca pasa de `max_dimension`,
104
+ # así una página enorme no se convierte en decenas de MP.
105
+ zoom = min(
106
+ max_dimension / rect.width, max_dimension / rect.height, 2.0
107
+ )
108
+ pix = src_page.get_pixmap(
109
+ matrix=pymupdf.Matrix(zoom, zoom), alpha=False
110
+ )
111
+ img_bytes = pix.tobytes("jpg", jpg_quality=jpg_quality)
112
+ new_page = doc.new_page(width=rect.width, height=rect.height)
113
+ new_page.insert_image(new_page.rect, stream=img_bytes)
114
+ done = page_num + 1
115
+ if done % _RERENDER_CHUNK == 0 and done < total:
116
+ # Volcar a disco y reabrir libera las imágenes ya escritas.
117
+ if flushed:
118
+ doc.saveIncr()
119
+ else:
120
+ doc.save(str(dst))
121
+ flushed = True
122
+ doc.close()
123
+ doc = pymupdf.open(str(dst))
124
+ # Un punto de progreso (y de cancelación) por página: un intento
125
+ # sobre un PDF largo puede durar decenas de segundos.
126
+ progress(done / total, f"página {done} de {total}")
127
+ if not flushed:
128
+ doc.save(str(dst), garbage=4, deflate=True)
129
+ return
130
+ doc.saveIncr()
131
+ finally:
132
+ doc.close()
133
+ # Los volcados incrementales dejan versiones intermedias: se compacta.
134
+ compact = dst.with_name(f".{dst.name}.compact")
135
+ try:
136
+ with pymupdf.open(str(dst)) as doc:
137
+ doc.save(str(compact), garbage=4, deflate=True)
138
+ os.replace(compact, dst)
139
+ finally:
140
+ compact.unlink(missing_ok=True)
141
+
142
+
143
+ def _preserve_compress(
144
+ src: Path, dst: Path, *, dpi_threshold: int, dpi_target: int, quality: int
145
+ ) -> None:
146
+ """Optimiza imágenes sin convertir las páginas completas en imágenes."""
147
+ with pymupdf.open(src) as doc:
148
+ rewrite_images = getattr(doc, "rewrite_images", None)
149
+ if rewrite_images is not None:
150
+ rewrite_images(
151
+ dpi_threshold=dpi_threshold,
152
+ dpi_target=dpi_target,
153
+ quality=quality,
154
+ )
155
+ doc.save(
156
+ str(dst),
157
+ garbage=4,
158
+ deflate=True,
159
+ deflate_images=True,
160
+ deflate_fonts=True,
161
+ clean=True,
162
+ )
163
+
164
+
165
+ class _Candidates:
166
+ """Conserva solo el intento más pequeño dentro de una carpeta temporal.
167
+
168
+ Los intentos no tocan la carpeta del usuario: solo el ganador se publica,
169
+ una vez, al final.
170
+ """
171
+
172
+ def __init__(self, workdir: Path) -> None:
173
+ self._workdir = workdir
174
+ self._count = 0
175
+ self.best: Path | None = None
176
+ self.best_mb = float("inf")
177
+ # Si el ganador convirtió páginas en imagen (pierden texto y enlaces).
178
+ self.best_rasterized = False
179
+
180
+ def attempt(
181
+ self, make: Callable[[Path], None], *, rasterizes: bool = False
182
+ ) -> float:
183
+ self._count += 1
184
+ candidate = self._workdir / f"intento-{self._count}.pdf"
185
+ make(candidate)
186
+ size = _size_mb(candidate)
187
+ if size < self.best_mb:
188
+ if self.best is not None:
189
+ self.best.unlink(missing_ok=True)
190
+ self.best, self.best_mb = candidate, size
191
+ self.best_rasterized = rasterizes
192
+ else:
193
+ candidate.unlink(missing_ok=True)
194
+ return size
195
+
196
+
197
+ RASTERIZED_WARNING = (
198
+ "Algunas páginas se convirtieron en imagen: puede haberse perdido texto "
199
+ "seleccionable, enlaces o anotaciones. Si los necesitas, usa «Conservar "
200
+ "texto y enlaces»."
201
+ )
202
+
203
+
204
+ @dataclass(frozen=True)
205
+ class _Compressed:
206
+ message: str
207
+ original_mb: float
208
+ final_mb: float
209
+ rasterized: bool
210
+
211
+ @property
212
+ def saved_pct(self) -> float:
213
+ if self.original_mb <= 0:
214
+ return 0.0
215
+ return max(0.0, (self.original_mb - self.final_mb) / self.original_mb * 100)
216
+
217
+ def outcome(self, out: Path) -> Outcome:
218
+ return Outcome(
219
+ out,
220
+ self.message,
221
+ warnings=(RASTERIZED_WARNING,) if self.rasterized else (),
222
+ metrics={
223
+ "original_mb": self.original_mb,
224
+ "final_mb": self.final_mb,
225
+ "saved_pct": self.saved_pct,
226
+ },
227
+ )
228
+
229
+
230
+ def _saved_label(original: float, final: float) -> str:
231
+ saved = (original - final) / original * 100 if original > 0 else 0.0
232
+ return f" · {saved:.0f} % menos" if saved >= 0.5 else ""
233
+
234
+
235
+ def _compress_one(
236
+ input_path: Path,
237
+ out: Path,
238
+ target_mb: float,
239
+ progress: Progress,
240
+ mode: str = "max",
241
+ ) -> _Compressed:
242
+ original = _size_mb(input_path)
243
+ progress(0.0, f"Tamaño original: {original:.2f} MB")
244
+
245
+ if original <= target_mb:
246
+ atomic_copy(input_path, out)
247
+ progress(1.0, "Ya está bajo el objetivo")
248
+ return _Compressed(
249
+ f"{original:.2f} MB (sin cambios)", original, original, False
250
+ )
251
+
252
+ # Los intentos van a una carpeta oculta junto al destino: usan el mismo
253
+ # disco que la salida (no el del sistema) y el ganador se publica con un
254
+ # `os.replace` atómico, sin copiarlo.
255
+ with tempfile.TemporaryDirectory(prefix=".pdf-tool-", dir=out.parent) as workdir:
256
+ candidates = _Candidates(Path(workdir))
257
+
258
+ def publish(note: str = "") -> _Compressed:
259
+ assert candidates.best is not None
260
+ os.replace(candidates.best, out)
261
+ final = candidates.best_mb
262
+ progress(1.0, f"Listo: {final:.2f} MB")
263
+ return _Compressed(
264
+ f"{original:.2f} MB → {final:.2f} MB"
265
+ f"{_saved_label(original, final)}{note}",
266
+ original,
267
+ final,
268
+ candidates.best_rasterized,
269
+ )
270
+
271
+ progress(0.05, "Optimizando la estructura del PDF…")
272
+ if (
273
+ candidates.attempt(lambda dst: _simple_compress(input_path, dst))
274
+ <= target_mb
275
+ ):
276
+ return publish()
277
+
278
+ if mode == "preserve":
279
+ n = len(_PRESERVE_ATTEMPTS)
280
+ for i, attempt in enumerate(_PRESERVE_ATTEMPTS):
281
+ progress(
282
+ 0.1 + 0.9 * i / n, f"Reduciendo imágenes (paso {i + 1} de {n})…"
283
+ )
284
+ size = candidates.attempt(
285
+ partial(
286
+ _preserve_compress,
287
+ input_path,
288
+ dpi_threshold=attempt["dpi_threshold"],
289
+ dpi_target=attempt["dpi_target"],
290
+ quality=attempt["quality"],
291
+ )
292
+ )
293
+ if size <= target_mb:
294
+ return publish(" (contenido preservado)")
295
+ return publish(
296
+ " (no se alcanzó el objetivo; se guardó la versión más pequeña "
297
+ "con el contenido preservado)"
298
+ )
299
+
300
+ n = len(_ATTEMPTS)
301
+ for i, attempt in enumerate(_ATTEMPTS):
302
+ base = 0.1 + 0.9 * i / n
303
+ step = f"Reduciendo calidad (paso {i + 1} de {n})"
304
+ progress(base, f"{step}…")
305
+
306
+ def step_progress(
307
+ pct: float, msg: str, _base: float = base, _step: str = step
308
+ ) -> None:
309
+ progress(_base + 0.9 / n * pct, f"{_step}: {msg}")
310
+
311
+ size = candidates.attempt(
312
+ partial(
313
+ _rerender,
314
+ input_path,
315
+ max_dimension=attempt["max_dimension"],
316
+ jpg_quality=attempt["jpg_quality"],
317
+ progress=step_progress,
318
+ ),
319
+ rasterizes=True,
320
+ )
321
+ if size <= target_mb:
322
+ return publish()
323
+ return publish(
324
+ " (no se alcanzó el objetivo; se guardó la versión más pequeña posible)"
325
+ )
326
+
327
+
328
+ def compress(
329
+ inputs: list[Path], params: CompressParams, progress: Progress = noop_progress
330
+ ) -> ToolResult:
331
+ target_mb = params.target_mb
332
+
333
+ def one(path: Path, scoped: Progress) -> Outcome:
334
+ out = output_path_for(path, target_mb, params.output_dir)
335
+ try:
336
+ done = _compress_one(path, out, target_mb, scoped, params.mode)
337
+ except JobCancelled:
338
+ # Cancelar conserva lo ya terminado, como en el resto de
339
+ # herramientas («Los archivos ya terminados se conservan»).
340
+ raise
341
+ except Exception:
342
+ # Un fallo tras publicar no debe dejar un resultado que la UI no
343
+ # va a reportar. `out` lo eligió `output_path`, así que no existía
344
+ # antes de este archivo.
345
+ out.unlink(missing_ok=True)
346
+ raise
347
+ return done.outcome(out)
348
+
349
+ batch = run_batch(inputs, one, progress, all_failed="Ningún PDF pudo comprimirse.")
350
+ outputs, total = batch.outputs, batch.total
351
+ done_items = [item for item in batch.items if item.ok]
352
+ total_original = sum(item.metrics["original_mb"] for item in done_items)
353
+ total_final = sum(item.metrics["final_mb"] for item in done_items)
354
+ saved_pct = (
355
+ max(0.0, (total_original - total_final) / total_original * 100)
356
+ if total_original
357
+ else 0.0
358
+ )
359
+ metrics = {
360
+ "original_mb": total_original,
361
+ "final_mb": total_final,
362
+ "saved_pct": saved_pct,
363
+ }
364
+ warnings = sorted({w for item in done_items for w in item.warnings})
365
+
366
+ if total == 1:
367
+ return ToolResult(
368
+ outputs=outputs,
369
+ summary=batch.items[0].message,
370
+ warnings=warnings,
371
+ metrics=metrics,
372
+ )
373
+
374
+ tail = f" ({saved_pct:.0f} % menos)" if saved_pct >= 0.5 else " (sin cambios)"
375
+ progress(1.0, f"{len(outputs)} archivos comprimidos")
376
+ head = (
377
+ f"{total} archivos"
378
+ if len(outputs) == total
379
+ else f"{len(outputs)} de {total} archivos"
380
+ )
381
+ return ToolResult(
382
+ outputs=outputs,
383
+ summary=f"{head} · {total_original:.2f} MB → {total_final:.2f} MB{tail}",
384
+ items=batch.items,
385
+ warnings=warnings,
386
+ metrics=metrics,
387
+ )