@mmmbuto/nexuscrew 0.8.33 → 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,207 @@
1
+ #!/usr/bin/env python3
2
+ """Fill explicit placeholders in a local DOCX template."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import tempfile
11
+ from collections import Counter
12
+ from pathlib import Path
13
+
14
+
15
+ def parse_args() -> argparse.Namespace:
16
+ parser = argparse.ArgumentParser(
17
+ description="Replace {{placeholders}} in a DOCX form using a JSON mapping."
18
+ )
19
+ parser.add_argument("src", type=Path)
20
+ parser.add_argument("out", type=Path)
21
+ parser.add_argument("--data", required=True, type=Path)
22
+ parser.add_argument(
23
+ "--literal-keys",
24
+ action="store_true",
25
+ help="also replace unbraced mapping keys; use only for an inspected template",
26
+ )
27
+ parser.add_argument(
28
+ "--allow-unused",
29
+ action="store_true",
30
+ help="allow mapping keys that were not found in the document",
31
+ )
32
+ parser.add_argument(
33
+ "--overwrite",
34
+ action="store_true",
35
+ help="replace an existing output file, never the source",
36
+ )
37
+ return parser.parse_args()
38
+
39
+
40
+ def load_docx():
41
+ try:
42
+ import docx
43
+ except ImportError as exc:
44
+ raise SystemExit(
45
+ "Missing dependency: python-docx. With user consent, install the "
46
+ "packages listed in the skill's requirements.txt."
47
+ ) from exc
48
+ return docx
49
+
50
+
51
+ def load_mapping(path: Path) -> dict[str, str]:
52
+ if not path.is_file():
53
+ raise SystemExit(f"Data file not found: {path}")
54
+ if path.stat().st_size > 2 * 1024 * 1024:
55
+ raise SystemExit("Data JSON exceeds the 2 MiB safety limit.")
56
+
57
+ try:
58
+ raw = json.loads(path.read_text(encoding="utf-8"))
59
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
60
+ raise SystemExit(f"Could not read data JSON: {exc}") from exc
61
+
62
+ if not isinstance(raw, dict) or not raw:
63
+ raise SystemExit("Data JSON must be a non-empty object.")
64
+
65
+ mapping: dict[str, str] = {}
66
+ for key, value in raw.items():
67
+ if (
68
+ not isinstance(key, str)
69
+ or not key.strip()
70
+ or len(key) > 128
71
+ or "{{" in key
72
+ or "}}" in key
73
+ ):
74
+ raise SystemExit("Every placeholder key must be a plain non-empty string.")
75
+ if value is None:
76
+ mapping[key] = ""
77
+ elif isinstance(value, (str, int, float, bool)):
78
+ mapping[key] = str(value)
79
+ else:
80
+ raise SystemExit(f"Placeholder {key!r} has a non-scalar value.")
81
+ return mapping
82
+
83
+
84
+ def token_pattern(mapping: dict[str, str], literal_keys: bool):
85
+ token_to_key = {}
86
+ for key in mapping:
87
+ token_to_key[f"{{{{{key}}}}}"] = key
88
+ if literal_keys:
89
+ token_to_key[key] = key
90
+ ordered = sorted(token_to_key, key=len, reverse=True)
91
+ return re.compile("|".join(re.escape(token) for token in ordered)), token_to_key
92
+
93
+
94
+ def replace_in_paragraph(paragraph, pattern, token_to_key, mapping, counts) -> None:
95
+ original = "".join(run.text for run in paragraph.runs)
96
+ if not original:
97
+ return
98
+
99
+ def replacement(match):
100
+ key = token_to_key[match.group(0)]
101
+ counts[key] += 1
102
+ return mapping[key]
103
+
104
+ updated = pattern.sub(replacement, original)
105
+ if updated == original:
106
+ return
107
+ if not paragraph.runs:
108
+ paragraph.add_run(updated)
109
+ return
110
+ paragraph.runs[0].text = updated
111
+ for run in paragraph.runs[1:]:
112
+ run.text = ""
113
+
114
+
115
+ def process_tables(tables, process_paragraph) -> None:
116
+ for table in tables:
117
+ for row in table.rows:
118
+ for cell in row.cells:
119
+ for paragraph in cell.paragraphs:
120
+ process_paragraph(paragraph)
121
+ process_tables(cell.tables, process_paragraph)
122
+
123
+
124
+ def save_atomic(document, output: Path) -> None:
125
+ output.parent.mkdir(parents=True, exist_ok=True)
126
+ handle = tempfile.NamedTemporaryFile(
127
+ prefix=f".{output.name}.",
128
+ suffix=".tmp",
129
+ dir=output.parent,
130
+ delete=False,
131
+ )
132
+ temporary = Path(handle.name)
133
+ handle.close()
134
+ try:
135
+ document.save(temporary)
136
+ os.replace(temporary, output)
137
+ os.chmod(output, 0o600)
138
+ except Exception:
139
+ temporary.unlink(missing_ok=True)
140
+ raise
141
+
142
+
143
+ def main() -> None:
144
+ args = parse_args()
145
+ source = args.src.resolve()
146
+ output_unresolved = args.out.expanduser()
147
+ if output_unresolved.is_symlink():
148
+ raise SystemExit("Output must not be a symbolic link.")
149
+ output = output_unresolved.resolve()
150
+ data_path = args.data.resolve()
151
+
152
+ if not source.is_file():
153
+ raise SystemExit(f"DOCX not found or not a regular file: {source}")
154
+ if source == output:
155
+ raise SystemExit("Source and output must be different files.")
156
+ if output.exists() and not args.overwrite:
157
+ raise SystemExit("Output already exists. Use --overwrite for an intentional revision.")
158
+
159
+ mapping = load_mapping(data_path)
160
+ pattern, token_to_key = token_pattern(mapping, args.literal_keys)
161
+ counts = Counter()
162
+ docx = load_docx()
163
+
164
+ try:
165
+ document = docx.Document(source)
166
+ except Exception as exc:
167
+ raise SystemExit(f"Could not open DOCX: {exc}") from exc
168
+
169
+ def process(paragraph):
170
+ replace_in_paragraph(
171
+ paragraph,
172
+ pattern,
173
+ token_to_key,
174
+ mapping,
175
+ counts,
176
+ )
177
+
178
+ for paragraph in document.paragraphs:
179
+ process(paragraph)
180
+ process_tables(document.tables, process)
181
+
182
+ for section in document.sections:
183
+ for paragraph in section.header.paragraphs:
184
+ process(paragraph)
185
+ process_tables(section.header.tables, process)
186
+ for paragraph in section.footer.paragraphs:
187
+ process(paragraph)
188
+ process_tables(section.footer.tables, process)
189
+
190
+ unused = sorted(key for key in mapping if counts[key] == 0)
191
+ if unused and not args.allow_unused:
192
+ raise SystemExit(
193
+ "Unused placeholders: "
194
+ + ", ".join(repr(key) for key in unused)
195
+ + ". Inspect the template or use --allow-unused deliberately."
196
+ )
197
+
198
+ save_atomic(document, output)
199
+ print(f"Saved: {output}")
200
+ print(f"Replacements: {sum(counts.values())}")
201
+ if unused:
202
+ print("Unused placeholders allowed: " + ", ".join(repr(key) for key in unused))
203
+ print("Review the completed DOCX or export a local copy to PDF before handoff.")
204
+
205
+
206
+ if __name__ == "__main__":
207
+ main()
@@ -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()