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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import flet as ft
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from pdftool.tools.compress.params import CompressParams
|
|
7
|
+
from pdftool.tools.compress.spec import SPEC
|
|
8
|
+
from pdftool.ui.accessibility import HelpButton
|
|
9
|
+
from pdftool.ui.file_list import MultiFileToolPanel
|
|
10
|
+
from pdftool.ui.panel_base import InvalidParams
|
|
11
|
+
from pdftool.ui.registry import register
|
|
12
|
+
|
|
13
|
+
_MODE_HELP = (
|
|
14
|
+
"Reducir al máximo: busca el archivo más pequeño posible. Algunas páginas "
|
|
15
|
+
"pueden dejar de tener texto seleccionable.\n\n"
|
|
16
|
+
"Conservar texto y enlaces: mantiene búsqueda, copiar/pegar, enlaces y "
|
|
17
|
+
"anotaciones cuando sea posible, aunque el archivo puede pesar más."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@register
|
|
22
|
+
class CompressTool(MultiFileToolPanel):
|
|
23
|
+
spec = SPEC
|
|
24
|
+
icon = ft.Icons.COMPRESS
|
|
25
|
+
run_label = "Comprimir"
|
|
26
|
+
run_icon = ft.Icons.PLAY_ARROW
|
|
27
|
+
pick_label = "Añadir PDFs"
|
|
28
|
+
|
|
29
|
+
def extra_controls(self) -> list[ft.Control]:
|
|
30
|
+
self._target_field = ft.TextField(
|
|
31
|
+
label="Tamaño objetivo (MB)",
|
|
32
|
+
value="5",
|
|
33
|
+
width=200,
|
|
34
|
+
keyboard_type=ft.KeyboardType.NUMBER,
|
|
35
|
+
helper_text="Objetivo, no garantía.",
|
|
36
|
+
)
|
|
37
|
+
self._mode_dd = ft.Dropdown(
|
|
38
|
+
label="Modo de compresión",
|
|
39
|
+
width=330, # cabe «Reducir al máximo (recomendado)» sin recortarse
|
|
40
|
+
value="max",
|
|
41
|
+
options=[
|
|
42
|
+
ft.dropdown.Option("max", "Reducir al máximo (recomendado)"),
|
|
43
|
+
ft.dropdown.Option("preserve", "Conservar texto y enlaces"),
|
|
44
|
+
],
|
|
45
|
+
)
|
|
46
|
+
self._mode_help = HelpButton("Ayuda sobre compresión", _MODE_HELP)
|
|
47
|
+
return [
|
|
48
|
+
ft.Row(
|
|
49
|
+
[
|
|
50
|
+
self._target_field,
|
|
51
|
+
self._mode_dd,
|
|
52
|
+
self._mode_help,
|
|
53
|
+
],
|
|
54
|
+
spacing=12,
|
|
55
|
+
wrap=True, # en ventanas estrechas pasa a dos líneas
|
|
56
|
+
# Arriba, no centrados: la ayuda «Objetivo, no garantía.» alarga
|
|
57
|
+
# el campo del objetivo y lo descuadraba frente al desplegable.
|
|
58
|
+
vertical_alignment=ft.CrossAxisAlignment.START,
|
|
59
|
+
),
|
|
60
|
+
self._mode_help.detail,
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
def make_params(self):
|
|
64
|
+
# En español se escribe «2,5»: se acepta la coma decimal.
|
|
65
|
+
text = (self._target_field.value or "").strip().replace(",", ".")
|
|
66
|
+
try:
|
|
67
|
+
return CompressParams(target_mb=float(text), mode=self._mode_dd.value)
|
|
68
|
+
except (ValueError, ValidationError) as exc:
|
|
69
|
+
raise InvalidParams(
|
|
70
|
+
"Escribe un tamaño mayor que 0, por ejemplo 5 o 2,5.",
|
|
71
|
+
field=self._target_field,
|
|
72
|
+
) from exc
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from pdftool.core.plugin import BaseParams
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CompressParams(BaseParams):
|
|
9
|
+
target_mb: float = Field(default=5.0, gt=0, description="Tamaño objetivo en MB.")
|
|
10
|
+
# "max" conserva el comportamiento actual y es el modo recomendado para
|
|
11
|
+
# el caso habitual: obtener el archivo más pequeño posible.
|
|
12
|
+
mode: Literal["max", "preserve"] = Field(
|
|
13
|
+
default="max",
|
|
14
|
+
description=(
|
|
15
|
+
"max: el archivo más pequeño posible (algunas páginas pueden perder "
|
|
16
|
+
"texto seleccionable); preserve: conserva texto, enlaces y anotaciones."
|
|
17
|
+
),
|
|
18
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from pdftool.core.plugin import ToolMeta, ToolSpec
|
|
2
|
+
from pdftool.core.registry import register
|
|
3
|
+
from pdftool.tools.compress.logic import compress
|
|
4
|
+
from pdftool.tools.compress.params import CompressParams
|
|
5
|
+
|
|
6
|
+
SPEC = register(
|
|
7
|
+
ToolSpec(
|
|
8
|
+
meta=ToolMeta(
|
|
9
|
+
id="compress",
|
|
10
|
+
name="Comprimir PDF",
|
|
11
|
+
description="Reduce el tamaño de uno o varios PDFs a un objetivo en MB.",
|
|
12
|
+
category="Optimizar",
|
|
13
|
+
order=30,
|
|
14
|
+
),
|
|
15
|
+
params=CompressParams,
|
|
16
|
+
run=compress,
|
|
17
|
+
)
|
|
18
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pymupdf
|
|
6
|
+
|
|
7
|
+
from pdftool.core.atomic import atomic_output
|
|
8
|
+
from pdftool.core.batch import noop_progress, run_batch
|
|
9
|
+
from pdftool.core.errors import UserError
|
|
10
|
+
from pdftool.core.naming import unique_path
|
|
11
|
+
from pdftool.core.plugin import Progress, ToolResult
|
|
12
|
+
from pdftool.tools.images2pdf.params import ImagesToPdfParams
|
|
13
|
+
|
|
14
|
+
ALLOWED_SUFFIXES = {".jpg", ".jpeg", ".png"}
|
|
15
|
+
# Una imagen de pocos KB puede declarar 60000×60000 px y agotar la memoria al
|
|
16
|
+
# decodificarla; se rechaza leyendo solo la cabecera.
|
|
17
|
+
MAX_IMAGE_PIXELS = 120_000_000
|
|
18
|
+
|
|
19
|
+
# Marcadores JPEG SOFn con dimensiones (excluye DHT, JPG y DAC).
|
|
20
|
+
_JPEG_SOF = {
|
|
21
|
+
0xC0,
|
|
22
|
+
0xC1,
|
|
23
|
+
0xC2,
|
|
24
|
+
0xC3,
|
|
25
|
+
0xC5,
|
|
26
|
+
0xC6,
|
|
27
|
+
0xC7,
|
|
28
|
+
0xC9,
|
|
29
|
+
0xCA,
|
|
30
|
+
0xCB,
|
|
31
|
+
0xCD,
|
|
32
|
+
0xCE,
|
|
33
|
+
0xCF,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def image_size(path: Path) -> tuple[int, int] | None:
|
|
38
|
+
"""(ancho, alto) leídos de la cabecera PNG/JPEG, sin decodificar. None si
|
|
39
|
+
no se reconoce."""
|
|
40
|
+
with open(path, "rb") as f:
|
|
41
|
+
head = f.read(24)
|
|
42
|
+
if head[:8] == b"\x89PNG\r\n\x1a\n" and head[12:16] == b"IHDR":
|
|
43
|
+
return int.from_bytes(head[16:20], "big"), int.from_bytes(
|
|
44
|
+
head[20:24], "big"
|
|
45
|
+
)
|
|
46
|
+
if head[:2] != b"\xff\xd8":
|
|
47
|
+
return None
|
|
48
|
+
f.seek(2)
|
|
49
|
+
while True:
|
|
50
|
+
marker = f.read(2)
|
|
51
|
+
if len(marker) < 2 or marker[0] != 0xFF:
|
|
52
|
+
return None
|
|
53
|
+
if marker[1] in (0xD8, 0x01) or 0xD0 <= marker[1] <= 0xD7:
|
|
54
|
+
continue
|
|
55
|
+
length_bytes = f.read(2)
|
|
56
|
+
if len(length_bytes) < 2:
|
|
57
|
+
return None
|
|
58
|
+
length = int.from_bytes(length_bytes, "big")
|
|
59
|
+
if marker[1] in _JPEG_SOF:
|
|
60
|
+
data = f.read(5)
|
|
61
|
+
if len(data) < 5:
|
|
62
|
+
return None
|
|
63
|
+
return int.from_bytes(data[3:5], "big"), int.from_bytes(
|
|
64
|
+
data[1:3], "big"
|
|
65
|
+
)
|
|
66
|
+
f.seek(length - 2, 1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _check_pixels(path: Path) -> None:
|
|
70
|
+
size = image_size(path)
|
|
71
|
+
if size is None:
|
|
72
|
+
return # formato no reconocido: lo decidirá MuPDF
|
|
73
|
+
width, height = size
|
|
74
|
+
if width * height > MAX_IMAGE_PIXELS:
|
|
75
|
+
raise UserError(
|
|
76
|
+
f"La imagen es demasiado grande ({width}×{height} px). "
|
|
77
|
+
"Redúcela antes de convertirla."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def output_path_for_images(input_path: Path, out_dir: Path | None = None) -> Path:
|
|
82
|
+
"""`<imagen>.pdf` en el destino (o junto al original), sin pisar nada."""
|
|
83
|
+
p = Path(input_path)
|
|
84
|
+
carpeta = Path(out_dir) if out_dir is not None else p.parent
|
|
85
|
+
return unique_path(carpeta / f"{p.stem}.pdf")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _convert_one(
|
|
89
|
+
input_path: Path, progress: Progress, out_dir: Path | None = None
|
|
90
|
+
) -> tuple[Path, str]:
|
|
91
|
+
"""Convierte una imagen. Devuelve (salida, etiqueta); un error lanza."""
|
|
92
|
+
if input_path.suffix.lower() not in ALLOWED_SUFFIXES:
|
|
93
|
+
raise UserError("Formato no soportado (usa JPG o PNG).")
|
|
94
|
+
_check_pixels(input_path)
|
|
95
|
+
progress(0.0, "Convirtiendo imagen…")
|
|
96
|
+
out = output_path_for_images(input_path, out_dir)
|
|
97
|
+
# convert_to_pdf genera una página del tamaño exacto de la imagen.
|
|
98
|
+
with pymupdf.open(str(input_path)) as img:
|
|
99
|
+
pdf_bytes = img.convert_to_pdf()
|
|
100
|
+
with atomic_output(out) as temporary, pymupdf.open("pdf", pdf_bytes) as pdf:
|
|
101
|
+
pdf.save(str(temporary), garbage=4, deflate=True)
|
|
102
|
+
progress(1.0, "Listo")
|
|
103
|
+
return out, f"→ {out.name}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def images_to_pdf(
|
|
107
|
+
inputs: list[Path], params: ImagesToPdfParams, progress: Progress = noop_progress
|
|
108
|
+
) -> ToolResult:
|
|
109
|
+
batch = run_batch(
|
|
110
|
+
inputs,
|
|
111
|
+
lambda path, scoped: _convert_one(path, scoped, params.output_dir),
|
|
112
|
+
progress,
|
|
113
|
+
all_failed="Ninguna imagen pudo convertirse.",
|
|
114
|
+
)
|
|
115
|
+
outputs, total = batch.outputs, batch.total
|
|
116
|
+
|
|
117
|
+
if total == 1:
|
|
118
|
+
return ToolResult(
|
|
119
|
+
outputs=outputs, summary=f"Imagen convertida → {outputs[0].name}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
progress(1.0, "Listo")
|
|
123
|
+
if len(outputs) == total:
|
|
124
|
+
summary = f"{total} imágenes convertidas"
|
|
125
|
+
else:
|
|
126
|
+
summary = f"{len(outputs)} de {total} imágenes convertidas"
|
|
127
|
+
return ToolResult(outputs=outputs, summary=summary, items=batch.items)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import flet as ft
|
|
4
|
+
|
|
5
|
+
from pdftool.tools.images2pdf.params import ImagesToPdfParams
|
|
6
|
+
from pdftool.tools.images2pdf.spec import SPEC
|
|
7
|
+
from pdftool.ui.file_list import MultiFileToolPanel
|
|
8
|
+
from pdftool.ui.registry import register
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@register
|
|
12
|
+
class ImagesToPdfTool(MultiFileToolPanel):
|
|
13
|
+
spec = SPEC
|
|
14
|
+
icon = ft.Icons.PHOTO_LIBRARY
|
|
15
|
+
run_label = "Convertir"
|
|
16
|
+
run_icon = ft.Icons.PICTURE_AS_PDF
|
|
17
|
+
pick_label = "Añadir imágenes"
|
|
18
|
+
pick_icon = ft.Icons.ADD_PHOTO_ALTERNATE
|
|
19
|
+
show_thumbnails = True
|
|
20
|
+
|
|
21
|
+
def make_params(self):
|
|
22
|
+
return ImagesToPdfParams()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pdftool.core.plugin import ToolMeta, ToolSpec
|
|
2
|
+
from pdftool.core.registry import register
|
|
3
|
+
from pdftool.tools.images2pdf.logic import images_to_pdf
|
|
4
|
+
from pdftool.tools.images2pdf.params import ImagesToPdfParams
|
|
5
|
+
|
|
6
|
+
SPEC = register(
|
|
7
|
+
ToolSpec(
|
|
8
|
+
meta=ToolMeta(
|
|
9
|
+
id="images2pdf",
|
|
10
|
+
name="Imágenes a PDF",
|
|
11
|
+
description="Crea un PDF por cada imagen (JPG o PNG).",
|
|
12
|
+
category="Convertir",
|
|
13
|
+
order=40,
|
|
14
|
+
),
|
|
15
|
+
params=ImagesToPdfParams,
|
|
16
|
+
run=images_to_pdf,
|
|
17
|
+
extensions=("jpg", "jpeg", "png"),
|
|
18
|
+
)
|
|
19
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pymupdf
|
|
6
|
+
|
|
7
|
+
from pdftool.core.atomic import atomic_output
|
|
8
|
+
from pdftool.core.batch import noop_progress
|
|
9
|
+
from pdftool.core.naming import unique_path
|
|
10
|
+
from pdftool.core.plugin import Progress, ToolResult
|
|
11
|
+
from pdftool.tools.merge.params import MergeParams
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def output_path_for_merge(
|
|
15
|
+
inputs: list[Path], name: str | None = None, out_dir: Path | None = None
|
|
16
|
+
) -> Path:
|
|
17
|
+
"""Salida junto al primer PDF (o en el destino); `name` custom o `<primero>_merged`.
|
|
18
|
+
|
|
19
|
+
Nunca pisa un archivo existente ni una de las entradas: si el nombre está
|
|
20
|
+
ocupado, se usa «nombre (1).pdf», «nombre (2).pdf»… La UI consulta esta
|
|
21
|
+
misma función para avisar del nombre final antes de ejecutar.
|
|
22
|
+
|
|
23
|
+
Si `out_dir` es None, la salida va junto al primer PDF.
|
|
24
|
+
"""
|
|
25
|
+
first = Path(inputs[0])
|
|
26
|
+
base = name or f"{first.stem}_merged"
|
|
27
|
+
carpeta = Path(out_dir) if out_dir is not None else first.parent
|
|
28
|
+
return unique_path(carpeta / f"{base}.pdf", taken=inputs)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def merge(
|
|
32
|
+
inputs: list[Path], params: MergeParams, progress: Progress = noop_progress
|
|
33
|
+
) -> ToolResult:
|
|
34
|
+
if not inputs:
|
|
35
|
+
raise ValueError("inputs está vacío")
|
|
36
|
+
|
|
37
|
+
paths = [Path(p) for p in inputs]
|
|
38
|
+
for p in paths:
|
|
39
|
+
if not p.exists():
|
|
40
|
+
raise FileNotFoundError(p)
|
|
41
|
+
|
|
42
|
+
out = output_path_for_merge(paths, params.output_name, params.output_dir)
|
|
43
|
+
total = len(paths)
|
|
44
|
+
progress(0.0, f"Uniendo {total} PDFs…")
|
|
45
|
+
|
|
46
|
+
with atomic_output(out) as temporary, pymupdf.open() as out_doc:
|
|
47
|
+
for i, p in enumerate(paths):
|
|
48
|
+
with pymupdf.open(str(p)) as src:
|
|
49
|
+
out_doc.insert_pdf(src)
|
|
50
|
+
progress((i + 1) / (total + 1), f"Añadido {p.name}")
|
|
51
|
+
out_doc.save(str(temporary), garbage=4, deflate=True)
|
|
52
|
+
|
|
53
|
+
progress(1.0, f"Listo: {out.name}")
|
|
54
|
+
return ToolResult(outputs=[out], summary=f"{total} PDFs unidos → {out.name}")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import flet as ft
|
|
6
|
+
|
|
7
|
+
from pdftool.core.naming import parse_output_name
|
|
8
|
+
from pdftool.tools.merge.logic import output_path_for_merge
|
|
9
|
+
from pdftool.tools.merge.params import MergeParams
|
|
10
|
+
from pdftool.tools.merge.spec import SPEC
|
|
11
|
+
from pdftool.ui.file_list import MultiFileToolPanel
|
|
12
|
+
from pdftool.ui.panel_base import OutputNameField
|
|
13
|
+
from pdftool.ui.registry import register
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@register
|
|
17
|
+
class MergeTool(MultiFileToolPanel):
|
|
18
|
+
spec = SPEC
|
|
19
|
+
icon = ft.Icons.MERGE
|
|
20
|
+
run_label = "Unir"
|
|
21
|
+
run_icon = ft.Icons.MERGE_TYPE
|
|
22
|
+
pick_label = "Añadir PDFs"
|
|
23
|
+
pick_icon = ft.Icons.UPLOAD_FILE
|
|
24
|
+
show_thumbnails = True
|
|
25
|
+
reorderable = True
|
|
26
|
+
|
|
27
|
+
def extra_controls(self) -> list[ft.Control]:
|
|
28
|
+
self._name_field = OutputNameField(resolve=self._resolve_output)
|
|
29
|
+
return [self._name_field]
|
|
30
|
+
|
|
31
|
+
def _resolve_output(self, base: str | None) -> Path | None:
|
|
32
|
+
"""Ruta real que usaría el run, o None si aún no hay archivos."""
|
|
33
|
+
if not self._files:
|
|
34
|
+
return None
|
|
35
|
+
return output_path_for_merge(self._files, base, self._out_dir.value)
|
|
36
|
+
|
|
37
|
+
def on_inputs_changed(self) -> None:
|
|
38
|
+
self._name_field.refresh()
|
|
39
|
+
|
|
40
|
+
def on_output_dir_changed(self) -> None:
|
|
41
|
+
self._name_field.refresh()
|
|
42
|
+
|
|
43
|
+
def make_params(self):
|
|
44
|
+
return MergeParams(output_name=parse_output_name(self._name_field.value))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from pydantic import Field, field_validator
|
|
2
|
+
|
|
3
|
+
from pdftool.core.naming import parse_output_name
|
|
4
|
+
from pdftool.core.plugin import BaseParams
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MergeParams(BaseParams):
|
|
8
|
+
"""El orden lo decide la lista de inputs. `output_name` es la base del
|
|
9
|
+
archivo de salida (sin extensión); None → default."""
|
|
10
|
+
|
|
11
|
+
output_name: str | None = Field(
|
|
12
|
+
default=None,
|
|
13
|
+
description="Nombre del PDF unido, sin extensión (por defecto, automático).",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
@field_validator("output_name")
|
|
17
|
+
@classmethod
|
|
18
|
+
def _sanitize(cls, value: str | None) -> str | None:
|
|
19
|
+
# Idempotente: la UI ya lo sanitiza para marcar el campo; la CLI no.
|
|
20
|
+
return parse_output_name(value)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pdftool.core.plugin import ToolMeta, ToolSpec
|
|
2
|
+
from pdftool.core.registry import register
|
|
3
|
+
from pdftool.tools.merge.logic import merge
|
|
4
|
+
from pdftool.tools.merge.params import MergeParams
|
|
5
|
+
|
|
6
|
+
SPEC = register(
|
|
7
|
+
ToolSpec(
|
|
8
|
+
meta=ToolMeta(
|
|
9
|
+
id="merge",
|
|
10
|
+
name="Unir PDFs",
|
|
11
|
+
description="Combina varios PDFs en uno solo, en el orden que elijas.",
|
|
12
|
+
category="Organizar",
|
|
13
|
+
order=10,
|
|
14
|
+
),
|
|
15
|
+
params=MergeParams,
|
|
16
|
+
run=merge,
|
|
17
|
+
min_inputs=2,
|
|
18
|
+
)
|
|
19
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pymupdf
|
|
6
|
+
|
|
7
|
+
from pdftool.core.atomic import atomic_output
|
|
8
|
+
from pdftool.core.batch import noop_progress, run_batch
|
|
9
|
+
from pdftool.core.errors import UserError
|
|
10
|
+
from pdftool.core.naming import output_path
|
|
11
|
+
from pdftool.core.plugin import Progress, ToolResult
|
|
12
|
+
from pdftool.tools.protect.params import ProtectParams
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _output(input_path: Path, suffix: str, out_dir: Path | None = None) -> Path:
|
|
16
|
+
"""Política de nombre de Proteger; la colisión la resuelve el helper."""
|
|
17
|
+
return output_path(input_path, suffix, out_dir=out_dir)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _protect_one(
|
|
21
|
+
input_path: Path, params: ProtectParams, progress: Progress
|
|
22
|
+
) -> tuple[Path, str]:
|
|
23
|
+
"""Procesa un archivo. Devuelve (salida, etiqueta); un error lanza."""
|
|
24
|
+
if params.mode == "protect":
|
|
25
|
+
progress(0.0, "Protegiendo PDF…")
|
|
26
|
+
out = _output(input_path, "protegido", params.output_dir)
|
|
27
|
+
with atomic_output(out) as temporary, pymupdf.open(str(input_path)) as doc:
|
|
28
|
+
if doc.needs_pass:
|
|
29
|
+
raise UserError(
|
|
30
|
+
"El PDF ya está protegido; quítale la contraseña primero."
|
|
31
|
+
)
|
|
32
|
+
doc.save(
|
|
33
|
+
str(temporary),
|
|
34
|
+
encryption=pymupdf.PDF_ENCRYPT_AES_256, # type: ignore[attr-defined]
|
|
35
|
+
owner_pw=params.password,
|
|
36
|
+
user_pw=params.password,
|
|
37
|
+
garbage=4,
|
|
38
|
+
deflate=True,
|
|
39
|
+
)
|
|
40
|
+
progress(1.0, "Listo")
|
|
41
|
+
return out, f"→ {out.name}"
|
|
42
|
+
|
|
43
|
+
# mode == "remove"
|
|
44
|
+
progress(0.0, "Quitando contraseña…")
|
|
45
|
+
out = _output(input_path, "sin_clave", params.output_dir)
|
|
46
|
+
with atomic_output(out) as temporary, pymupdf.open(str(input_path)) as doc:
|
|
47
|
+
if doc.needs_pass and not doc.authenticate(params.password):
|
|
48
|
+
raise UserError("Contraseña incorrecta.")
|
|
49
|
+
# garbage=4 descarta objetos sin referencias: si no, el PDF conserva el
|
|
50
|
+
# diccionario de cifrado viejo con los hashes de la contraseña quitada.
|
|
51
|
+
doc.save(
|
|
52
|
+
str(temporary),
|
|
53
|
+
encryption=pymupdf.PDF_ENCRYPT_NONE, # type: ignore[attr-defined]
|
|
54
|
+
garbage=4,
|
|
55
|
+
deflate=True,
|
|
56
|
+
)
|
|
57
|
+
progress(1.0, "Listo")
|
|
58
|
+
return out, f"→ {out.name}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def protect(
|
|
62
|
+
inputs: list[Path], params: ProtectParams, progress: Progress = noop_progress
|
|
63
|
+
) -> ToolResult:
|
|
64
|
+
batch = run_batch(
|
|
65
|
+
inputs,
|
|
66
|
+
lambda path, scoped: _protect_one(path, params, scoped),
|
|
67
|
+
progress,
|
|
68
|
+
all_failed="Ningún PDF pudo procesarse.",
|
|
69
|
+
)
|
|
70
|
+
outputs, total = batch.outputs, batch.total
|
|
71
|
+
|
|
72
|
+
if total == 1:
|
|
73
|
+
verb = "PDF protegido" if params.mode == "protect" else "Contraseña removida"
|
|
74
|
+
return ToolResult(outputs=outputs, summary=f"{verb} → {outputs[0].name}")
|
|
75
|
+
|
|
76
|
+
progress(1.0, "Listo")
|
|
77
|
+
if len(outputs) == total:
|
|
78
|
+
summary = (
|
|
79
|
+
f"{total} PDFs protegidos"
|
|
80
|
+
if params.mode == "protect"
|
|
81
|
+
else f"{total} contraseñas removidas"
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
summary = f"{len(outputs)} de {total} PDFs procesados"
|
|
85
|
+
return ToolResult(outputs=outputs, summary=summary, items=batch.items)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import flet as ft
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from pdftool.core.plugin import ToolResult
|
|
7
|
+
from pdftool.tools.protect.params import ProtectParams
|
|
8
|
+
from pdftool.tools.protect.spec import SPEC
|
|
9
|
+
from pdftool.ui.file_list import MultiFileToolPanel
|
|
10
|
+
from pdftool.ui.panel_base import InvalidParams
|
|
11
|
+
from pdftool.ui.registry import register
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@register
|
|
15
|
+
class ProtectTool(MultiFileToolPanel):
|
|
16
|
+
spec = SPEC
|
|
17
|
+
icon = ft.Icons.LOCK
|
|
18
|
+
run_label = "Proteger"
|
|
19
|
+
run_icon = ft.Icons.LOCK
|
|
20
|
+
pick_label = "Añadir PDFs"
|
|
21
|
+
|
|
22
|
+
def extra_controls(self) -> list[ft.Control]:
|
|
23
|
+
self._mode_dd = ft.Dropdown(
|
|
24
|
+
label="Acción",
|
|
25
|
+
width=240,
|
|
26
|
+
value="protect",
|
|
27
|
+
options=[
|
|
28
|
+
ft.dropdown.Option("protect", "Proteger (poner contraseña)"),
|
|
29
|
+
ft.dropdown.Option("remove", "Quitar contraseña"),
|
|
30
|
+
],
|
|
31
|
+
)
|
|
32
|
+
self._mode_dd.on_change = self._on_mode_change
|
|
33
|
+
self._pw_field = ft.TextField(
|
|
34
|
+
label="Contraseña", width=320, password=True, can_reveal_password=True
|
|
35
|
+
)
|
|
36
|
+
# Un error al teclear dejaría un PDF que nadie puede abrir.
|
|
37
|
+
self._confirm_field = ft.TextField(
|
|
38
|
+
label="Repetir contraseña",
|
|
39
|
+
width=320,
|
|
40
|
+
password=True,
|
|
41
|
+
can_reveal_password=True,
|
|
42
|
+
helper_text="Guárdala bien: sin ella no podrás abrir el PDF.",
|
|
43
|
+
)
|
|
44
|
+
return [self._mode_dd, self._pw_field, self._confirm_field]
|
|
45
|
+
|
|
46
|
+
def _on_mode_change(self, _e) -> None:
|
|
47
|
+
protecting = self._mode_dd.value == "protect"
|
|
48
|
+
self._pw_field.label = "Contraseña" if protecting else "Contraseña actual"
|
|
49
|
+
self._confirm_field.visible = protecting
|
|
50
|
+
self.run_btn.text = "Proteger" if protecting else "Quitar contraseña"
|
|
51
|
+
self.run_btn.icon = ft.Icons.LOCK if protecting else ft.Icons.LOCK_OPEN
|
|
52
|
+
self._page.update()
|
|
53
|
+
|
|
54
|
+
def on_result(self, result: ToolResult) -> None:
|
|
55
|
+
super().on_result(result)
|
|
56
|
+
# No dejar la contraseña escrita en un panel que se conserva al navegar.
|
|
57
|
+
self._pw_field.value = ""
|
|
58
|
+
self._confirm_field.value = ""
|
|
59
|
+
|
|
60
|
+
def make_params(self):
|
|
61
|
+
password = self._pw_field.value or ""
|
|
62
|
+
try:
|
|
63
|
+
params = ProtectParams(mode=self._mode_dd.value, password=password)
|
|
64
|
+
except (ValueError, ValidationError) as exc:
|
|
65
|
+
raise InvalidParams(
|
|
66
|
+
"Escribe una contraseña.", field=self._pw_field
|
|
67
|
+
) from exc
|
|
68
|
+
if params.mode == "protect" and password != (self._confirm_field.value or ""):
|
|
69
|
+
raise InvalidParams(
|
|
70
|
+
"Las contraseñas no coinciden.", field=self._confirm_field
|
|
71
|
+
)
|
|
72
|
+
return params
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from pdftool.core.plugin import BaseParams
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProtectParams(BaseParams):
|
|
9
|
+
mode: Literal["protect", "remove"] = Field(
|
|
10
|
+
default="protect",
|
|
11
|
+
description="protect: añade la contraseña; remove: la quita.",
|
|
12
|
+
)
|
|
13
|
+
# Secreto: la CLI nunca lo acepta como argumento (quedaría en el historial).
|
|
14
|
+
password: str = Field(
|
|
15
|
+
min_length=1,
|
|
16
|
+
description="Contraseña del PDF.",
|
|
17
|
+
json_schema_extra={"secret": True},
|
|
18
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from pdftool.core.plugin import ToolMeta, ToolSpec
|
|
2
|
+
from pdftool.core.registry import register
|
|
3
|
+
from pdftool.tools.protect.logic import protect
|
|
4
|
+
from pdftool.tools.protect.params import ProtectParams
|
|
5
|
+
|
|
6
|
+
SPEC = register(
|
|
7
|
+
ToolSpec(
|
|
8
|
+
meta=ToolMeta(
|
|
9
|
+
id="protect",
|
|
10
|
+
name="Proteger PDF",
|
|
11
|
+
description="Añade o quita la contraseña de uno o varios PDFs.",
|
|
12
|
+
category="Seguridad",
|
|
13
|
+
order=60,
|
|
14
|
+
),
|
|
15
|
+
params=ProtectParams,
|
|
16
|
+
run=protect,
|
|
17
|
+
)
|
|
18
|
+
)
|
|
File without changes
|