@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,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()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Prepare an explicitly authorised local signature image for PDF overlay."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_args() -> argparse.Namespace:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
description=(
|
|
15
|
+
"Remove paper background, crop and export a signature as transparent PNG. "
|
|
16
|
+
"Use only with the signer's explicit authorisation."
|
|
17
|
+
)
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument("src", type=Path)
|
|
20
|
+
parser.add_argument("out", type=Path)
|
|
21
|
+
parser.add_argument("--dark", type=int, default=90)
|
|
22
|
+
parser.add_argument("--light", type=int, default=185)
|
|
23
|
+
parser.add_argument("--margin", type=int, default=12)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--min-alpha",
|
|
26
|
+
type=int,
|
|
27
|
+
default=60,
|
|
28
|
+
help="discard low-opacity background pixels; 0 disables",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--max-width",
|
|
32
|
+
type=int,
|
|
33
|
+
default=0,
|
|
34
|
+
help="downsize to this pixel width; 0 preserves the cropped width",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--overwrite",
|
|
38
|
+
action="store_true",
|
|
39
|
+
help="replace an existing output file, never the source",
|
|
40
|
+
)
|
|
41
|
+
return parser.parse_args()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_dependencies():
|
|
45
|
+
try:
|
|
46
|
+
import numpy
|
|
47
|
+
from PIL import Image
|
|
48
|
+
except ImportError as exc:
|
|
49
|
+
raise SystemExit(
|
|
50
|
+
"Missing dependency: numpy and Pillow are required. With user "
|
|
51
|
+
"consent, install the packages listed in requirements.txt."
|
|
52
|
+
) from exc
|
|
53
|
+
return numpy, Image
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def validate_args(args: argparse.Namespace) -> tuple[Path, Path]:
|
|
57
|
+
source = args.src.resolve()
|
|
58
|
+
output_unresolved = args.out.expanduser()
|
|
59
|
+
if output_unresolved.is_symlink():
|
|
60
|
+
raise SystemExit("Output must not be a symbolic link.")
|
|
61
|
+
output = output_unresolved.resolve()
|
|
62
|
+
|
|
63
|
+
if not source.is_file():
|
|
64
|
+
raise SystemExit(f"Input image not found or not a regular file: {source}")
|
|
65
|
+
if source == output:
|
|
66
|
+
raise SystemExit("Source and output must be different files.")
|
|
67
|
+
if output.exists() and not args.overwrite:
|
|
68
|
+
raise SystemExit("Output already exists. Use --overwrite for an intentional revision.")
|
|
69
|
+
if output.suffix.lower() != ".png":
|
|
70
|
+
raise SystemExit("Output must use the .png extension.")
|
|
71
|
+
if not 0 <= args.dark < args.light <= 255:
|
|
72
|
+
raise SystemExit("Require 0 <= --dark < --light <= 255.")
|
|
73
|
+
if args.margin < 0 or args.margin > 1000:
|
|
74
|
+
raise SystemExit("--margin must be between 0 and 1000 pixels.")
|
|
75
|
+
if not 0 <= args.min_alpha <= 255:
|
|
76
|
+
raise SystemExit("--min-alpha must be between 0 and 255.")
|
|
77
|
+
if args.max_width < 0 or args.max_width > 20_000:
|
|
78
|
+
raise SystemExit("--max-width must be between 0 and 20000 pixels.")
|
|
79
|
+
return source, output
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def flatten_to_greyscale(Image, source):
|
|
83
|
+
if source.mode in ("RGBA", "LA") or (
|
|
84
|
+
source.mode == "P" and "transparency" in source.info
|
|
85
|
+
):
|
|
86
|
+
rgba = source.convert("RGBA")
|
|
87
|
+
flattened = Image.new("RGB", rgba.size, (255, 255, 255))
|
|
88
|
+
flattened.paste(rgba, mask=rgba.getchannel("A"))
|
|
89
|
+
return flattened.convert("L")
|
|
90
|
+
return source.convert("L")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main() -> None:
|
|
94
|
+
args = parse_args()
|
|
95
|
+
source_path, output_path = validate_args(args)
|
|
96
|
+
np, Image = load_dependencies()
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
with Image.open(source_path) as source:
|
|
100
|
+
source.load()
|
|
101
|
+
grey = np.asarray(flatten_to_greyscale(Image, source), dtype=np.float32)
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
raise SystemExit(f"Could not read input image: {exc}") from exc
|
|
104
|
+
|
|
105
|
+
dark = float(args.dark)
|
|
106
|
+
light = float(args.light)
|
|
107
|
+
alpha = np.clip((light - grey) / (light - dark), 0.0, 1.0)
|
|
108
|
+
|
|
109
|
+
ink = alpha > 0.35
|
|
110
|
+
height, width = ink.shape
|
|
111
|
+
column_ink = ink.sum(axis=0)
|
|
112
|
+
row_ink = ink.sum(axis=1)
|
|
113
|
+
minimum_column = max(3, int(0.004 * height))
|
|
114
|
+
minimum_row = max(3, int(0.004 * width))
|
|
115
|
+
columns = np.where(column_ink >= minimum_column)[0]
|
|
116
|
+
rows = np.where(row_ink >= minimum_row)[0]
|
|
117
|
+
if columns.size == 0 or rows.size == 0:
|
|
118
|
+
raise SystemExit(
|
|
119
|
+
"No consistent ink stroke detected. Adjust --dark/--light and inspect the source."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
margin = args.margin
|
|
123
|
+
x0 = max(0, int(columns[0]) - margin)
|
|
124
|
+
x1 = min(width, int(columns[-1]) + 1 + margin)
|
|
125
|
+
y0 = max(0, int(rows[0]) - margin)
|
|
126
|
+
y1 = min(height, int(rows[-1]) + 1 + margin)
|
|
127
|
+
|
|
128
|
+
cropped_alpha = (alpha[y0:y1, x0:x1] * 255).astype(np.uint8)
|
|
129
|
+
if args.min_alpha:
|
|
130
|
+
cropped_alpha[cropped_alpha < args.min_alpha] = 0
|
|
131
|
+
|
|
132
|
+
crop_height, crop_width = cropped_alpha.shape
|
|
133
|
+
pixels = np.zeros((crop_height, crop_width, 4), dtype=np.uint8)
|
|
134
|
+
pixels[..., 3] = cropped_alpha
|
|
135
|
+
prepared = Image.fromarray(pixels, "RGBA")
|
|
136
|
+
|
|
137
|
+
if args.max_width and crop_width > args.max_width:
|
|
138
|
+
new_height = max(1, round(crop_height * args.max_width / crop_width))
|
|
139
|
+
prepared = prepared.resize(
|
|
140
|
+
(args.max_width, new_height),
|
|
141
|
+
Image.Resampling.LANCZOS,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
handle = tempfile.NamedTemporaryFile(
|
|
146
|
+
prefix=f".{output_path.name}.",
|
|
147
|
+
suffix=".tmp",
|
|
148
|
+
dir=output_path.parent,
|
|
149
|
+
delete=False,
|
|
150
|
+
)
|
|
151
|
+
temporary = Path(handle.name)
|
|
152
|
+
handle.close()
|
|
153
|
+
try:
|
|
154
|
+
prepared.save(temporary, format="PNG")
|
|
155
|
+
os.replace(temporary, output_path)
|
|
156
|
+
os.chmod(output_path, 0o600)
|
|
157
|
+
except Exception:
|
|
158
|
+
temporary.unlink(missing_ok=True)
|
|
159
|
+
raise
|
|
160
|
+
print(
|
|
161
|
+
f"Saved: {output_path} | crop {prepared.width}x{prepared.height}px "
|
|
162
|
+
f"from {width}x{height}px"
|
|
163
|
+
)
|
|
164
|
+
print(
|
|
165
|
+
"Verify the PNG on a pure white background. Keep it private and use it "
|
|
166
|
+
"only for the specifically authorised document."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
main()
|