Workflow-App/Workflow/scripts/subst.py

847 lines
32 KiB
Python
Raw Normal View History

"""
subst.py — Phase 6 letter substitution engine.
Standalone Python script invoked by lib/letter.ts via:
python3 subst.py <template.docx> <out.docx> <context.json> <workdir>
What it does:
1. Reads context.json (company, attendees, signers, resolution info)
2. Substitutes every [PLACEHOLDER] token in the docx
3. Embeds signature PNGs above the signature lines
4. Saves the result as out.docx (caller then runs soffice → PDF)
The signatures themselves come from PocketBase file URLs in the signer
records — we download them to <workdir>/<signer_id>.png BEFORE invoking
this script (the Node side handles PB auth).
"""
import sys
import os
import re
import json
from copy import deepcopy
from docx import Document
from docx.enum.text import WD_TAB_ALIGNMENT
from docx.shared import Inches, Pt
from docx.oxml.ns import qn
from docx.text.paragraph import Paragraph
# ─── Helpers ─────────────────────────────────────────────────────────────
def _label(ctx):
r = ctx.get("resigning_director_label")
n = ctx.get("newly_appointed_director_label")
# Treat "None this meeting" as the empty-sentinel that letter-runner
# emits when there are no directors of that kind on this resolution.
has_resignations = r and r != "None this meeting"
has_appointments = n and n != "None this meeting"
if has_resignations and has_appointments:
return "resignation and appointment"
if has_resignations:
return "resignation"
if has_appointments:
return "appointment"
return "resignation and/or appointment"
def _resigning_label(ctx):
n = ctx.get("resigning_director_label")
if not n or n == "None this meeting":
# Letter-runner emitted the empty-sentinel. _neutralise_empty_sections
# will rewrite paragraphs that need this token.
return "None this meeting"
return n
def _newly_appointed_label(ctx):
n = ctx.get("newly_appointed_director_label")
if not n or n == "None this meeting":
return "None this meeting"
return n
def _role_key(role):
"""Fold director_role to a canonical key.
The app writes LOWERCASE short values ("resigned" / "appointed" /
"existing" / "external") — see app/api/resolutions/create/route.ts — while
older fixtures used "RESIGNED DIRECTOR" / "NEW DIRECTOR". Matching the
literal uppercase string silently found nothing, so the appointment date
fell back to the meeting date. Keep both spellings working.
"""
key = (role or "").lower()
key = "".join(ch for ch in key if ch.isalpha())
if key in ("resigned", "resigneddirector", "resignation"):
return "resigned"
if key in ("appointed", "appointment", "newdirector", "newappointment"):
return "appointed"
if key in ("existing", "existingdirector"):
return "existing"
if key == "external":
return "external"
return key
def _new_appointment_date(ctx):
apts = [a for a in ctx["attendees"] if _role_key(a.get("director_role")) == "appointed"]
if apts and apts[0].get("effective_date"):
return apts[0]["effective_date"]
return ctx["meeting_date"]
ROLE_LEGAL_LABEL = {
"resigned": "RESIGNED DIRECTOR",
"appointed": "NEW DIRECTOR",
"existing": "DIRECTOR",
"external": "DIRECTOR",
}
def _attendee_roles(ctx):
"""Roles for [POSITION/DIRECTOR/NEW DIRECTOR/RESIGNED DIRECTOR].
Rendered in the letter's legal wording, not the raw DB value — the DB stores
"resigned"/"existing" but the template's position column expects
"RESIGNED DIRECTOR"/"DIRECTOR".
"""
labels = []
for a in ctx["attendees"]:
if a.get("director_role"):
key = _role_key(a["director_role"])
label = ROLE_LEGAL_LABEL.get(key, a["director_role"].upper())
if label not in labels:
labels.append(label)
return labels or ["DIRECTOR"]
def _attendee_emails(ctx):
emails = [a["email"] for a in ctx["attendees"] if a.get("email")]
return ", ".join(emails) if emails else "[EMAIL ADDRESS OF THE ADDITIONAL ATTENDEES]"
def _build_placeholders(ctx):
"""Ordered list of (token, value). Order matters (longest first)."""
return [
("[COMPANY NAME]", ctx["company"]["name"]),
("[ENTERPRISE NO]", ctx["company"]["enterprise_no"]),
(
"[ADDRESS LINE 1, ADDRESS LINE 2, TOWN /CITY, PROVINCE, POSTAL CODE]",
ctx["company"]["address"],
),
("[ADDRESS_CITY]", ctx["company"]["address_city"]),
("[DATE EMAIL WAS SENT]", ctx["meeting_date"]),
("[DATE LETTER WAS SENT]", ctx["meeting_date"]),
("[date the letter was sent]", ctx["meeting_date"]),
("[as per insert]", ctx["meeting_date"]),
("[date as per insert]", ctx["meeting_date"]),
("[Effective Date of Appointment]", _new_appointment_date(ctx)),
("[resignation and/or appointment]", _label(ctx)),
("[director that resigned name , surname and id]", _resigning_label(ctx)),
("[director name surname and id]", _resigning_label(ctx)),
("[resigning director name, surname and id]", _resigning_label(ctx)),
("[newly appointed director name, surname and id]", _newly_appointed_label(ctx)),
("[newly appointed director name surname and id]", _newly_appointed_label(ctx)),
("[POSITION/DIRECTOR/NEW DIRECTOR/RESIGNED DIRECTOR]", "/".join(sorted(set(_attendee_roles(ctx))))),
("[EMAIL ADDRESS OF THE ADDITIONAL ATTENDEES]", _attendee_emails(ctx)),
]
def _replace_token_across_runs(runs, token, value):
"""Replace `token` even when it spans several runs.
Formatting must survive the substitution. The replacement text is written
into the run where the token STARTS, so that run's bold/italic applies.
This matters a lot in the master template: every Ordinary Resolution opens
with a bold label run ("Ordinary Resolution 1") followed by non-bold body
runs. The old implementation collapsed the whole paragraph into run 0,
which meant that substituting ANY placeholder turned the entire resolution
bold. Here, "Ordinary Resolution 1" stays bold and the RESOLVED text does
not.
In §2 the director-name token is itself bold in the template, and that
bold is preserved because the token starts in a bold run.
"""
joined = "".join(r.text for r in runs)
guard = 0
while token in joined and guard < 50:
guard += 1
idx = joined.index(token)
# Map the character span [idx, idx+len(token)) onto run indices.
pos = 0
start_run = end_run = None
start_offset = end_offset = 0
for i, r in enumerate(runs):
length = len(r.text)
if start_run is None and pos + length > idx:
start_run = i
start_offset = idx - pos
if pos + length >= idx + len(token):
end_run = i
end_offset = idx + len(token) - pos
break
pos += length
if start_run is None or end_run is None:
return
head = runs[start_run].text[:start_offset]
tail = runs[end_run].text[end_offset:]
runs[start_run].text = head + value + tail
for i in range(start_run + 1, end_run + 1):
runs[i].text = ""
joined = "".join(r.text for r in runs)
def _substitute_runs(paragraph, placeholders):
"""Find/replace every token in a paragraph, preserving run formatting."""
runs = paragraph.runs
if not runs:
return
for token, value in placeholders:
if token in "".join(r.text for r in runs):
_replace_token_across_runs(runs, token, value)
def _walk(doc, placeholders):
for p in doc.paragraphs:
_substitute_runs(p, placeholders)
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for p in cell.paragraphs:
_substitute_runs(p, placeholders)
def _fix_section3_director_token(doc, ctx):
"""The master docx uses `[director name surname and id]` in BOTH Section 2
(resignations) AND Section 3 (appointments). Without context-aware
substitution the same token maps to the resigning director everywhere
and Section 3 names the wrong person.
This pre-pass targets the FIRST `[director name surname and id]`
occurrence AFTER the `3. Appointment of Director(s)` heading and
substitutes it with the NEWLY APPOINTED label. After this pass the
global walk still substitutes remaining occurrences (i.e. Section 2)
with the resigning label, as intended by _build_placeholders().
When the resolution has no newly-appointed director, the sentinel
"None this meeting" flows through here. We skip the section-3
substitution entirely — _neutralise_empty_sections will rewrite
Section 3 prose to "None this meeting." instead of leaving the
literal "None this meeting" name in place.
"""
newly_appointed = _newly_appointed_label(ctx)
if newly_appointed.startswith("[") or newly_appointed == "None this meeting":
# Nothing real to substitute; the empty-section pre-pass handles it.
return
# Find paragraph index where Section 3 starts.
section3_idx = None
for i, p in enumerate(doc.paragraphs):
if "3. Appointment of Director" in p.text:
section3_idx = i
break
if section3_idx is None:
return
# Walk paragraphs from Section 3 onward and replace the FIRST occurrence
# of the target token.
target = "[director name surname and id]"
for i in range(section3_idx, len(doc.paragraphs)):
p = doc.paragraphs[i]
if target not in p.text:
continue
_substitute_runs(p, [(target, newly_appointed)])
return
APPOINT_HEADINGS = ("3. Appointment of Director", "3 . Appointment of Director")
ORDINARY_HEADINGS = ("4. Ordinary Resolutions", "4 . Ordinary Resolutions")
CLOSE_HEADINGS = ("5. Close", "5 . Close")
APPOINT_OR_HEADINGS = ("Ordinary Resolution 3", "Ordinary Resolution 4")
def _set_paragraph_text(p, text):
"""Replace a paragraph's text, keeping the first run's formatting."""
if p.runs:
p.runs[0].text = text
for r in p.runs[1:]:
r.text = ""
else:
p.add_run(text)
def _delete_paragraphs(paras):
"""Remove <w:p> elements from the document body."""
for p in paras:
el = p._element
parent = el.getparent()
if parent is not None:
parent.remove(el)
def _heading_index(paras, prefixes, start=0):
"""Index of the first paragraph whose text starts with any prefix."""
for i in range(start, len(paras)):
full = "".join(r.text for r in paras[i].runs)
if any(full.startswith(h) for h in prefixes):
return i
return None
def _drop_appointment_sections(doc):
"""No new directors this meeting → remove §3, OR3 and OR4, then renumber.
Leaves the minutes reading 1, 2, 3 (Ordinary Resolutions), 4 (Close) with
no gap and no "(None this meeting.)" stub in a formal document. §4 → §3 and
§5 → §4.
Called AFTER the _neutralise_empty_sections collapse pass, so it works on a
document where §3/OR3/OR4 have already been blanked to the empty notice;
either way the paragraphs are removed here.
"""
# 1. §3 heading + its body paragraph(s), up to §4 (or §5 / end of doc).
paras = list(doc.paragraphs)
start = _heading_index(paras, APPOINT_HEADINGS)
if start is not None:
end = _heading_index(paras, ORDINARY_HEADINGS + CLOSE_HEADINGS, start=start + 1)
if end is None:
end = len(paras)
_delete_paragraphs(paras[start:end])
# 2. OR3 / OR4 paragraphs anywhere in the body.
paras = list(doc.paragraphs)
ors = []
for p in paras:
full = "".join(r.text for r in p.runs)
if any(full.startswith(h) for h in APPOINT_OR_HEADINGS):
ors.append(p)
_delete_paragraphs(ors)
# 3. Renumber §4 → §3 and §5 → §4.
paras = list(doc.paragraphs)
ordinary_idx = _heading_index(paras, ORDINARY_HEADINGS)
if ordinary_idx is not None:
_set_paragraph_text(paras[ordinary_idx], "3. Ordinary Resolutions:")
close_idx = _heading_index(paras, CLOSE_HEADINGS)
if close_idx is not None:
_set_paragraph_text(paras[close_idx], "4. Close:")
def _neutralise_empty_sections(doc, ctx):
"""When a resolution has no resignations and/or no appointments, the
imperative-prose paragraphs in §2, §3 and OR1..OR4 would read broken
("...resignation of None this meeting from their positions...").
This pre-pass finds the affected paragraphs BEFORE the global _walk
runs and replaces them with a one-line "(None this meeting.)" notice.
Strategy:
- Identify which paragraphs belong to the resignation sections (§2,
OR1, OR2) and which belong to appointment sections (§3, OR3, OR4)
based on PARAGRAPH POSITION in the doc — not on token presence,
because tokens in those paragraphs would have been replaced by
real director names in some cases (e.g. the §3 paragraph that
contains [director name surname and id] still mentions [Effective
Date of Appointment] which makes the section detectable).
- For each resignation/appointment section, replace the WHOLE
paragraph (heading + body) with "(None this meeting.)" when the
corresponding director list is empty.
Docx structure (master docx):
- paragraphs[0..7]: header / addressee / intro / §2 (heading) /
§2 (resignation prose)
- paragraphs[8..]: §3, §4 ORs, §5
Rather than relying on brittle position indices we use a HEADING
DETECTION approach: walk paragraphs and find the "2. Resignations of
Director" heading, then the "3. Appointment of Director" heading, then
"Ordinary Resolution N" markers. Each section's body paragraphs are
the ones between its heading and the next heading.
"""
resigning = _resigning_label(ctx) # "None this meeting" if empty
appointed = _newly_appointed_label(ctx)
if resigning != "None this meeting" and appointed != "None this meeting":
return # Both populated — nothing to neutralise.
empty_placeholder = "(None this meeting.)"
# Headings that identify each section we care about. We rewrite the
# whole paragraph (including its heading) so the visual structure
# of the document collapses cleanly.
resignation_headings = ["2 . Resignations of Director", "2. Resignations of Director"]
appointment_headings = ["3 . Appointment of Director", "3. Appointment of Director"]
# ORs to rewrite by ordinal. The master docx has OR1, OR2 (resignation),
# OR3, OR4 (appointment). We rewrite each OR pair when its
# corresponding list is empty.
resignation_or_headings = ["Ordinary Resolution 1", "Ordinary Resolution 2"]
appointment_or_headings = ["Ordinary Resolution 3", "Ordinary Resolution 4"]
# When NO new directors were appointed we don't want a dangling
# "3. Appointment of Director(s): (None this meeting.)" heading in a
# formal minutes document. Drop §3 + OR3 + OR4 and renumber so the letter
# reads 1, 2, 3 (Ordinary Resolutions), 4 (Close) with no gap.
#
# This MUST run BEFORE the collapse pass below: the collapse rewrites each
# section's heading to "(None this meeting.)", after which the heading is
# no longer findable and the paragraphs can never be removed.
if appointed == "None this meeting":
_drop_appointment_sections(doc)
paragraphs = doc.paragraphs
# Build a list of (index, kind) where kind is "resign" / "appoint" /
# "ordinary" / "or_resign" / "or_appoint". §4 "Ordinary Resolutions" is ALSO
# a boundary — without it the §2 range ran all the way to OR1 and wiped the
# ordinary-resolutions heading plus its "proposed, seconded and passed"
# intro line, so the letter silently lost that header. It may read "3." by
# now if §3 was dropped and the document renumbered above.
ordinary_heading = [
"4 . Ordinary Resolutions", "4. Ordinary Resolutions",
"3 . Ordinary Resolutions", "3. Ordinary Resolutions",
]
close_headings = ["5. Close", "5 . Close", "4. Close", "4 . Close"]
section_starts = []
close_idx = len(paragraphs)
for i, p in enumerate(paragraphs):
full = "".join(r.text for r in p.runs)
if any(full.startswith(h) for h in resignation_headings):
section_starts.append((i, "resign"))
elif any(full.startswith(h) for h in appointment_headings):
section_starts.append((i, "appoint"))
elif any(full.startswith(h) for h in ordinary_heading):
section_starts.append((i, "ordinary"))
elif any(full.startswith(h) for h in resignation_or_headings):
section_starts.append((i, "or_resign"))
elif any(full.startswith(h) for h in appointment_or_headings):
section_starts.append((i, "or_appoint"))
elif any(full.startswith(h) for h in close_headings):
close_idx = i
break
# §4 is a boundary but is never itself neutralised — it always keeps OR1
# and/or OR3 content.
for idx, (start, kind) in enumerate(section_starts):
if kind == "ordinary":
continue
if kind in ("resign", "or_resign") and resigning != "None this meeting":
continue
if kind in ("appoint", "or_appoint") and appointed != "None this meeting":
continue
# End of this section = next section_start index in section_starts,
# or close_idx (start of §5), whichever comes first.
end = len(paragraphs)
if idx + 1 < len(section_starts):
end = min(end, section_starts[idx + 1][0])
end = min(end, close_idx)
# Rewrite the first paragraph in the range to "(None this meeting.)"
# and clear the rest.
if start < len(paragraphs):
target_p = paragraphs[start]
if target_p.runs:
target_p.runs[0].text = empty_placeholder
for r in target_p.runs[1:]:
r.text = ""
for j in range(start + 1, end):
if j >= len(paragraphs):
break
p = paragraphs[j]
if p.runs:
for r in p.runs:
r.text = ""
def _title_case(text):
"""'SUNE PIETERS' -> 'Sune Pieters'.
Director names are stored SHOUTED in PocketBase. Shouting every attendee in
a formal minutes document reads badly, so the attendee block is rendered in
normal title case. Hyphenated and apostrophied names keep their separators.
"""
words = (text or "").split()
return " ".join(w[:1].upper() + w[1:].lower() for w in words)
# Role wording for the attendee block. Kept separate from ROLE_LEGAL_LABEL
# (ALL CAPS) because that one feeds the [POSITION/...] placeholder in body
# prose, whereas the attendee list is a readable table.
ROLE_ATTENDEE_LABEL = {
"resigned": "Director Resigned",
"appointed": "Director Appointed",
"existing": "Director",
"external": "Guest",
}
def _attendee_rows(attendees):
"""(name, role) per attendee, with names title-cased and no email column."""
rows = []
for a in attendees:
name = _title_case(f"{a.get('name') or ''} {a.get('surname') or ''}".strip())
if not name:
continue
role = ROLE_ATTENDEE_LABEL.get(
_role_key(a.get("director_role")),
_title_case(a.get("director_role")) or "Director",
)
rows.append((name, role))
return rows
def _first_paragraph_starting(doc, prefix):
"""The first body paragraph whose text starts with `prefix` (case-insensitive)."""
low = prefix.lower()
for p in doc.paragraphs:
if "".join(r.text for r in p.runs).strip().lower().startswith(low):
return p
return None
def _rewrite_paragraph(p, text):
"""Collapse a single-line paragraph into `text`, keeping the first run's look."""
if not p.runs:
p.add_run(text)
return
p.runs[0].text = text
for r in p.runs[1:]:
r.text = ""
def _format_held_at(doc):
"""Tidy the "HELD AT:" line.
Two problems in the master template:
1. It reads "HELD AT: COMPANY ADDRESS <address>". "COMPANY ADDRESS" is a
leftover label from the template author — it is not part of any address
and the operator never wants it in the finished letter. Strip it.
2. The address is stored inconsistently cased ("138, RIETVLEI, RUSTENBURG,
North West, 0299"). Force the whole line to capitals so it reads as a
single heading, matching "ON THE:" beneath it.
"""
p = _first_paragraph_starting(doc, "HELD AT")
if p is None:
return
joined = "".join(r.text for r in p.runs)
joined = re.sub(r"\s*COMPANY\s+ADDRESS\s*", " ", joined, flags=re.IGNORECASE)
# Upper-case only what follows the "HELD AT:" label, so the label keeps its
# own casing if the template ever changes it.
m = re.match(r"^(HELD\s+AT\s*:)(.*)$", joined, flags=re.IGNORECASE | re.DOTALL)
if m:
joined = m.group(1).upper() + " " + m.group(2).strip().upper()
else:
joined = joined.upper()
_rewrite_paragraph(p, joined)
def _fix_signed_at_date(doc):
"""Collapse the duplicated date in the sign-off line.
The template reads:
Signed at [CITY] on this [DATE] day of [DATE]
which renders the same date twice:
"Signed at RUSTENBURG on this 9 October 2026 day of 9 October 2026"
Only collapsed when both halves are actually the same date, so a template
that genuinely wants two different dates is left alone.
"""
p = _first_paragraph_starting(doc, "Signed at")
if p is None:
return
joined = "".join(r.text for r in p.runs)
m = re.match(
r"^(.*?)\s*on\s*this\s+(.+?)\s+day\s+of\s+(.+?)\s*$",
joined.strip(),
flags=re.IGNORECASE | re.DOTALL,
)
if not m:
return
if m.group(2).strip().lower() != m.group(3).strip().lower():
return # two different dates — leave the template's wording alone
_rewrite_paragraph(p, f"{m.group(1).strip()} on {m.group(2).strip()}")
def _fill_first_attendee_block(doc, attendees):
"""Replace paragraph index 8 with one neatly aligned row per attendee.
Layout: "Sune Pieters<tab>Director Resigned"
The role column is aligned with a REAL left tab stop, not with space
padding. The letterhead font is proportional, so padding to a character
count does NOT line the roles up — a 12-character name and an
18-character name occupy very different widths. A tab stop is what
Word actually aligns on.
"""
if not attendees or len(doc.paragraphs) <= 8:
return
target = doc.paragraphs[8]
rows = _attendee_rows(attendees)
if not rows:
return
# Where the role column starts. Measured from the left margin, so it is
# independent of how long any individual name is.
ROLE_COL = Inches(2.3)
orig_size = target.runs[0].font.size if target.runs else None
orig_name = target.runs[0].font.name if target.runs else None
orig_color = None
if target.runs and target.runs[0].font.color:
orig_color = target.runs[0].font.color.rgb
# Build new <w:p> elements in REVERSE order, each added AFTER target.
# addnext() appends, so iterating in reverse gives us correct final order.
inserted = []
for _a in reversed(rows):
np_elem = deepcopy(target._element)
for r in np_elem.findall(qn("w:r")):
t = r.find(qn("w:t"))
if t is not None:
t.text = ""
target._element.addnext(np_elem)
inserted.append(np_elem)
inserted.reverse() # now in attendees[] order
for i, (name, role) in enumerate(rows):
para = Paragraph(inserted[i], doc.part)
para.text = ""
# Replace whatever tab stops the template paragraph carried with a
# single explicit left stop, so every row's role lines up.
para.paragraph_format.tab_stops.clear_all()
para.paragraph_format.tab_stops.add_tab_stop(
ROLE_COL, WD_TAB_ALIGNMENT.LEFT
)
run = para.add_run(f"{name}\t{role}")
if orig_size:
run.font.size = orig_size
if orig_name:
run.font.name = orig_name
if orig_color:
run.font.color.rgb = orig_color
# REMOVE the original empty paragraph from the body (so we don't get
# a stray empty bullet in the rendered output)
target._element.getparent().remove(target._element)
def _person_name(person):
"""'Name Surname', title-cased. The ID is NOT appended here — it gets its
own line under the name in the signature block."""
return _title_case(f"{person.get('name') or ''} {person.get('surname') or ''}".strip())
def _labelled(value, label):
"""'ID - 9810230091087', or '' when there's no value."""
v = (value or "").strip()
return f"{label} - {v}" if v else ""
def _signature_people(ctx):
"""(name, id_line, cell_line, email_line) for each signature slot.
The template has a fixed number of `ATTENDEE [NAME AND SURNAME ID]` slots
but the meeting may have a different number of attendees. Two cases:
* someone has already signed → use the signed signers (the ones whose
signature image is being embedded above the line).
* nobody has signed yet → use the attendees who MUST sign. This is
the normal state for a letter being emailed out for approval.
Each person renders as:
Name Surname
ID - 9810230091087
Cell - 0827985409
Email - sj@dyfins.com
A missing value simply drops its line rather than leaving a blank.
"""
signed = [s for s in ctx.get("signers", []) if s.get("status") == "signed"]
if signed:
pool = [s.get("attendee") or {} for s in signed]
else:
attendees = ctx.get("attendees", [])
must_sign = [a for a in attendees if a.get("must_sign", True)]
pool = must_sign or attendees
out = []
seen = set()
for a in pool:
name = _person_name(a)
if not name or name in seen:
continue
seen.add(name)
out.append(
(
name,
_labelled(a.get("director_id_number"), "ID"),
_labelled(a.get("telephone"), "Cell"),
_labelled(a.get("email"), "Email"),
)
)
return out
def _insert_detail_line(after_p, doc, text):
"""Add a smaller sub-line directly beneath `after_p`.
Cloned from the source paragraph so it inherits the font and colour, then
scaled to 85% so it reads as a detail under the name rather than a heading.
Returns the new <w:p> element.
"""
new_p = deepcopy(after_p._element)
for r in new_p.findall(qn("w:r")):
t = r.find(qn("w:t"))
if t is not None:
t.text = ""
after_p._element.addnext(new_p)
para = Paragraph(new_p, doc.part)
para.text = ""
run = para.add_run(text)
if after_p.runs and after_p.runs[0].font.size:
run.font.size = Pt(int(after_p.runs[0].font.size.pt * 0.85))
if after_p.runs and after_p.runs[0].font.name:
run.font.name = after_p.runs[0].font.name
return new_p
def _fill_attendee_lines(doc, ctx):
"""Fill the template's `ATTENDEE [NAME AND SURNAME ID]` slots.
Each slot becomes:
Name Surname
ID - 9810230091087
Cell - 0827985409
Email - sj@dyfins.com
"""
people = _signature_people(ctx)
slot_idx = 0
for p in list(doc.paragraphs):
if "ATTENDEE [NAME AND SURNAME ID]" not in p.text:
continue
name, id_line, cell_line, email_line = (
people[slot_idx] if slot_idx < len(people) else ("—", "", "", "")
)
# Just the name — the template's "ATTENDEE" label is dropped so the
# line reads "Sune Pieters", not "ATTENDEE Sune Pieters".
replaced = False
for run in p.runs:
if "ATTENDEE [NAME AND SURNAME ID]" in run.text:
run.text = run.text.replace("ATTENDEE [NAME AND SURNAME ID]", name)
replaced = True
if not replaced:
joined = "".join(r.text for r in p.runs).replace(
"ATTENDEE [NAME AND SURNAME ID]", name
)
p.runs[0].text = joined
for r in p.runs[1:]:
r.text = ""
# Chain each detail line off the previous one so the order stays
# name → ID → Cell → Email. addnext() inserts directly after the given
# element, so inserting them all relative to `p` would reverse them.
cursor = p
for detail in (id_line, cell_line, email_line):
if not detail:
continue
cursor = Paragraph(
_insert_detail_line(cursor, doc, detail), doc.part
)
slot_idx += 1
def _embed_signatures(doc, signers, workdir):
"""Insert signature images ABOVE the underscore lines.
Strategy: walk the body element directly and find <w:p> with text that is
ONLY underscores, then prepend a new <w:p> holding the signature image.
"""
from docx.oxml.ns import qn
from docx.text.paragraph import Paragraph
from copy import deepcopy
from docx.shared import Inches
body = doc.element.body
signed = [s for s in signers if s["status"] == "signed" and s.get("signature_path")]
slot_idx = 0
# Iterate over a snapshot of <w:p> children to avoid mutation issues
paragraphs = list(body.findall(qn("w:p")))
for p_elem in paragraphs:
text_runs = p_elem.findall(qn("w:r"))
text = "".join(
(t.text or "") for r in text_runs for t in r.findall(qn("w:t"))
).strip()
if not text or not all(c == "_" for c in text):
continue
if slot_idx >= len(signed):
break
sig_path = signed[slot_idx]["signature_path"]
slot_idx += 1
if not os.path.exists(sig_path):
continue
# Create a new <w:p> cloned from this one, insert before
new_p = deepcopy(p_elem)
for r in new_p.findall(qn("w:r")):
t = r.find(qn("w:t"))
if t is not None:
t.text = ""
p_elem.addprevious(new_p)
# Wrap as Paragraph and add the image
np = Paragraph(new_p, doc.part)
np.text = ""
run = np.add_run()
run.add_picture(sig_path, width=Inches(2.0))
# ─── Main ────────────────────────────────────────────────────────────────
def main():
if len(sys.argv) != 5:
print(
"usage: subst.py <template.docx> <out.docx> <context.json> <workdir>",
file=sys.stderr,
)
sys.exit(2)
template_path = sys.argv[1]
output_path = sys.argv[2]
context_path = sys.argv[3]
workdir = sys.argv[4]
with open(context_path) as f:
ctx = json.load(f)
doc = Document(template_path)
placeholders = _build_placeholders(ctx)
# Pre-pass: Section 3 uses the same [director name surname and id]
# token as Section 2 but must map to the NEWLY APPOINTED director.
_fix_section3_director_token(doc, ctx)
# Pre-pass: collapse §2 / §3 / OR1..OR4 paragraphs to "None this
# meeting." when the resolution has no resignations and/or no
# appointments on this meeting.
_neutralise_empty_sections(doc, ctx)
_walk(doc, placeholders)
_fill_first_attendee_block(doc, ctx["attendees"])
_fill_attendee_lines(doc, ctx)
_embed_signatures(doc, ctx["signers"], workdir)
# Cosmetic clean-up of two template lines.
_format_held_at(doc)
_fix_signed_at_date(doc)
doc.save(output_path)
print(f"OK {output_path}")
if __name__ == "__main__":
main()