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,139 @@
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.errors import UserError
10
+ from pdftool.core.naming import output_path
11
+ from pdftool.core.plugin import Progress, ToolResult
12
+ from pdftool.tools.split.params import SplitParams
13
+
14
+ # Rango 1-indexed e inclusivo. None = extremo abierto (inicio o final del PDF).
15
+ Range = tuple[int | None, int | None]
16
+
17
+
18
+ def parse_ranges(spec: str) -> list[Range]:
19
+ """Parsea la sintaxis de rangos sin conocer el total de páginas.
20
+
21
+ Acepta (1-indexed, inclusivo), separados por comas:
22
+ ``N`` -> solo la página N -> (N, N)
23
+ ``N-M`` -> de la N a la M -> (N, M)
24
+ ``N-`` -> de la N hasta el final -> (N, None)
25
+ ``-M`` -> del inicio hasta la M -> (None, M)
26
+
27
+ Lanza ``ValueError`` si la sintaxis es inválida o el rango está invertido.
28
+ """
29
+ if not spec or not spec.strip():
30
+ raise UserError("Indica al menos un rango (ej: 1-3, 5, 8-)")
31
+
32
+ out: list[Range] = []
33
+ for raw in spec.split(","):
34
+ token = raw.strip()
35
+ if not token: # tolera comas sobrantes ("1-3,")
36
+ continue
37
+ out.append(_parse_token(token))
38
+ if not out:
39
+ raise UserError("Indica al menos un rango (ej: 1-3, 5, 8-)")
40
+ return out
41
+
42
+
43
+ def _parse_int(text: str) -> int:
44
+ if not text.isdigit():
45
+ raise UserError(
46
+ f"«{text}» no es un número de página válido. Usa por ejemplo 1-3, 5"
47
+ )
48
+ value = int(text)
49
+ if value < 1:
50
+ raise UserError("Las páginas empiezan en 1")
51
+ return value
52
+
53
+
54
+ def _parse_token(token: str) -> Range:
55
+ if "-" not in token:
56
+ n = _parse_int(token)
57
+ return (n, n)
58
+
59
+ parts = token.split("-")
60
+ if len(parts) != 2:
61
+ raise UserError(f"«{token}» no es un rango válido. Usa por ejemplo 1-3, 5")
62
+ left, right = parts[0].strip(), parts[1].strip()
63
+
64
+ start = _parse_int(left) if left else None
65
+ end = _parse_int(right) if right else None
66
+ if not left and not right:
67
+ raise UserError(f"«{token}» no es un rango válido. Usa por ejemplo 1-3, 5")
68
+ if start is not None and end is not None and start > end:
69
+ raise UserError(
70
+ f"El rango «{token}» está al revés: escribe primero la página menor."
71
+ )
72
+ return (start, end)
73
+
74
+
75
+ def resolve_ranges(parsed: list[Range], page_count: int) -> list[tuple[int, int]]:
76
+ """Rellena extremos abiertos con el total real y valida límites."""
77
+ resolved: list[tuple[int, int]] = []
78
+ for start, end in parsed:
79
+ s = 1 if start is None else start
80
+ e = page_count if end is None else end
81
+ if s > page_count or e > page_count:
82
+ raise UserError(f"El rango {s}-{e} excede el total de {page_count} páginas")
83
+ resolved.append((s, e))
84
+ return resolved
85
+
86
+
87
+ def page_count(path: Path) -> int:
88
+ """Número de páginas de un PDF; lanza si no se puede abrir."""
89
+ with pymupdf.open(str(path)) as doc:
90
+ return doc.page_count
91
+
92
+
93
+ def _label(start: int, end: int, width: int) -> str:
94
+ if start == end:
95
+ return f"p{start:0{width}d}"
96
+ return f"p{start:0{width}d}-{end:0{width}d}"
97
+
98
+
99
+ def _ranges_for(params: SplitParams, page_count: int) -> list[tuple[int, int]]:
100
+ if params.mode == "single":
101
+ return [(i, i) for i in range(1, page_count + 1)]
102
+ if params.mode == "every":
103
+ return [
104
+ (s, min(s + params.every_n - 1, page_count))
105
+ for s in range(1, page_count + 1, params.every_n)
106
+ ]
107
+ return resolve_ranges(parse_ranges(params.ranges), page_count)
108
+
109
+
110
+ def split(
111
+ inputs: list[Path], params: SplitParams, progress: Progress = noop_progress
112
+ ) -> ToolResult:
113
+ if not inputs:
114
+ raise ValueError("inputs está vacío")
115
+
116
+ src_path = Path(inputs[0])
117
+ if not src_path.exists():
118
+ raise FileNotFoundError(src_path)
119
+
120
+ outputs: list[Path] = []
121
+ with pymupdf.open(str(src_path)) as src:
122
+ page_count = src.page_count
123
+ ranges = _ranges_for(params, page_count)
124
+ width = len(str(page_count))
125
+ total = len(ranges)
126
+ progress(0.0, f"Dividiendo en {total} archivos…")
127
+
128
+ for i, (start, end) in enumerate(ranges):
129
+ out = output_path(
130
+ src_path, _label(start, end, width), out_dir=params.output_dir
131
+ )
132
+ with atomic_output(out) as temporary, pymupdf.open() as dst:
133
+ dst.insert_pdf(src, from_page=start - 1, to_page=end - 1)
134
+ dst.save(str(temporary), garbage=4, deflate=True)
135
+ outputs.append(out)
136
+ progress((i + 1) / total, f"Generado {out.name}")
137
+
138
+ progress(1.0, f"Listo: {total} archivos")
139
+ return ToolResult(outputs=outputs, summary=f"{src_path.name} → {total} archivos")
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import flet as ft
6
+ from pydantic import ValidationError
7
+
8
+ from pdftool.core.errors import UserError
9
+ from pdftool.tools.split.logic import page_count, parse_ranges, resolve_ranges
10
+ from pdftool.tools.split.params import SplitParams
11
+ from pdftool.tools.split.spec import SPEC
12
+ from pdftool.ui.accessibility import HelpButton
13
+ from pdftool.ui.panel_base import InvalidParams, SingleFileToolPanel
14
+ from pdftool.ui.registry import register
15
+
16
+ _RANGES_HELP = (
17
+ "Cómo escribir rangos:\n"
18
+ " 1-3 → páginas 1 a la 3\n"
19
+ " 5 → solo la página 5\n"
20
+ " 8- → de la página 8 hasta el final\n"
21
+ " -4 → del inicio hasta la página 4\n"
22
+ "Separa varios rangos con comas:\n"
23
+ " 1-3, 5, 8-"
24
+ )
25
+
26
+
27
+ @register
28
+ class SplitTool(SingleFileToolPanel):
29
+ spec = SPEC
30
+ icon = ft.Icons.CONTENT_CUT
31
+ run_label = "Dividir"
32
+ run_icon = ft.Icons.CONTENT_CUT
33
+ pick_label = "Elegir PDF"
34
+
35
+ def extra_controls(self) -> list[ft.Control]:
36
+ self._pages = 0
37
+ self._ranges_help = HelpButton("Ayuda sobre rangos de páginas", _RANGES_HELP)
38
+ self._ranges_field = ft.TextField(
39
+ label="Rangos", hint_text="ej: 1-3, 5, 8-", width=240, disabled=False
40
+ )
41
+ # Sin label propio: la fila ya se lee «Cada [10] páginas».
42
+ self._every_field = ft.TextField(
43
+ value="10",
44
+ tooltip="Páginas por archivo",
45
+ width=160,
46
+ disabled=True,
47
+ keyboard_type=ft.KeyboardType.NUMBER,
48
+ )
49
+ self._mode = ft.RadioGroup(
50
+ value="ranges",
51
+ content=ft.Column(
52
+ [
53
+ ft.Row(
54
+ [
55
+ ft.Radio(value="ranges", label="Por rangos"),
56
+ self._ranges_field,
57
+ self._ranges_help,
58
+ ]
59
+ ),
60
+ self._ranges_help.detail,
61
+ ft.Radio(value="single", label="Una página por archivo"),
62
+ ft.Row(
63
+ [
64
+ ft.Radio(value="every", label="Cada"),
65
+ self._every_field,
66
+ ft.Text("páginas"),
67
+ ]
68
+ ),
69
+ ],
70
+ spacing=8,
71
+ ),
72
+ )
73
+ self._mode.on_change = self._on_mode_change
74
+ return [ft.Text("Cómo dividir:", weight=ft.FontWeight.W_500), self._mode]
75
+
76
+ def _on_mode_change(self, _e) -> None:
77
+ self._ranges_field.disabled = self._mode.value != "ranges"
78
+ self._every_field.disabled = self._mode.value != "every"
79
+ self._page.update()
80
+
81
+ def after_pick(self, path: Path) -> None:
82
+ # Se reinicia antes de leer: si el PDF no abre, los rangos no deben
83
+ # validarse contra las páginas del archivo anterior.
84
+ self._pages = 0
85
+ self._file_label.value = f"{path.name} · leyendo páginas…"
86
+
87
+ # Abrir un PDF grande o dañado puede tardar: se lee en segundo plano.
88
+ def on_done(count: int) -> None:
89
+ if self._file != path: # el usuario ya eligió otro archivo
90
+ return
91
+ self._pages = count
92
+ self._file_label.value = f"{path.name} · {count} páginas"
93
+ self._sync_ready_state()
94
+ self._page.update()
95
+
96
+ def on_error(exc: Exception) -> None:
97
+ if self._file != path:
98
+ return
99
+ self._reject_pick(exc)
100
+ self._page.update()
101
+
102
+ self._ctx.run_job(
103
+ work=lambda _progress: page_count(path),
104
+ on_progress=lambda *_: None,
105
+ on_done=on_done,
106
+ on_error=on_error,
107
+ )
108
+
109
+ def can_run(self) -> bool:
110
+ return super().can_run() and self._pages > 0
111
+
112
+ def make_params(self):
113
+ try:
114
+ params = SplitParams(
115
+ mode=self._mode.value,
116
+ ranges=self._ranges_field.value or "",
117
+ every_n=int(self._every_field.value) if self._every_field.value else 1,
118
+ )
119
+ except (ValueError, ValidationError) as exc:
120
+ raise InvalidParams(
121
+ "Escribe un número de páginas mayor que 0.", field=self._every_field
122
+ ) from exc
123
+ if params.mode == "ranges":
124
+ # Valida sintaxis y límites antes de lanzar el job.
125
+ try:
126
+ resolve_ranges(parse_ranges(params.ranges), int(self._pages))
127
+ except UserError as exc:
128
+ raise InvalidParams(str(exc), field=self._ranges_field) from exc
129
+ return params
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import Field
6
+
7
+ from pdftool.core.plugin import BaseParams
8
+
9
+
10
+ class SplitParams(BaseParams):
11
+ """Cómo dividir el PDF.
12
+
13
+ - ``ranges``: un PDF por cada rango de ``ranges`` (ej. "1-3, 5, 8-").
14
+ - ``single``: una página por archivo.
15
+ - ``every``: bloques de ``every_n`` páginas.
16
+ """
17
+
18
+ mode: Literal["ranges", "single", "every"] = Field(
19
+ default="ranges",
20
+ description="ranges: un PDF por rango; single: una página por archivo; "
21
+ "every: bloques de N páginas.",
22
+ )
23
+ ranges: str = Field(
24
+ default="", description='Rangos de páginas para mode=ranges, ej. "1-3, 5, 8-".'
25
+ )
26
+ every_n: int = Field(
27
+ default=1, ge=1, description="Páginas por archivo para mode=every."
28
+ )
@@ -0,0 +1,19 @@
1
+ from pdftool.core.plugin import ToolMeta, ToolSpec
2
+ from pdftool.core.registry import register
3
+ from pdftool.tools.split.logic import split
4
+ from pdftool.tools.split.params import SplitParams
5
+
6
+ SPEC = register(
7
+ ToolSpec(
8
+ meta=ToolMeta(
9
+ id="split",
10
+ name="Dividir PDF",
11
+ description="Separa un PDF en varios, por rangos o por páginas.",
12
+ category="Organizar",
13
+ order=20,
14
+ ),
15
+ params=SplitParams,
16
+ run=split,
17
+ max_inputs=1,
18
+ )
19
+ )
File without changes
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from pathlib import Path
5
+
6
+ import pymupdf
7
+
8
+ from pdftool.core.atomic import atomic_output
9
+ from pdftool.core.batch import noop_progress
10
+ from pdftool.core.naming import output_path
11
+ from pdftool.core.plugin import Progress, ToolResult
12
+ from pdftool.tools.watermark.params import WatermarkParams
13
+
14
+ _ANGLE = 45
15
+ # Tope de sellos por página: el mosaico crece con el área de la página. Queda por
16
+ # encima de un A0 con letra de 8 pt (~8000), así que solo actúa ante páginas
17
+ # fuera de lo normal (hasta 14400 pt de lado).
18
+ _MAX_STAMPS_PER_PAGE = 20_000
19
+
20
+
21
+ def output_path_for_watermark(input_path: Path, out_dir: Path | None = None) -> Path:
22
+ """Política de nombre de Marca de agua; la colisión la resuelve el helper."""
23
+ return output_path(input_path, "marca", out_dir=out_dir)
24
+
25
+
26
+ def _stamp_count(rect: pymupdf.Rect, sx: float, sy: float, text_w: float) -> int:
27
+ """Sellos que pondrá el mosaico (mismos límites que el bucle de `watermark`)."""
28
+ cols = math.ceil((rect.width + sx + text_w) / sx)
29
+ rows = math.ceil((rect.height + sy / 2) / sy)
30
+ return cols * rows
31
+
32
+
33
+ def _grid_steps(
34
+ rect: pymupdf.Rect, step_x: float, step_y: float, text_w: float
35
+ ) -> tuple[float, float]:
36
+ """Separación del mosaico, ampliada si la página pediría demasiados sellos."""
37
+ stamps = _stamp_count(rect, step_x, step_y, text_w)
38
+ if stamps <= _MAX_STAMPS_PER_PAGE:
39
+ return step_x, step_y
40
+ scale = math.sqrt(stamps / _MAX_STAMPS_PER_PAGE)
41
+ while _stamp_count(rect, step_x * scale, step_y * scale, text_w) > (
42
+ _MAX_STAMPS_PER_PAGE
43
+ ):
44
+ scale *= 1.05
45
+ return step_x * scale, step_y * scale
46
+
47
+
48
+ def _tile_page(
49
+ page: pymupdf.Page,
50
+ text: str,
51
+ params: WatermarkParams,
52
+ font: pymupdf.Font,
53
+ step_x: float,
54
+ step_y: float,
55
+ text_w: float,
56
+ ) -> None:
57
+ """Cubre la página con el texto girado `_ANGLE` grados en mosaico.
58
+
59
+ Cada sello gira sobre su propio punto de inicio. En vez de un insert_text
60
+ por sello (coste cuadrático: cada llamada reescribe el contenido de la
61
+ página), todos van en un único TextWriter con una sola rotación alrededor
62
+ del centro: cada sello se coloca en el punto que esa rotación lleva a su
63
+ posición final, con idéntico resultado.
64
+ """
65
+ rect = page.rect
66
+ sx, sy = _grid_steps(rect, step_x, step_y, text_w)
67
+ center = pymupdf.Point(rect.width / 2, rect.height / 2)
68
+ # `morph` gira en el sistema de PDF (y hacia arriba); en coordenadas de
69
+ # página la rotación inversa equivale a la misma matriz.
70
+ rot = pymupdf.Matrix(_ANGLE)
71
+ writer = pymupdf.TextWriter(rect, opacity=params.opacity, color=tuple(params.color))
72
+ y = sy / 2
73
+ while y < rect.height + sy:
74
+ x = -text_w
75
+ while x < rect.width + sx:
76
+ start = (pymupdf.Point(x, y) - center) * rot + center
77
+ writer.append(start, text, font=font, fontsize=params.font_size)
78
+ x += sx
79
+ y += sy
80
+ writer.write_text(page, morph=(center, rot), overlay=True)
81
+
82
+
83
+ def watermark(
84
+ inputs: list[Path], params: WatermarkParams, progress: Progress = noop_progress
85
+ ) -> ToolResult:
86
+ if not inputs:
87
+ raise ValueError("inputs está vacío")
88
+ input_path = Path(inputs[0])
89
+ if not input_path.exists():
90
+ raise FileNotFoundError(input_path)
91
+
92
+ out = output_path_for_watermark(input_path, params.output_dir)
93
+ text = params.text
94
+ text_w = pymupdf.get_text_length(text, fontsize=params.font_size)
95
+ # Separación de la rejilla (mosaico): ancho del texto + holgura.
96
+ step_x = max(text_w + params.font_size * 2, params.font_size * 4)
97
+ step_y = params.font_size * 4
98
+
99
+ progress(0.0, "Aplicando marca de agua…")
100
+ font = pymupdf.Font("helv")
101
+ with atomic_output(out) as temporary, pymupdf.open(str(input_path)) as doc:
102
+ total = doc.page_count
103
+ for pno in range(total):
104
+ page = doc[pno]
105
+ _tile_page(page, text, params, font, step_x, step_y, text_w)
106
+ progress((pno + 1) / total, f"Página {pno + 1}/{total}")
107
+ doc.save(str(temporary), garbage=4, deflate=True)
108
+
109
+ progress(1.0, f"Listo: {out.name}")
110
+ return ToolResult(outputs=[out], summary=f"Marca '{text}' aplicada → {out.name}")
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import flet as ft
4
+ from pydantic import ValidationError
5
+
6
+ from pdftool.tools.watermark.params import WatermarkParams
7
+ from pdftool.tools.watermark.spec import SPEC
8
+ from pdftool.ui.panel_base import InvalidParams, SingleFileToolPanel
9
+ from pdftool.ui.registry import register
10
+
11
+ _COLORS = {
12
+ "Gris": (0.5, 0.5, 0.5),
13
+ "Rojo": (0.85, 0.2, 0.2),
14
+ "Azul": (0.2, 0.4, 0.85),
15
+ "Negro": (0.0, 0.0, 0.0),
16
+ }
17
+
18
+
19
+ @register
20
+ class WatermarkTool(SingleFileToolPanel):
21
+ spec = SPEC
22
+ icon = ft.Icons.BRANDING_WATERMARK
23
+ run_label = "Añadir marca de agua"
24
+ run_icon = ft.Icons.BRANDING_WATERMARK
25
+ pick_label = "Elegir PDF"
26
+
27
+ def extra_controls(self) -> list[ft.Control]:
28
+ self._text_field = ft.TextField(
29
+ label="Texto de la marca", value="CONFIDENCIAL", width=320
30
+ )
31
+ # En porcentaje: «15 %» se entiende mejor que «0.15».
32
+ self._opacity = ft.Slider(
33
+ min=5,
34
+ max=60,
35
+ value=15,
36
+ divisions=11,
37
+ label="{value} %",
38
+ width=320,
39
+ )
40
+ self._color_dd = ft.Dropdown(
41
+ label="Color",
42
+ width=160,
43
+ value="Gris",
44
+ options=[ft.dropdown.Option(name) for name in _COLORS],
45
+ )
46
+ self._size_field = ft.TextField(
47
+ label="Tamaño del texto (pt)",
48
+ value="40",
49
+ width=180,
50
+ keyboard_type=ft.KeyboardType.NUMBER,
51
+ )
52
+ return [
53
+ self._text_field,
54
+ ft.Row([self._color_dd, self._size_field]),
55
+ ft.Column([ft.Text("Opacidad"), self._opacity], spacing=0),
56
+ ]
57
+
58
+ def make_params(self):
59
+ try:
60
+ return WatermarkParams(
61
+ text=self._text_field.value or "",
62
+ opacity=float(self._opacity.value) / 100,
63
+ font_size=int(self._size_field.value),
64
+ color=_COLORS.get(self._color_dd.value, _COLORS["Gris"]),
65
+ )
66
+ except (ValueError, ValidationError) as exc:
67
+ raise InvalidParams("Revisa el texto, tamaño y opacidad.") from exc
@@ -0,0 +1,19 @@
1
+ from typing import Annotated
2
+
3
+ from pydantic import Field
4
+
5
+ from pdftool.core.plugin import BaseParams
6
+
7
+ # Componente de color RGB.
8
+ _Unit = Annotated[float, Field(ge=0, le=1)]
9
+
10
+
11
+ class WatermarkParams(BaseParams):
12
+ text: str = Field(min_length=1, description="Texto de la marca de agua.")
13
+ opacity: float = Field(
14
+ default=0.15, gt=0, le=1, description="Opacidad, de 0 (excluido) a 1."
15
+ )
16
+ font_size: int = Field(default=40, gt=0, description="Tamaño de letra.")
17
+ color: tuple[_Unit, _Unit, _Unit] = Field(
18
+ default=(0.5, 0.5, 0.5), description="Color RGB, cada valor de 0 a 1."
19
+ )
@@ -0,0 +1,19 @@
1
+ from pdftool.core.plugin import ToolMeta, ToolSpec
2
+ from pdftool.core.registry import register
3
+ from pdftool.tools.watermark.logic import watermark
4
+ from pdftool.tools.watermark.params import WatermarkParams
5
+
6
+ SPEC = register(
7
+ ToolSpec(
8
+ meta=ToolMeta(
9
+ id="watermark",
10
+ name="Marca de agua",
11
+ description="Repite un texto en diagonal sobre todas las páginas.",
12
+ category="Editar",
13
+ order=50,
14
+ ),
15
+ params=WatermarkParams,
16
+ run=watermark,
17
+ max_inputs=1,
18
+ )
19
+ )
pdftool/ui/__init__.py ADDED
File without changes
@@ -0,0 +1,44 @@
1
+ """Controles estándar con nombres accesibles y ayuda disponible al teclado."""
2
+
3
+ import flet as ft
4
+
5
+
6
+ def icon_content(icon, label: str) -> ft.Icon:
7
+ return ft.Icon(icon, size=24, semantics_label=label)
8
+
9
+
10
+ class NamedIconButton(ft.IconButton):
11
+ def __init__(self, icon, label: str, **kwargs):
12
+ super().__init__(
13
+ content=icon_content(icon, label),
14
+ tooltip=label,
15
+ focus_color=ft.Colors.PRIMARY_CONTAINER,
16
+ **kwargs,
17
+ )
18
+
19
+
20
+ class HelpButton(NamedIconButton):
21
+ """La ayuda se muestra al foco o al activar el botón, sin un diálogo."""
22
+
23
+ def __init__(self, label: str, message: str):
24
+ self.detail = ft.Text(
25
+ message,
26
+ size=12,
27
+ color=ft.Colors.ON_SURFACE_VARIANT,
28
+ visible=False,
29
+ )
30
+ super().__init__(ft.Icons.HELP_OUTLINE, label)
31
+ self.tooltip = message
32
+ self.on_focus = self._show
33
+ self.on_click = self._show
34
+ self.on_blur = self._hide
35
+
36
+ def _show(self, _e):
37
+ self.detail.visible = True
38
+ if self.page:
39
+ self.page.update()
40
+
41
+ def _hide(self, _e):
42
+ self.detail.visible = False
43
+ if self.page:
44
+ self.page.update()