@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.
@@ -112,8 +112,22 @@ function parseFlags(argv, valueFlags) {
112
112
 
113
113
  function serve(opts = {}) {
114
114
  const serverStart = opts.serverStart || require('../server.js').start;
115
+ const platform = opts.platform || detectPlatform();
116
+ // A legacy Termux:Boot script can start the new package from a replaceable
117
+ // npm directory. Correct the live process before it can create the shared
118
+ // tmux server, then atomically refresh both boot definitions for next boot.
119
+ if (platform === 'termux') {
120
+ stabilizeTermuxWorkingDirectory({ ...opts, platform });
121
+ try {
122
+ repairTermuxBootDefinitions({ ...opts, platform, clearMarker: true });
123
+ } catch (error) {
124
+ const warn = opts.lifecycleWarn || ((message) => process.stderr.write(`${message}\n`));
125
+ warn(`nexuscrew: Termux boot auto-repair pending — ${String(error && error.message || error)}`);
126
+ }
127
+ }
115
128
  // Service manager e Termux:Boot entrano direttamente da `serve`, senza
116
- // passare per smartUp. Ripara solo fleet.json MANCANTE; un file invalido
129
+ // passare per smartUp. Su Termux ripara anche gli script di boot legacy.
130
+ // Per Fleet, ripara solo fleet.json MANCANTE; un file invalido
117
131
  // resta intatto e il provider continua a fallire chiuso.
118
132
  (opts.ensureFleetDefaultsImpl || ensureFleetDefaults)(opts);
119
133
  if (opts.pidfile) {
@@ -279,6 +293,37 @@ function restart(opts = {}) {
279
293
  const execImpl = opts.execImpl || execFileSync;
280
294
  const log = opts.log || console.log;
281
295
  if (!['linux', 'mac', 'termux'].includes(platform)) throw new Error(`restart: platform ${platform} non supportata`);
296
+ let termuxRepair = null;
297
+ if (platform === 'termux') {
298
+ try {
299
+ stabilizeTermuxWorkingDirectory({ ...opts, platform });
300
+ termuxRepair = repairTermuxBootDefinitions({
301
+ ...opts, platform, clearMarker: false,
302
+ });
303
+ } catch (error) {
304
+ return {
305
+ platform,
306
+ runtimeOwner: 'portable',
307
+ restarted: false,
308
+ reason: `Termux boot auto-repair failed: ${String(error && error.message || error)}`,
309
+ };
310
+ }
311
+ }
312
+ const complete = (result) => {
313
+ if (platform !== 'termux' || !result || result.restarted !== true || !termuxRepair?.markerPending) {
314
+ return result;
315
+ }
316
+ try {
317
+ clearServiceMigrationMarker(opts.home || require('node:os').homedir(), opts.serviceMigrationMarkerPath);
318
+ return result;
319
+ } catch (error) {
320
+ return {
321
+ ...result,
322
+ serviceRepairPending: true,
323
+ warning: `Termux boot repair marker cleanup pending: ${String(error && error.message || error)}`,
324
+ };
325
+ }
326
+ };
282
327
  const before = resolveRuntimeOwner({ ...opts, platform, execImpl });
283
328
 
284
329
  // A managed owner can use the service manager's atomic restart. In a
@@ -307,7 +352,7 @@ function restart(opts = {}) {
307
352
  execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
308
353
  log(`restart: launchctl kickstart -k ${label}`);
309
354
  }
310
- return { platform, owner: before.owner, runtimeOwner: 'managed', restarted: true };
355
+ return complete({ platform, owner: before.owner, runtimeOwner: 'managed', restarted: true });
311
356
  } catch (error) {
312
357
  return { platform, owner: before.owner, runtimeOwner: 'managed', restarted: false, reason: String(error.message || error) };
313
358
  }
@@ -319,7 +364,7 @@ function restart(opts = {}) {
319
364
  const started = (opts.startPortableImpl || startPortable)({ ...opts, platform, spawnImpl: opts.spawnImpl });
320
365
  const ok = !!(started && started.started !== false);
321
366
  if (ok) log('restart: portable runtime started');
322
- return { platform, owner: before.owner, runtimeOwner: 'portable', restarted: ok, reason: ok ? undefined : started && started.reason };
367
+ return complete({ platform, owner: before.owner, runtimeOwner: 'portable', restarted: ok, reason: ok ? undefined : started && started.reason });
323
368
  }
324
369
 
325
370
  // `restart` on a stopped runtime follows the explicit boot owner; with boot
@@ -330,7 +375,7 @@ function restart(opts = {}) {
330
375
  : (opts.startPortableImpl || startPortable)({ ...opts, platform, spawnImpl: opts.spawnImpl });
331
376
  const ok = !!(started && started.started !== false);
332
377
  if (ok) log(`restart: ${useManaged ? 'managed' : 'portable'} runtime started`);
333
- return { platform, owner: before.owner, runtimeOwner: useManaged ? 'managed' : 'portable', restarted: ok, reason: ok ? undefined : started && started.reason };
378
+ return complete({ platform, owner: before.owner, runtimeOwner: useManaged ? 'managed' : 'portable', restarted: ok, reason: ok ? undefined : started && started.reason });
334
379
  }
335
380
 
336
381
  function wizardComplete(configPath) {
@@ -406,6 +451,63 @@ function clearServiceMigrationMarker(home, override) {
406
451
  }
407
452
  }
408
453
 
454
+ // Termux app updates replace the global npm package while existing boot
455
+ // scripts remain on disk. Always enter the stable HOME before serving or
456
+ // booting Fleet so a legacy script cannot make tmux inherit the package cwd.
457
+ function stabilizeTermuxWorkingDirectory(opts = {}) {
458
+ const platform = opts.platform || detectPlatform();
459
+ if (platform !== 'termux') return { platform, changed: false };
460
+ const home = opts.home || require('node:os').homedir();
461
+ (opts.chdirImpl || process.chdir)(home);
462
+ return { platform, changed: true, cwd: home };
463
+ }
464
+
465
+ // Refresh only an already-enabled Termux:Boot installation. Boot remains
466
+ // opt-in: missing scripts are never created by this repair path. A durable
467
+ // marker makes an interrupted refresh visible and retryable.
468
+ function repairTermuxBootDefinitions(opts = {}) {
469
+ const platform = opts.platform || detectPlatform();
470
+ const home = opts.home || require('node:os').homedir();
471
+ if (platform !== 'termux') return { platform, persistent: false, refreshed: false, markerPending: false };
472
+ const persistent = bootState({ ...opts, platform }).enabled;
473
+ if (!persistent) return { platform, persistent: false, refreshed: false, markerPending: false };
474
+
475
+ const markerPending = serviceMigrationPending(home, opts.serviceMigrationMarkerPath);
476
+ const refreshNeeded = serviceDefinitionNeedsRefresh(
477
+ platform, home, opts.installPath, opts.fleetInstallPath,
478
+ );
479
+ if (!markerPending && !refreshNeeded) {
480
+ return { platform, persistent: true, refreshed: false, markerPending: false };
481
+ }
482
+
483
+ if (refreshNeeded) {
484
+ ensureServiceMigrationMarker(home, opts.serviceMigrationMarkerPath);
485
+ const result = (opts.runInitImpl || runInit)({
486
+ ...opts,
487
+ home,
488
+ platform,
489
+ installBoot: true,
490
+ printUrl: false,
491
+ log: opts.lifecycleLog || (() => {}),
492
+ });
493
+ if (serviceDefinitionNeedsRefresh(platform, home, opts.installPath, opts.fleetInstallPath)) {
494
+ throw new Error('generated Termux boot definitions failed stable-HOME verification');
495
+ }
496
+ const failures = Array.isArray(result?.installFailures) ? result.installFailures : [];
497
+ if (failures.length) {
498
+ const components = [...new Set(failures.map((failure) => failure?.component)
499
+ .filter((component) => component === 'service' || component === 'fleet-companion'))];
500
+ throw new Error(`Termux boot definition install failed${components.length ? ` (${components.join(', ')})` : ''}`);
501
+ }
502
+ }
503
+
504
+ const pending = markerPending || refreshNeeded;
505
+ if (pending && opts.clearMarker !== false) {
506
+ clearServiceMigrationMarker(home, opts.serviceMigrationMarkerPath);
507
+ }
508
+ return { platform, persistent: true, refreshed: refreshNeeded, markerPending: pending };
509
+ }
510
+
409
511
  function portAvailable(port, host = '127.0.0.1') {
410
512
  return new Promise((resolve) => {
411
513
  const s = net.createServer();
@@ -742,6 +844,7 @@ function update(opts = {}) {
742
844
  // / cfg / exit (per test, no process.exit che uccide il runner).
743
845
  async function runFleetBoot(opts = {}) {
744
846
  const log = opts.log || console.log;
847
+ stabilizeTermuxWorkingDirectory(opts);
745
848
  const loadConfig = opts.loadConfig || require('../config.js').loadConfig;
746
849
  const selectProvider = opts.selectProvider || require('../fleet/provider.js').selectProvider;
747
850
  const { bootCells } = require('../fleet/boot.js');
@@ -1065,6 +1168,8 @@ module.exports = {
1065
1168
  serviceDefinitionNeedsRefresh,
1066
1169
  fleetServiceDefinitionNeedsRefresh,
1067
1170
  serviceMigrationPending,
1171
+ stabilizeTermuxWorkingDirectory,
1172
+ repairTermuxBootDefinitions,
1068
1173
  portAvailable, findAvailablePort, probeNexusCrew, waitForNexusCrew, openPwa, startPortable, wizardComplete,
1069
1174
  servicePinsLegacyPort,
1070
1175
  isServiceRunning, readRoles,
@@ -30,9 +30,24 @@ function removePidfile(p) {
30
30
  try { fs.unlinkSync(p); } catch (_) {}
31
31
  }
32
32
 
33
- function pidExists(pid) {
34
- try { process.kill(pid, 0); return true; }
35
- catch (e) { return e.code === 'EPERM'; } // EPERM = esiste ma non nostro
33
+ // A PID can exist without belonging to this UID. Android commonly reuses PIDs
34
+ // across app sandboxes; kill(pid, 0) then returns EPERM and /proc is hidden.
35
+ // Keep generic existence separate from NexusCrew ownership so a foreign PID
36
+ // can never keep one of our pidfiles "alive" forever.
37
+ function pidOwnership(pid, killImpl = process.kill) {
38
+ try {
39
+ killImpl(pid, 0);
40
+ return 'owned';
41
+ } catch (e) {
42
+ if (e && e.code === 'EPERM') return 'foreign';
43
+ if (e && e.code === 'ESRCH') return 'missing';
44
+ return 'unknown';
45
+ }
46
+ }
47
+
48
+ function pidExists(pid, killImpl = process.kill) {
49
+ const ownership = pidOwnership(pid, killImpl);
50
+ return ownership === 'owned' || ownership === 'foreign';
36
51
  }
37
52
 
38
53
  function readCmdline(pid) {
@@ -50,36 +65,46 @@ function cmdMatches(savedCmd, liveCmd) {
50
65
  return liveCmd.includes(savedCmd) || savedCmd.includes(liveCmd);
51
66
  }
52
67
 
53
- // true se il pid esiste E il cmd matcha (o non verificabile).
54
- function isAlive(meta) {
68
+ // true se il pid appartiene a questo UID E il cmd matcha (o non verificabile).
69
+ // EPERM is deliberately false: NexusCrew must neither adopt nor signal a
70
+ // process owned by another Android/Linux user.
71
+ function isAlive(meta, impl = {}) {
55
72
  if (!meta || !Number.isFinite(meta.pid)) return false;
56
- if (!pidExists(meta.pid)) return false;
73
+ if (pidOwnership(meta.pid, impl.killImpl || process.kill) !== 'owned') return false;
57
74
  if (meta.cmd) {
58
- const live = readCmdline(meta.pid);
75
+ const live = (impl.readCmdlineImpl || readCmdline)(meta.pid);
59
76
  if (live) return cmdMatches(meta.cmd, live);
60
77
  }
61
78
  return true;
62
79
  }
63
80
 
64
81
  // Rimuove pidfile stale (pid morto o non verificabile). Ritorna true se rimosso.
65
- function cleanStale(p) {
82
+ function cleanStale(p, impl = {}) {
66
83
  const meta = readPidfile(p);
67
84
  if (!meta) return false;
68
- if (!isAlive(meta)) { removePidfile(p); return true; }
85
+ if (!isAlive(meta, impl)) { removePidfile(p); return true; }
69
86
  return false;
70
87
  }
71
88
 
72
89
  // Kill verificato: legge pidfile, verifica pid+cmd, signal. MAI broad match by name.
73
90
  // Ritorna { killed, pid?, reason? }.
74
- function killPidfile(p, signal = 'SIGTERM') {
91
+ function killPidfile(p, signal = 'SIGTERM', impl = {}) {
75
92
  const meta = readPidfile(p);
76
93
  if (!meta) return { killed: false, reason: 'no pidfile' };
77
- if (!pidExists(meta.pid)) {
94
+ const killImpl = impl.killImpl || process.kill;
95
+ const ownership = pidOwnership(meta.pid, killImpl);
96
+ if (ownership === 'missing' || ownership === 'unknown') {
78
97
  removePidfile(p);
79
98
  return { killed: false, reason: 'stale (pid dead)' };
80
99
  }
100
+ if (ownership === 'foreign') {
101
+ // Never send a real signal after an EPERM ownership probe. The pidfile is
102
+ // ours; the process is not.
103
+ removePidfile(p);
104
+ return { killed: false, reason: 'stale (pid not owned)' };
105
+ }
81
106
  if (meta.cmd) {
82
- const live = readCmdline(meta.pid);
107
+ const live = (impl.readCmdlineImpl || readCmdline)(meta.pid);
83
108
  if (live && !cmdMatches(meta.cmd, live)) {
84
109
  // PID reuse: processo diverso. Non killare. Rimuovi pidfile stale.
85
110
  removePidfile(p);
@@ -87,7 +112,7 @@ function killPidfile(p, signal = 'SIGTERM') {
87
112
  }
88
113
  }
89
114
  try {
90
- process.kill(meta.pid, signal);
115
+ killImpl(meta.pid, signal);
91
116
  removePidfile(p);
92
117
  return { killed: true, pid: meta.pid };
93
118
  } catch (e) {
@@ -97,5 +122,5 @@ function killPidfile(p, signal = 'SIGTERM') {
97
122
 
98
123
  module.exports = {
99
124
  defaultPidfilePath, readPidfile, writePidfile, removePidfile,
100
- pidExists, readCmdline, isAlive, cleanStale, killPidfile,
125
+ pidOwnership, pidExists, readCmdline, isAlive, cleanStale, killPidfile,
101
126
  };
@@ -130,7 +130,12 @@ function reconcileTunnelSupervisors({ home = os.homedir(), configuredNames = [],
130
130
  .filter((name) => store.NODE_NAME_RE.test(String(name || ''))));
131
131
  const stopOne = typeof stopImpl === 'function' ? stopImpl : stopTunnel;
132
132
  const result = { kept: [], stopped: [], cleaned: [], failed: [] };
133
- const safeAbsent = new Set(['no pidfile', 'stale (pid dead)', 'pid reuse (cmd mismatch)']);
133
+ const safeAbsent = new Set([
134
+ 'no pidfile',
135
+ 'stale (pid dead)',
136
+ 'stale (pid not owned)',
137
+ 'pid reuse (cmd mismatch)',
138
+ ]);
134
139
  for (const name of tunnelPidNames(home)) {
135
140
  if (keep.has(name)) { result.kept.push(name); continue; }
136
141
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.33",
3
+ "version": "0.8.34",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {
@@ -0,0 +1,154 @@
1
+ ---
2
+ name: fill-forms
3
+ description: Use for inspecting, filling and visually validating local PDF or DOCX forms, including AcroForm fields, coordinate-based overlays, checkboxes, character boxes and an explicitly authorised signature image. Trigger when the user asks to fill in a PDF, complete a form, prepare paperwork for signature, populate a DOCX template or inspect a form before completion. Keep processing local, preserve the original, never invent personal data, and never sign or submit a document without explicit authorisation.
4
+ ---
5
+
6
+ # Fill Forms
7
+
8
+ Fill PDF and DOCX forms locally with the bundled scripts. Prefer named PDF
9
+ fields when they exist; use coordinate overlays only for flat forms or scans.
10
+ Always preserve the blank source and visually verify a new output file.
11
+
12
+ ## Select the response language
13
+
14
+ Choose the language for questions, progress updates and explanations in this
15
+ order:
16
+
17
+ 1. the user's explicit language preference;
18
+ 2. the language of the current request;
19
+ 3. a reliable client or system locale;
20
+ 4. English.
21
+
22
+ Do not translate field values, names, identifiers, legal wording or document
23
+ text merely to match the response language. Preserve the document's language
24
+ unless the user explicitly requests a translation.
25
+
26
+ ## Apply the safety boundary
27
+
28
+ - Work on local copies. Do not upload a form or its contents to an external
29
+ service unless the user explicitly requests and authorises that transfer.
30
+ - Never invent a name, address, identifier, account number, date, selection or
31
+ legal answer. Ask for missing data or leave the field visibly unresolved.
32
+ - Treat signatures as sensitive assets. Insert a signature image only when the
33
+ authorised signer supplies it and explicitly asks for insertion into the
34
+ identified document. Never keep signature images in source control.
35
+ - Filling is not signing, sending or submitting. Obtain separate explicit
36
+ authorisation before any consequential external action.
37
+ - Never overwrite the blank source. The scripts reject identical source and
38
+ output paths and require `--overwrite` before replacing an existing output.
39
+ - Generated documents, prepared signatures and preview images are written as
40
+ owner-only files; generated preview/grid directories are owner-only.
41
+ - Keep configuration files containing personal data out of source control and
42
+ remove temporary previews when the user no longer needs them.
43
+
44
+ ## Prepare dependencies
45
+
46
+ Resolve every script relative to this `SKILL.md`. The scripts require Python 3
47
+ and do not install packages automatically. Check the environment first:
48
+
49
+ ```bash
50
+ python3 --version
51
+ python3 -c "import fitz"
52
+ ```
53
+
54
+ For PDF image/signature handling and DOCX support, the complete dependency set
55
+ is listed in `requirements.txt`. If dependencies are missing, explain what is
56
+ needed and ask before creating a virtual environment or installing packages.
57
+ Never modify the system Python implicitly.
58
+
59
+ ## Inspect first
60
+
61
+ Run the inspector before filling a PDF:
62
+
63
+ ```bash
64
+ python3 <skill-dir>/scripts/inspect_pdf.py form.pdf
65
+ ```
66
+
67
+ It reports AcroForm fields and produces coordinate-grid PNG files next to the
68
+ source. Review those images rather than guessing positions. Replacing an
69
+ existing grid requires an intentional `--overwrite`.
70
+
71
+ - If named fields exist, prefer `mode: "acroform"`.
72
+ - For a flat PDF or scan, use `mode: "overlay"`.
73
+ - Use `mode: "both"` only when the document genuinely mixes both forms.
74
+
75
+ For precise checkboxes and signature lines, anchor coordinates to nearby PDF
76
+ text rather than estimating from a full-page image. Read
77
+ [`references/overlay-technique.md`](references/overlay-technique.md) before a
78
+ non-trivial overlay.
79
+
80
+ ## Fill a PDF
81
+
82
+ Create a JSON configuration beside the working copy:
83
+
84
+ ```json
85
+ {
86
+ "src": "blank-form.pdf",
87
+ "out": "completed-form.pdf",
88
+ "mode": "overlay",
89
+ "font_size": 9,
90
+ "overlay": [
91
+ {"page": 0, "x": 220, "y": 190, "text": "Example Person"},
92
+ {"page": 0, "x": 62, "y": 332, "check": true},
93
+ {
94
+ "page": 1,
95
+ "x": 70,
96
+ "y": 410,
97
+ "text": "XX00EXAMPLE0000000000000000",
98
+ "spread": {"step": 17.5}
99
+ }
100
+ ]
101
+ }
102
+ ```
103
+
104
+ Relative paths are resolved from the configuration file. Run:
105
+
106
+ ```bash
107
+ python3 <skill-dir>/scripts/fill_pdf.py form-data.json
108
+ ```
109
+
110
+ The script creates the output and a `<output>_preview/` directory. Use
111
+ `--overwrite` only for an intentional revision of that output.
112
+
113
+ Supported overlay items:
114
+
115
+ - `text`, `x`, `y`, optional `size`;
116
+ - `check: true`, `x`, `y`, optional `size`;
117
+ - `text` plus `spread.step` and optional `spread.skip` for character boxes;
118
+ - `image` plus `rect: [x0, y0, x1, y1]` for an explicitly authorised local
119
+ image;
120
+ - optional top-level `font_file` for a user-provided TTF/OTF when the built-in
121
+ PDF font cannot represent the required text.
122
+
123
+ For AcroForm mode, provide `"acroform": {"field-name": "value"}` and booleans
124
+ for checkboxes. Unknown requested field names fail closed instead of silently
125
+ producing an incomplete form.
126
+
127
+ ## Fill a DOCX template
128
+
129
+ Inspect its paragraphs and tables:
130
+
131
+ ```bash
132
+ python3 <skill-dir>/scripts/dump_docx.py template.docx
133
+ ```
134
+
135
+ Use explicit `{{placeholder}}` tokens whenever possible, then run:
136
+
137
+ ```bash
138
+ python3 <skill-dir>/scripts/fill_docx.py template.docx completed.docx \
139
+ --data form-data.json
140
+ ```
141
+
142
+ Plain-text key replacement is available only with `--literal-keys` because it
143
+ can otherwise replace unintended text. Unused data keys fail closed unless the
144
+ user deliberately chooses `--allow-unused`.
145
+
146
+ ## Verify before handoff
147
+
148
+ 1. Review every generated preview page at full size.
149
+ 2. Zoom critical regions such as checkboxes, character boxes and signatures.
150
+ 3. Compare every populated value with the user's authoritative source.
151
+ 4. Confirm that the original file is unchanged and the output has a distinct
152
+ name.
153
+ 5. Show the final preview or document to the user before calling it ready.
154
+ 6. State clearly which fields remain empty, uncertain, unsigned or unsubmitted.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Fill Forms"
3
+ short_description: "Safely fill and verify local PDF and DOCX forms"
4
+ default_prompt: "Use fill-forms to inspect and safely complete this local form."
@@ -0,0 +1,99 @@
1
+ # Coordinate Overlay Technique
2
+
3
+ Read this reference before filling a non-trivial flat PDF. For ordinary
4
+ AcroForm documents, named fields are safer and this guide is unnecessary.
5
+
6
+ ## Coordinate system
7
+
8
+ PyMuPDF uses PDF points (1 point is 1/72 inch), with the origin at the top-left.
9
+ The x axis increases to the right and the y axis increases downward.
10
+ `insert_text((x, y), ...)` places the text baseline at `(x, y)`.
11
+
12
+ The PNG grids produced by `inspect_pdf.py` use the same coordinates. No manual
13
+ pixel-to-point conversion is needed.
14
+
15
+ ## Anchor to existing text
16
+
17
+ For a checkbox, signature line or narrow field, locate a nearby printed label
18
+ and derive an offset from its exact PDF word bounds:
19
+
20
+ ```python
21
+ import fitz
22
+
23
+ doc = fitz.open("form.pdf")
24
+ for word in doc[0].get_text("words"):
25
+ if word[4] == "Consent":
26
+ print(word[:5])
27
+ ```
28
+
29
+ A word tuple starts with `(x0, y0, x1, y1, text)`. Position the field relative
30
+ to those bounds and verify the result in a high-resolution clipped preview.
31
+ Do not treat an estimated offset as final evidence.
32
+
33
+ ## Text baselines and boxes
34
+
35
+ Use the line on which the text should sit as the initial y coordinate. If the
36
+ text falls below the line, decrease y; if it floats too high, increase y.
37
+ Adjust in small steps.
38
+
39
+ For a checkbox, place the X near the horizontal centre and use the lower part
40
+ of the box as the initial baseline. Verify at high zoom.
41
+
42
+ ## Character-by-character fields
43
+
44
+ Use `spread.step` for account numbers, dates or identifiers printed as separate
45
+ boxes:
46
+
47
+ ```json
48
+ {
49
+ "page": 0,
50
+ "x": 70,
51
+ "y": 410,
52
+ "text": "XX00EXAMPLE0000000000000000",
53
+ "spread": {"step": 17.5}
54
+ }
55
+ ```
56
+
57
+ Measure `step` from two adjacent boxes on the coordinate grid. `spread.skip`
58
+ accepts zero-based character indexes that should not be drawn while retaining
59
+ their spacing.
60
+
61
+ ## Long text and fonts
62
+
63
+ The overlay script does not wrap text automatically. Reduce the per-item
64
+ `size`, split the content into explicitly positioned lines, or leave the field
65
+ for manual completion rather than allowing text to overlap legal wording.
66
+
67
+ The built-in PDF font is suitable for common Latin text. For another script or
68
+ missing glyph, use a locally available, appropriately licensed TTF/OTF through
69
+ the top-level `font_file` configuration. Do not bundle private or unlicensed
70
+ fonts.
71
+
72
+ ## Signature images
73
+
74
+ Insert a signature only after explicit authorisation from the signer for the
75
+ specific document. Prepare the image locally with `prepare_signature.py` and
76
+ keep the original and prepared image outside source control.
77
+
78
+ Use a transparent PNG and a rectangle whose proportions are close to the
79
+ signature. Avoid stretching it unnaturally. Place it so the visible stroke
80
+ rests on the intended line, then inspect the result on a pure white background
81
+ and in a clipped high-resolution preview.
82
+
83
+ The fill script downsizes very large inserted images to the resolution needed
84
+ for the target rectangle. This avoids embedding a multi-megapixel source into
85
+ a small signature area.
86
+
87
+ ## Focused verification
88
+
89
+ Render a critical region rather than relying only on a full-page preview:
90
+
91
+ ```python
92
+ clip = fitz.Rect(x0, y0, x1, y1)
93
+ pix = doc[page_number].get_pixmap(clip=clip, dpi=240)
94
+ pix.save("verification-region.png")
95
+ ```
96
+
97
+ Check every populated field, not only representative examples. Treat the
98
+ completed file as a draft until the user has reviewed it. Filling a form does
99
+ not authorise signing, sending or submission.
@@ -0,0 +1,4 @@
1
+ PyMuPDF>=1.24,<2
2
+ Pillow>=10,<13
3
+ numpy>=1.26,<3
4
+ python-docx>=1.1,<2
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """Print paragraphs, tables, headers and footers from a DOCX template."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from pathlib import Path
8
+
9
+
10
+ def parse_args() -> argparse.Namespace:
11
+ parser = argparse.ArgumentParser(
12
+ description="Inspect text and table structure in a local DOCX form."
13
+ )
14
+ parser.add_argument("docx", type=Path)
15
+ return parser.parse_args()
16
+
17
+
18
+ def load_docx():
19
+ try:
20
+ import docx
21
+ except ImportError as exc:
22
+ raise SystemExit(
23
+ "Missing dependency: python-docx. With user consent, install the "
24
+ "packages listed in the skill's requirements.txt."
25
+ ) from exc
26
+ return docx
27
+
28
+
29
+ def print_paragraphs(paragraphs, heading: str) -> None:
30
+ print(f"== {heading} ==")
31
+ for index, paragraph in enumerate(paragraphs):
32
+ text = paragraph.text.strip()
33
+ if text:
34
+ print(f"[{index}] {text}")
35
+
36
+
37
+ def print_tables(tables, heading: str) -> None:
38
+ for table_index, table in enumerate(tables):
39
+ print(
40
+ f"\n== {heading} TABLE {table_index} "
41
+ f"({len(table.rows)}x{len(table.columns)}) =="
42
+ )
43
+ for row_index, row in enumerate(table.rows):
44
+ cells = [cell.text.strip() for cell in row.cells]
45
+ print(f" row {row_index}: {cells}")
46
+
47
+
48
+ def main() -> None:
49
+ args = parse_args()
50
+ if not args.docx.is_file():
51
+ raise SystemExit(f"DOCX not found or not a regular file: {args.docx}")
52
+
53
+ docx = load_docx()
54
+ try:
55
+ document = docx.Document(args.docx)
56
+ except Exception as exc:
57
+ raise SystemExit(f"Could not open DOCX: {exc}") from exc
58
+
59
+ print_paragraphs(document.paragraphs, "BODY PARAGRAPHS")
60
+ print_tables(document.tables, "BODY")
61
+
62
+ for section_index, section in enumerate(document.sections):
63
+ print_paragraphs(section.header.paragraphs, f"HEADER {section_index}")
64
+ print_tables(section.header.tables, f"HEADER {section_index}")
65
+ print_paragraphs(section.footer.paragraphs, f"FOOTER {section_index}")
66
+ print_tables(section.footer.tables, f"FOOTER {section_index}")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()