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
@@ -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,6 @@
1
+ from pdftool.core.plugin import BaseParams
2
+
3
+
4
+ class ImagesToPdfParams(BaseParams):
5
+ """Sin parámetros: cada imagen (JPG/PNG) se convierte en su propio PDF de
6
+ una página del tamaño exacto de la imagen."""
@@ -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