847 lines
32 KiB
Python
847 lines
32 KiB
Python
|
|
"""
|
||
|
|
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()
|