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/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
|
pdftool/core/registry.py
ADDED
|
@@ -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
|
pdftool/core/updater.py
ADDED
|
@@ -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
|
+
)
|