@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.
- package/CHANGELOG.md +25 -0
- package/README.md +20 -1
- package/frontend/dist/assets/{index-CP5MeWCH.js → index-cNTOIj7e.js} +1 -1
- package/frontend/dist/index.html +1 -1
- 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,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()
|