@mmmbuto/nexuscrew 0.8.33 → 0.8.35

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