@mmmbuto/nexuscrew 0.8.32 → 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 +49 -0
- package/MCP_COMPANIONS.md +67 -0
- package/README.md +42 -5
- package/frontend/dist/assets/{index-Dw90VJRY.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/fleet/launch.js +16 -2
- package/lib/mcp/server.js +13 -0
- package/lib/nodes/tunnel.js +6 -1
- package/mcp-companions.json +103 -0
- package/package.json +3 -1
- package/skills/crew/SKILL.md +89 -0
- package/skills/crew/agents/openai.yaml +4 -0
- 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
- package/skills/mail-assistant/SKILL.md +71 -0
- package/skills/mail-assistant/agents/openai.yaml +4 -0
- package/skills/memory/SKILL.md +81 -0
- package/skills/memory/agents/openai.yaml +4 -0
- package/skills/nexuscrew-agent/SKILL.md +20 -0
- package/skills/vl-msa/SKILL.md +68 -0
- package/skills/vl-msa/agents/openai.yaml +4 -0
|
@@ -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,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,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()
|
|
@@ -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()
|