@mmmbuto/nexuscrew 0.8.32 → 0.8.34

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.
@@ -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()