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,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()
|