@mmmbuto/nexuscrew 0.8.33 → 0.8.35
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.
- package/CHANGELOG.md +32 -0
- package/README.md +20 -1
- package/frontend/dist/assets/{index-CrggFFKZ.css → index-5PMZjYKR.css} +1 -1
- package/frontend/dist/assets/{index-CP5MeWCH.js → index-BeTp29rp.js} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +109 -4
- package/lib/cli/pidfile.js +39 -14
- package/lib/nodes/tunnel.js +6 -1
- package/package.json +1 -1
- package/skills/fill-forms/SKILL.md +154 -0
- package/skills/fill-forms/agents/openai.yaml +4 -0
- package/skills/fill-forms/references/overlay-technique.md +99 -0
- package/skills/fill-forms/requirements.txt +4 -0
- package/skills/fill-forms/scripts/dump_docx.py +70 -0
- package/skills/fill-forms/scripts/fill_docx.py +207 -0
- package/skills/fill-forms/scripts/fill_pdf.py +424 -0
- package/skills/fill-forms/scripts/inspect_pdf.py +188 -0
- package/skills/fill-forms/scripts/prepare_signature.py +171 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fill a local PDF with AcroForm values and/or coordinate overlays."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
MAX_CONFIG_BYTES = 2 * 1024 * 1024
|
|
15
|
+
MAX_IMAGE_BYTES = 25 * 1024 * 1024
|
|
16
|
+
BLACK = (0, 0, 0)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_args() -> argparse.Namespace:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
description="Fill a PDF from a bounded JSON configuration and render previews."
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("config", type=Path)
|
|
24
|
+
parser.add_argument("--dpi", type=int, default=130)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--overwrite",
|
|
27
|
+
action="store_true",
|
|
28
|
+
help="replace an existing output file, never the source",
|
|
29
|
+
)
|
|
30
|
+
return parser.parse_args()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_fitz():
|
|
34
|
+
try:
|
|
35
|
+
import fitz
|
|
36
|
+
except ImportError as exc:
|
|
37
|
+
raise SystemExit(
|
|
38
|
+
"Missing dependency: PyMuPDF. With user consent, install the "
|
|
39
|
+
"packages listed in the skill's requirements.txt."
|
|
40
|
+
) from exc
|
|
41
|
+
return fitz
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_pillow_image():
|
|
45
|
+
try:
|
|
46
|
+
from PIL import Image
|
|
47
|
+
except ImportError as exc:
|
|
48
|
+
raise SystemExit(
|
|
49
|
+
"Missing dependency: Pillow is required for image overlays. With "
|
|
50
|
+
"user consent, install the packages listed in requirements.txt."
|
|
51
|
+
) from exc
|
|
52
|
+
return Image
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_config(path: Path) -> dict:
|
|
56
|
+
if not path.is_file():
|
|
57
|
+
raise SystemExit(f"Configuration not found: {path}")
|
|
58
|
+
if path.stat().st_size > MAX_CONFIG_BYTES:
|
|
59
|
+
raise SystemExit("Configuration exceeds the 2 MiB safety limit.")
|
|
60
|
+
try:
|
|
61
|
+
config = json.loads(path.read_text(encoding="utf-8"))
|
|
62
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
63
|
+
raise SystemExit(f"Could not read configuration: {exc}") from exc
|
|
64
|
+
if not isinstance(config, dict):
|
|
65
|
+
raise SystemExit("Configuration must be a JSON object.")
|
|
66
|
+
return config
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_path(base: Path, value, label: str) -> Path:
|
|
70
|
+
if not isinstance(value, str) or not value.strip():
|
|
71
|
+
raise SystemExit(f"{label} must be a non-empty path string.")
|
|
72
|
+
path = Path(value).expanduser()
|
|
73
|
+
if not path.is_absolute():
|
|
74
|
+
path = base / path
|
|
75
|
+
return path.resolve()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def number(value, label: str) -> float:
|
|
79
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
80
|
+
raise SystemExit(f"{label} must be numeric.")
|
|
81
|
+
return float(value)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def positive_size(value, label: str) -> float:
|
|
85
|
+
size = number(value, label)
|
|
86
|
+
if size <= 0 or size > 72:
|
|
87
|
+
raise SystemExit(f"{label} must be greater than 0 and at most 72.")
|
|
88
|
+
return size
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def point(item: dict, page, index: int) -> tuple[float, float]:
|
|
92
|
+
x = number(item.get("x"), f"overlay[{index}].x")
|
|
93
|
+
y = number(item.get("y"), f"overlay[{index}].y")
|
|
94
|
+
if x < 0 or x > page.rect.width or y < 0 or y > page.rect.height:
|
|
95
|
+
raise SystemExit(f"overlay[{index}] coordinates fall outside the page.")
|
|
96
|
+
return x, y
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def register_font(page, font_file: Path | None) -> str:
|
|
100
|
+
if font_file is None:
|
|
101
|
+
return "helv"
|
|
102
|
+
page.insert_font(fontname="formfill", fontfile=str(font_file))
|
|
103
|
+
return "formfill"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def apply_acroform(fitz, document, data: dict) -> int:
|
|
107
|
+
if not isinstance(data, dict) or not data:
|
|
108
|
+
raise SystemExit("acroform must be a non-empty object.")
|
|
109
|
+
|
|
110
|
+
requested = set()
|
|
111
|
+
for key, value in data.items():
|
|
112
|
+
if not isinstance(key, str) or not key:
|
|
113
|
+
raise SystemExit("Every AcroForm field name must be a non-empty string.")
|
|
114
|
+
if not isinstance(value, (str, int, float, bool)) and value is not None:
|
|
115
|
+
raise SystemExit(f"AcroForm field {key!r} has a non-scalar value.")
|
|
116
|
+
requested.add(key)
|
|
117
|
+
|
|
118
|
+
seen = set()
|
|
119
|
+
updates = 0
|
|
120
|
+
for page_number in range(document.page_count):
|
|
121
|
+
for widget in document[page_number].widgets() or []:
|
|
122
|
+
if widget.field_name not in data:
|
|
123
|
+
continue
|
|
124
|
+
value = data[widget.field_name]
|
|
125
|
+
if widget.field_type == fitz.PDF_WIDGET_TYPE_CHECKBOX:
|
|
126
|
+
widget.field_value = bool(value)
|
|
127
|
+
else:
|
|
128
|
+
widget.field_value = "" if value is None else str(value)
|
|
129
|
+
widget.update()
|
|
130
|
+
seen.add(widget.field_name)
|
|
131
|
+
updates += 1
|
|
132
|
+
|
|
133
|
+
missing = sorted(requested - seen)
|
|
134
|
+
if missing:
|
|
135
|
+
raise SystemExit(
|
|
136
|
+
"Unknown AcroForm fields: " + ", ".join(repr(name) for name in missing)
|
|
137
|
+
)
|
|
138
|
+
return updates
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def validate_rect(fitz, values, page, index: int):
|
|
142
|
+
if not isinstance(values, list) or len(values) != 4:
|
|
143
|
+
raise SystemExit(f"overlay[{index}].rect must contain four numbers.")
|
|
144
|
+
rect = fitz.Rect(*(number(value, f"overlay[{index}].rect") for value in values))
|
|
145
|
+
if rect.is_empty or rect.is_infinite:
|
|
146
|
+
raise SystemExit(f"overlay[{index}].rect must be a finite non-empty rectangle.")
|
|
147
|
+
page_rect = page.rect
|
|
148
|
+
if (
|
|
149
|
+
rect.x0 < page_rect.x0
|
|
150
|
+
or rect.y0 < page_rect.y0
|
|
151
|
+
or rect.x1 > page_rect.x1
|
|
152
|
+
or rect.y1 > page_rect.y1
|
|
153
|
+
):
|
|
154
|
+
raise SystemExit(f"overlay[{index}].rect falls outside the page.")
|
|
155
|
+
return rect
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def image_stream(Image, image_path: Path, rect) -> bytes:
|
|
159
|
+
if not image_path.is_file():
|
|
160
|
+
raise SystemExit(f"Overlay image not found: {image_path}")
|
|
161
|
+
if image_path.stat().st_size > MAX_IMAGE_BYTES:
|
|
162
|
+
raise SystemExit(f"Overlay image exceeds the 25 MiB safety limit: {image_path}")
|
|
163
|
+
try:
|
|
164
|
+
with Image.open(image_path) as image:
|
|
165
|
+
image.load()
|
|
166
|
+
max_width = max(64, int(rect.width / 72 * 250))
|
|
167
|
+
if image.width > max_width:
|
|
168
|
+
new_height = max(1, round(image.height * max_width / image.width))
|
|
169
|
+
image = image.resize((max_width, new_height), Image.Resampling.LANCZOS)
|
|
170
|
+
buffer = io.BytesIO()
|
|
171
|
+
image.save(buffer, format="PNG")
|
|
172
|
+
return buffer.getvalue()
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
raise SystemExit(f"Could not prepare overlay image {image_path}: {exc}") from exc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def apply_overlays(fitz, document, items, default_size, base, font_file) -> int:
|
|
178
|
+
if not isinstance(items, list) or not items:
|
|
179
|
+
raise SystemExit("overlay must be a non-empty array.")
|
|
180
|
+
|
|
181
|
+
pillow_image = None
|
|
182
|
+
applied = 0
|
|
183
|
+
total_text = 0
|
|
184
|
+
|
|
185
|
+
for index, item in enumerate(items):
|
|
186
|
+
if not isinstance(item, dict):
|
|
187
|
+
raise SystemExit(f"overlay[{index}] must be an object.")
|
|
188
|
+
page_number = item.get("page")
|
|
189
|
+
if (
|
|
190
|
+
isinstance(page_number, bool)
|
|
191
|
+
or not isinstance(page_number, int)
|
|
192
|
+
or page_number < 0
|
|
193
|
+
or page_number >= document.page_count
|
|
194
|
+
):
|
|
195
|
+
raise SystemExit(f"overlay[{index}].page is outside the document.")
|
|
196
|
+
page = document[page_number]
|
|
197
|
+
|
|
198
|
+
is_image = "image" in item
|
|
199
|
+
is_check = item.get("check") is True
|
|
200
|
+
is_text = "text" in item
|
|
201
|
+
if sum((is_image, is_check, is_text)) != 1:
|
|
202
|
+
raise SystemExit(
|
|
203
|
+
f"overlay[{index}] must contain exactly one of image, check:true or text."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if is_image:
|
|
207
|
+
rect = validate_rect(fitz, item.get("rect"), page, index)
|
|
208
|
+
image_path = resolve_path(base, item["image"], f"overlay[{index}].image")
|
|
209
|
+
if pillow_image is None:
|
|
210
|
+
pillow_image = load_pillow_image()
|
|
211
|
+
stream = image_stream(pillow_image, image_path, rect)
|
|
212
|
+
stretch = item.get("stretch", False)
|
|
213
|
+
if not isinstance(stretch, bool):
|
|
214
|
+
raise SystemExit(f"overlay[{index}].stretch must be true or false.")
|
|
215
|
+
page.insert_image(rect, stream=stream, keep_proportion=not stretch)
|
|
216
|
+
applied += 1
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
x, y = point(item, page, index)
|
|
220
|
+
if is_check:
|
|
221
|
+
size = positive_size(item.get("size", 11), f"overlay[{index}].size")
|
|
222
|
+
font_name = register_font(page, font_file)
|
|
223
|
+
page.insert_text((x, y), "X", fontsize=size, fontname=font_name, color=BLACK)
|
|
224
|
+
applied += 1
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
text = item["text"]
|
|
228
|
+
if not isinstance(text, (str, int, float)) or isinstance(text, bool):
|
|
229
|
+
raise SystemExit(f"overlay[{index}].text must be a string or number.")
|
|
230
|
+
text = str(text)
|
|
231
|
+
if len(text) > 5000:
|
|
232
|
+
raise SystemExit(f"overlay[{index}].text exceeds 5000 characters.")
|
|
233
|
+
total_text += len(text)
|
|
234
|
+
if total_text > 100_000:
|
|
235
|
+
raise SystemExit("Overlay text exceeds the 100000-character safety limit.")
|
|
236
|
+
size = positive_size(item.get("size", default_size), f"overlay[{index}].size")
|
|
237
|
+
font_name = register_font(page, font_file)
|
|
238
|
+
spread = item.get("spread")
|
|
239
|
+
|
|
240
|
+
if spread is None:
|
|
241
|
+
page.insert_text(
|
|
242
|
+
(x, y),
|
|
243
|
+
text,
|
|
244
|
+
fontsize=size,
|
|
245
|
+
fontname=font_name,
|
|
246
|
+
color=BLACK,
|
|
247
|
+
)
|
|
248
|
+
else:
|
|
249
|
+
if not isinstance(spread, dict):
|
|
250
|
+
raise SystemExit(f"overlay[{index}].spread must be an object.")
|
|
251
|
+
step = number(spread.get("step"), f"overlay[{index}].spread.step")
|
|
252
|
+
if step <= 0 or step > 200:
|
|
253
|
+
raise SystemExit(
|
|
254
|
+
f"overlay[{index}].spread.step must be greater than 0 and at most 200."
|
|
255
|
+
)
|
|
256
|
+
skip_values = spread.get("skip", [])
|
|
257
|
+
if (
|
|
258
|
+
not isinstance(skip_values, list)
|
|
259
|
+
or any(
|
|
260
|
+
isinstance(value, bool)
|
|
261
|
+
or not isinstance(value, int)
|
|
262
|
+
or value < 0
|
|
263
|
+
for value in skip_values
|
|
264
|
+
)
|
|
265
|
+
):
|
|
266
|
+
raise SystemExit(
|
|
267
|
+
f"overlay[{index}].spread.skip must contain non-negative integers."
|
|
268
|
+
)
|
|
269
|
+
skip = set(skip_values)
|
|
270
|
+
for character_index, character in enumerate(text):
|
|
271
|
+
if character_index not in skip:
|
|
272
|
+
page.insert_text(
|
|
273
|
+
(x, y),
|
|
274
|
+
character,
|
|
275
|
+
fontsize=size,
|
|
276
|
+
fontname=font_name,
|
|
277
|
+
color=BLACK,
|
|
278
|
+
)
|
|
279
|
+
x += step
|
|
280
|
+
applied += 1
|
|
281
|
+
|
|
282
|
+
return applied
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def save_atomic(document, output: Path) -> None:
|
|
286
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
handle = tempfile.NamedTemporaryFile(
|
|
288
|
+
prefix=f".{output.name}.",
|
|
289
|
+
suffix=".tmp",
|
|
290
|
+
dir=output.parent,
|
|
291
|
+
delete=False,
|
|
292
|
+
)
|
|
293
|
+
temporary = Path(handle.name)
|
|
294
|
+
handle.close()
|
|
295
|
+
try:
|
|
296
|
+
document.save(temporary, garbage=4, deflate=True)
|
|
297
|
+
os.replace(temporary, output)
|
|
298
|
+
os.chmod(output, 0o600)
|
|
299
|
+
except Exception:
|
|
300
|
+
temporary.unlink(missing_ok=True)
|
|
301
|
+
raise
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def preview_path(output: Path) -> Path:
|
|
305
|
+
return output.with_suffix("").with_name(f"{output.stem}_preview")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def validate_preview_target(preview_dir: Path, overwrite: bool) -> None:
|
|
309
|
+
if preview_dir.is_symlink():
|
|
310
|
+
raise SystemExit("Preview directory must not be a symbolic link.")
|
|
311
|
+
if not preview_dir.exists():
|
|
312
|
+
return
|
|
313
|
+
if not preview_dir.is_dir():
|
|
314
|
+
raise SystemExit(f"Preview target is not a directory: {preview_dir}")
|
|
315
|
+
entries = list(preview_dir.iterdir())
|
|
316
|
+
safe = re.compile(r"^page-[1-9][0-9]*\.png$")
|
|
317
|
+
if any(
|
|
318
|
+
entry.is_symlink()
|
|
319
|
+
or not entry.is_file()
|
|
320
|
+
or safe.fullmatch(entry.name) is None
|
|
321
|
+
for entry in entries
|
|
322
|
+
):
|
|
323
|
+
raise SystemExit(
|
|
324
|
+
"Preview directory contains unexpected files; choose another output name."
|
|
325
|
+
)
|
|
326
|
+
if entries and not overwrite:
|
|
327
|
+
raise SystemExit(
|
|
328
|
+
"Preview directory already exists. Use --overwrite for an intentional revision."
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def render_previews(fitz, output: Path, preview_dir: Path, dpi: int) -> Path:
|
|
333
|
+
preview_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
334
|
+
os.chmod(preview_dir, 0o700)
|
|
335
|
+
for existing in preview_dir.glob("page-*.png"):
|
|
336
|
+
existing.unlink()
|
|
337
|
+
completed = fitz.open(output)
|
|
338
|
+
try:
|
|
339
|
+
for page_number in range(completed.page_count):
|
|
340
|
+
target = preview_dir / f"page-{page_number + 1}.png"
|
|
341
|
+
completed[page_number].get_pixmap(dpi=dpi).save(target)
|
|
342
|
+
os.chmod(target, 0o600)
|
|
343
|
+
finally:
|
|
344
|
+
completed.close()
|
|
345
|
+
return preview_dir
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def main() -> None:
|
|
349
|
+
args = parse_args()
|
|
350
|
+
if args.dpi < 72 or args.dpi > 600:
|
|
351
|
+
raise SystemExit("--dpi must be between 72 and 600.")
|
|
352
|
+
|
|
353
|
+
config_path = args.config.resolve()
|
|
354
|
+
config = read_config(config_path)
|
|
355
|
+
base = config_path.parent
|
|
356
|
+
source = resolve_path(base, config.get("src"), "src")
|
|
357
|
+
output_value = config.get("out")
|
|
358
|
+
output_unresolved = Path(output_value).expanduser() if isinstance(output_value, str) else None
|
|
359
|
+
if output_unresolved is not None and not output_unresolved.is_absolute():
|
|
360
|
+
output_unresolved = base / output_unresolved
|
|
361
|
+
if output_unresolved is not None and output_unresolved.is_symlink():
|
|
362
|
+
raise SystemExit("Output must not be a symbolic link.")
|
|
363
|
+
output = resolve_path(base, output_value, "out")
|
|
364
|
+
|
|
365
|
+
if not source.is_file():
|
|
366
|
+
raise SystemExit(f"Source PDF not found or not a regular file: {source}")
|
|
367
|
+
if source == output:
|
|
368
|
+
raise SystemExit("Source and output must be different files.")
|
|
369
|
+
if output.exists() and not args.overwrite:
|
|
370
|
+
raise SystemExit("Output already exists. Use --overwrite for an intentional revision.")
|
|
371
|
+
preview_dir = preview_path(output)
|
|
372
|
+
validate_preview_target(preview_dir, args.overwrite)
|
|
373
|
+
|
|
374
|
+
mode = config.get("mode", "overlay")
|
|
375
|
+
if mode not in {"acroform", "overlay", "both"}:
|
|
376
|
+
raise SystemExit("mode must be acroform, overlay or both.")
|
|
377
|
+
default_size = positive_size(config.get("font_size", 9), "font_size")
|
|
378
|
+
|
|
379
|
+
font_file = None
|
|
380
|
+
if config.get("font_file") is not None:
|
|
381
|
+
font_file = resolve_path(base, config["font_file"], "font_file")
|
|
382
|
+
if not font_file.is_file() or font_file.suffix.lower() not in {".ttf", ".otf"}:
|
|
383
|
+
raise SystemExit("font_file must be an existing local TTF or OTF file.")
|
|
384
|
+
|
|
385
|
+
fitz = load_fitz()
|
|
386
|
+
try:
|
|
387
|
+
document = fitz.open(source)
|
|
388
|
+
except Exception as exc:
|
|
389
|
+
raise SystemExit(f"Could not open source PDF: {exc}") from exc
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
if document.page_count < 1:
|
|
393
|
+
raise SystemExit("The PDF has no pages.")
|
|
394
|
+
if document.needs_pass:
|
|
395
|
+
raise SystemExit("The PDF is encrypted. Decrypt an authorised working copy first.")
|
|
396
|
+
|
|
397
|
+
acroform_count = 0
|
|
398
|
+
overlay_count = 0
|
|
399
|
+
if mode in {"acroform", "both"}:
|
|
400
|
+
acroform_count = apply_acroform(fitz, document, config.get("acroform"))
|
|
401
|
+
if mode in {"overlay", "both"}:
|
|
402
|
+
overlay_count = apply_overlays(
|
|
403
|
+
fitz,
|
|
404
|
+
document,
|
|
405
|
+
config.get("overlay"),
|
|
406
|
+
default_size,
|
|
407
|
+
base,
|
|
408
|
+
font_file,
|
|
409
|
+
)
|
|
410
|
+
save_atomic(document, output)
|
|
411
|
+
finally:
|
|
412
|
+
document.close()
|
|
413
|
+
|
|
414
|
+
preview_dir = render_previews(fitz, output, preview_dir, args.dpi)
|
|
415
|
+
print(
|
|
416
|
+
f"Saved: {output} "
|
|
417
|
+
f"(AcroForm updates: {acroform_count}, overlays: {overlay_count})"
|
|
418
|
+
)
|
|
419
|
+
print(f"Verification previews: {preview_dir}/page-*.png")
|
|
420
|
+
print("Review every page before handoff, signing, sending or submission.")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
if __name__ == "__main__":
|
|
424
|
+
main()
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Inspect a PDF form and render coordinate-grid previews."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_args() -> argparse.Namespace:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
description="List AcroForm fields and render a coordinate grid for each PDF page."
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument("pdf", type=Path)
|
|
17
|
+
parser.add_argument("--dpi", type=int, default=150)
|
|
18
|
+
parser.add_argument("--step", type=int, default=25, help="grid step in PDF points")
|
|
19
|
+
parser.add_argument("--output-dir", type=Path)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--overwrite",
|
|
22
|
+
action="store_true",
|
|
23
|
+
help="replace an existing generated page grid",
|
|
24
|
+
)
|
|
25
|
+
return parser.parse_args()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_fitz():
|
|
29
|
+
try:
|
|
30
|
+
import fitz
|
|
31
|
+
except ImportError as exc:
|
|
32
|
+
raise SystemExit(
|
|
33
|
+
"Missing dependency: PyMuPDF. With user consent, install the "
|
|
34
|
+
"packages listed in the skill's requirements.txt."
|
|
35
|
+
) from exc
|
|
36
|
+
return fitz
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_args(args: argparse.Namespace) -> None:
|
|
40
|
+
if args.dpi < 72 or args.dpi > 600:
|
|
41
|
+
raise SystemExit("--dpi must be between 72 and 600.")
|
|
42
|
+
if args.step < 5 or args.step > 200:
|
|
43
|
+
raise SystemExit("--step must be between 5 and 200 PDF points.")
|
|
44
|
+
if not args.pdf.is_file():
|
|
45
|
+
raise SystemExit(f"PDF not found or not a regular file: {args.pdf}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def list_fields(document) -> list[tuple[int, str, str, tuple[float, ...]]]:
|
|
49
|
+
fields = []
|
|
50
|
+
for page_number in range(document.page_count):
|
|
51
|
+
for widget in document[page_number].widgets() or []:
|
|
52
|
+
fields.append(
|
|
53
|
+
(
|
|
54
|
+
page_number,
|
|
55
|
+
widget.field_name or "",
|
|
56
|
+
widget.field_type_string or "unknown",
|
|
57
|
+
tuple(round(value, 1) for value in widget.rect),
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
return fields
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def draw_grid(fitz, document, step: int):
|
|
64
|
+
grid = fitz.open()
|
|
65
|
+
grid.insert_pdf(document)
|
|
66
|
+
grey = (0.7, 0.7, 0.85)
|
|
67
|
+
red = (0.9, 0.1, 0.1)
|
|
68
|
+
|
|
69
|
+
for page_number in range(grid.page_count):
|
|
70
|
+
page = grid[page_number]
|
|
71
|
+
width, height = page.rect.width, page.rect.height
|
|
72
|
+
|
|
73
|
+
x = 0
|
|
74
|
+
while x <= width:
|
|
75
|
+
major = int(x) % 100 == 0
|
|
76
|
+
page.draw_line(
|
|
77
|
+
(x, 0),
|
|
78
|
+
(x, height),
|
|
79
|
+
color=red if major else grey,
|
|
80
|
+
width=0.5 if major else 0.25,
|
|
81
|
+
)
|
|
82
|
+
if major and x > 0:
|
|
83
|
+
page.insert_text((x + 1, 9), str(int(x)), fontsize=6, color=red)
|
|
84
|
+
x += step
|
|
85
|
+
|
|
86
|
+
y = 0
|
|
87
|
+
while y <= height:
|
|
88
|
+
major = int(y) % 100 == 0
|
|
89
|
+
page.draw_line(
|
|
90
|
+
(0, y),
|
|
91
|
+
(width, y),
|
|
92
|
+
color=red if major else grey,
|
|
93
|
+
width=0.5 if major else 0.25,
|
|
94
|
+
)
|
|
95
|
+
if major and y > 0:
|
|
96
|
+
page.insert_text((2, y - 1), str(int(y)), fontsize=6, color=red)
|
|
97
|
+
y += step
|
|
98
|
+
|
|
99
|
+
return grid
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main() -> None:
|
|
103
|
+
args = parse_args()
|
|
104
|
+
validate_args(args)
|
|
105
|
+
fitz = load_fitz()
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
document = fitz.open(args.pdf)
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
raise SystemExit(f"Could not open PDF: {exc}") from exc
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
if document.page_count < 1:
|
|
114
|
+
raise SystemExit("The PDF has no pages.")
|
|
115
|
+
if document.needs_pass:
|
|
116
|
+
raise SystemExit("The PDF is encrypted. Decrypt an authorised working copy first.")
|
|
117
|
+
|
|
118
|
+
first = document[0].rect
|
|
119
|
+
print(f"PDF: {args.pdf}")
|
|
120
|
+
print(
|
|
121
|
+
f"Pages: {document.page_count} | first page: "
|
|
122
|
+
f"{first.width:.0f} x {first.height:.0f} pt"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
fields = list_fields(document)
|
|
126
|
+
if fields:
|
|
127
|
+
print(f"\nFillable PDF: {len(fields)} AcroForm fields")
|
|
128
|
+
print(f"{'page':>4} {'type':<14} name")
|
|
129
|
+
for page_number, name, field_type, rect in fields:
|
|
130
|
+
print(
|
|
131
|
+
f"{page_number:>4} {field_type:<14} "
|
|
132
|
+
f"{name!r} rect={rect}"
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
print("\nFlat PDF: no AcroForm fields. Use coordinate overlays.")
|
|
136
|
+
|
|
137
|
+
output_unresolved = (
|
|
138
|
+
args.output_dir.expanduser()
|
|
139
|
+
if args.output_dir
|
|
140
|
+
else args.pdf.resolve().with_suffix("").with_name(
|
|
141
|
+
f"{args.pdf.stem}_grid"
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
if output_unresolved.is_symlink():
|
|
145
|
+
raise SystemExit("Grid output directory must not be a symbolic link.")
|
|
146
|
+
output_dir = output_unresolved.resolve()
|
|
147
|
+
if output_dir.exists() and not output_dir.is_dir():
|
|
148
|
+
raise SystemExit(f"Grid output target is not a directory: {output_dir}")
|
|
149
|
+
if output_dir.exists():
|
|
150
|
+
safe = re.compile(r"^page-[1-9][0-9]*\.png$")
|
|
151
|
+
entries = list(output_dir.iterdir())
|
|
152
|
+
if any(
|
|
153
|
+
entry.is_symlink()
|
|
154
|
+
or not entry.is_file()
|
|
155
|
+
or safe.fullmatch(entry.name) is None
|
|
156
|
+
for entry in entries
|
|
157
|
+
):
|
|
158
|
+
raise SystemExit(
|
|
159
|
+
"Grid output directory contains unexpected files; "
|
|
160
|
+
"choose another --output-dir."
|
|
161
|
+
)
|
|
162
|
+
if entries and not args.overwrite:
|
|
163
|
+
raise SystemExit(
|
|
164
|
+
"Grid output already exists. Use --overwrite for an "
|
|
165
|
+
"intentional revision."
|
|
166
|
+
)
|
|
167
|
+
for entry in entries:
|
|
168
|
+
entry.unlink()
|
|
169
|
+
output_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
170
|
+
os.chmod(output_dir, 0o700)
|
|
171
|
+
|
|
172
|
+
grid = draw_grid(fitz, document, args.step)
|
|
173
|
+
try:
|
|
174
|
+
for page_number in range(grid.page_count):
|
|
175
|
+
target = output_dir / f"page-{page_number + 1}.png"
|
|
176
|
+
grid[page_number].get_pixmap(dpi=args.dpi).save(target)
|
|
177
|
+
os.chmod(target, 0o600)
|
|
178
|
+
finally:
|
|
179
|
+
grid.close()
|
|
180
|
+
|
|
181
|
+
print(f"\nCoordinate grids: {output_dir}/page-*.png")
|
|
182
|
+
print("Review the PNG files before choosing overlay coordinates.")
|
|
183
|
+
finally:
|
|
184
|
+
document.close()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
main()
|