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