Workflow-App/Workflow/scripts/subst_shareholder.py
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

767 lines
No EOL
28 KiB
Python

#!/usr/bin/env python
"""
subst_shareholder.py
====================
Fills `assets/templates/shareholder_change_resolution_master.docx` for section
5 ("Change in shareholders") and writes the result.
Invoked by lib/shareholder-letter.ts as:
python subst_shareholder.py <template.docx> <out.docx> <context.json>
Context shape
-------------
{
"company": { "name": str, "enterprise_no": str,
"address": str, "address_city": str },
"meeting_date": "YYYY-MM-DD",
"attendees": [
{ "name": str, "surname": str, "position": str,
"signs": bool, "email": str | None }, ...
],
"transferors": [
{ "name": str, "surname": str,
# FULL identification — "Conrad Mmako (ID 8001234567)". Already contains
# the display name; do not print party_label() next to it.
"id_label": str,
"percentage": str, "certificate_no": str,
"shares_sold": int,
"distinctive_from": str, "distinctive_to": str,
"shares_retained": int,
"retained_from": str, "retained_to": str,
"consideration": str, "payment_method": str }, ...
],
"transferees": [
{ "name": str, "surname": str, "id_label": str, "shares": int }, ...
],
"votes_percent": str,
}
Three tokens expand to generated blocks rather than plain text, because their
size depends on how many parties the resolution has:
[ATTENDEES_TABLE] -> tab-separated NAME / POSITION lines
[TRANSFER_CLAUSES] -> one WHEREAS triple per transferor
[RESOLVED_CLAUSES] -> the operative clauses, run-in style
[SIGNATURE_BLOCK] -> one signature line per SIGNING attendee
FORMATTING IS INHERITED, NEVER STAMPED
--------------------------------------
Generated runs copy the character formatting of the marker paragraph they
replace, and set no font or size of their own (see `emit_like`). The master's
.docx is therefore the single source of truth for typography: change the font in
Word and every generated clause follows. An earlier version stamped Pt(11)/Pt(10)
/Pt(9) onto each run, which silently overrode whatever the operator chose and
made the body and the generated clauses two different documents.
Run 0 of a marker styles the bold lead-in, run 1 the body that follows it — which
is how the firm's own draft writes its clauses.
This script is deliberately SEPARATE from scripts/subst.py. That one encodes the
director-change letter's structure — its section headings, its "None this
meeting" collapsing rules, its signature embedding — none of which apply here.
Sharing one script would mean every change here risked breaking section 4's
legal document.
"""
import copy
import json
import os
import re
import sys
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from docx.shared import Pt
# ─── formatting helpers ─────────────────────────────────────────────────────
def fmt_int(n):
try:
return f"{int(n):,}"
except (TypeError, ValueError):
return str(n) if n not in (None, "") else "___"
def full_name(p):
return " ".join(x for x in (p.get("name", ""), p.get("surname", "")) if x).strip()
def title_case(text):
"""'SUNE PIETERS' -> 'Sune Pieters'. Names are stored SHOUTED in PB."""
return " ".join(w[:1].upper() + w[1:].lower() for w in (text or "").split())
def party_label(p):
return title_case(full_name(p)) or "(unnamed)"
def as_subject(p):
"""The party as the subject of a clause.
A company takes the definite article — "The Acme (Pty) Ltd shall retain…" —
a person never does. "The Stephanus Gerhardus Barnard shall retain" is not
English, and it turned up in a signed-looking document.
"""
label = party_label(p)
if p.get("source") == "company":
return f"The {label}"
return label
def or_blank(value, fallback="________"):
"""A captured value, or an underscore run if the operator left it blank."""
v = ("" if value is None else str(value)).strip()
return v if v else fallback
def percent_label(value):
"""Normalise the captured percentage into exactly one trailing '%'.
The wizard's placeholder shows "e.g. 51%", so a bare "51" and a "51%" are
both reasonable input and the template cannot simply append its own sign —
that printed "51%%".
"""
v = ("" if value is None else str(value)).strip()
if not v:
return "________%"
if v.endswith("%"):
return v
return f"{v}%"
def pretty_date(iso):
"""'2026-10-14' -> '14 October 2026'. Falls back to the raw string."""
if not iso:
return ""
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})", iso)
if not m:
return iso
months = ["January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"]
y, mo, d = m.groups()
return f"{int(d)} {months[int(mo) - 1]} {y}"
def buyer_phrase(transferees):
"""'X' or 'X and Y' — used inside the WHEREAS transfer clause."""
labels = [party_label(t) for t in transferees]
if not labels:
return "the transferee"
if len(labels) == 1:
return labels[0]
if len(labels) == 2:
return f"{labels[0]} and {labels[1]}"
return ", ".join(labels[:-1]) + f" and {labels[-1]}"
# ─── token substitution ──────────────────────────────────────────────────────
def substitute_runs(paragraph, token_text, value):
"""Replace `token_text` even when split across runs.
The value is written into the run where the token STARTS, so that run's
formatting survives.
"""
runs = paragraph.runs
if not runs:
return False
joined = "".join(r.text for r in runs)
if token_text not in joined:
return False
guard = 0
while token_text in joined and guard < 50:
guard += 1
idx = joined.index(token_text)
pos = 0
start_run = end_run = None
start_off = end_off = 0
for i, r in enumerate(runs):
length = len(r.text)
if start_run is None and pos + length > idx:
start_run = i
start_off = idx - pos
if pos + length >= idx + len(token_text):
end_run = i
end_off = idx + len(token_text) - pos
break
pos += length
if start_run is None or end_run is None:
return False
head = runs[start_run].text[:start_off]
tail = runs[end_run].text[end_off:]
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)
return True
def walk(doc, pairs):
for p in doc.paragraphs:
for tok, val in pairs:
substitute_runs(p, tok, val)
for t in doc.tables:
for row in t.rows:
for cell in row.cells:
for p in cell.paragraphs:
for tok, val in pairs:
substitute_runs(p, tok, val)
# ─── document plumbing ───────────────────────────────────────────────────────
def drop(paragraph):
el = paragraph._element
parent = el.getparent()
if parent is not None:
parent.remove(el)
def find_marker(doc, token_text):
for p in doc.paragraphs:
if token_text in "".join(r.text for r in p.runs):
return p
return None
def _rprs_of(el):
"""rPr of each direct run child of a paragraph element."""
return [r.find(qn("w:rPr")) for r in el.findall(qn("w:r"))]
def _para_text(el):
"""All visible text in a paragraph element, whitespace ignored.
Used to decide whether a paragraph is an empty spacer. A paragraph holding
only tabs or spaces counts as blank — the master's spacers are written that
way — but a paragraph with any real character in it never does.
"""
parts = [t.text or "" for t in el.iter(qn("w:t"))]
return "".join(parts).strip()
# Space above each signature rule, in twentieths of a point (Word's twips).
#
# This is set EXPLICITLY rather than left to a blank paragraph, because a blank
# paragraph's height is added to the height of the line above it — and that line
# is 10pt under "Approved by:" but 8.5pt under the ID / Cell / Email details.
# The sum therefore differs, and the first signature rule floated about 9pt
# further from the heading than every later one. An explicit `space_before`
# adds a fixed amount that does not depend on what precedes it, so every gap is
# genuinely identical. 600 twips = 30pt.
SIGNATURE_SPACE_BEFORE = 600
def set_space_before(el, twips):
"""Force `w:spacing/@w:before` on a paragraph, keeping the rest intact."""
ppr = el.find(qn("w:pPr"))
if ppr is None:
ppr = el.makeelement(qn("w:pPr"), {})
el.insert(0, ppr)
spacing = ppr.find(qn("w:spacing"))
if spacing is None:
spacing = ppr.makeelement(qn("w:spacing"), {})
# w:spacing follows w:tabs and precedes w:ind / w:jc.
ind = ppr.find(qn("w:ind"))
if ind is not None:
ind.addprevious(spacing)
else:
ppr.append(spacing)
spacing.set(qn("w:before"), str(twips))
# Where the role column starts, measured from the left margin. Same column
# section 4 uses, so the two letters' attendee lists line up identically.
ROLE_COL_INCHES = 2.3
def _set_tab_stop(el, inches):
"""Give a paragraph one explicit left tab stop, replacing any it inherited."""
ppr = el.find(qn("w:pPr"))
if ppr is None:
ppr = el.makeelement(qn("w:pPr"), {})
el.insert(0, ppr)
for old in ppr.findall(qn("w:tabs")):
ppr.remove(old)
tabs = ppr.makeelement(qn("w:tabs"), {})
tab = ppr.makeelement(qn("w:tab"), {})
tab.set(qn("w:val"), "left")
tab.set(qn("w:pos"), str(int(round(inches * 1440)))) # twips
tabs.append(tab)
# w:tabs follows w:numPr/w:spacing's predecessors; insert before w:spacing
# if present, else append.
spacing = ppr.find(qn("w:spacing"))
if spacing is not None:
spacing.addprevious(tabs)
else:
ppr.append(tabs)
def set_bold(rpr, bold):
"""Set or clear bold on an rPr in a way Word actually honours.
CT_OnOff is an ATTRIBUTE, not a parent/child pair. The correct markup is
`<w:b w:val="0"/>`. Writing a CHILD element — `<w:b><w:val w:val="0"/></w:b>`
— leaves `<w:b>` with no value at all, which defaults to bold ON, so every
"not bold" run came out bold. It also has to sit in schema position, right
after w:rStyle / w:rFonts, or Word ignores the whole rPr.
"""
for tag in ("w:b", "w:bCs"):
for node in rpr.findall(qn(tag)):
rpr.remove(node)
b_el = rpr.makeelement(qn("w:b"), {})
b_el.set(qn("w:val"), "1" if bold else "0")
insert_at = 0
for i, child in enumerate(rpr):
if child.tag.split("}")[-1] in ("rStyle", "rFonts"):
insert_at = i + 1
else:
break
rpr.insert(insert_at, b_el)
DETAIL_SIZE_FACTOR = 0.85
def scale_rpr_size(rpr, factor):
"""Scale an inherited run's font size by `factor`.
Used for the ID / Cell / Email detail lines, which section 4 renders at 85%
of the name above them (`subst.py::_insert_detail_line`) — that slight step
down is what makes them read as detail rather than as a second heading, and
the operator asked for section 5 to match.
`w:sz` is stored in HALF-POINTS as an integer, so the arithmetic has to land
back on that grid or Word rejects the value. A run with no explicit size
inherits from the style and is left completely alone — scaling something that
isn't there would mean inventing a size the template never asked for.
"""
if factor == 1.0:
return
for tag in ("w:sz", "w:szCs"):
node = rpr.find(qn(tag))
if node is None:
continue
raw = node.get(qn("w:val"))
try:
half_points = int(round(float(raw) * factor))
except (TypeError, ValueError):
continue
# Never round a visible size away to nothing.
node.set(qn("w:val"), str(max(2, half_points)))
def emit_like(marker_el, runs, scale=1.0):
"""Build a paragraph element that INHERITS the marker's formatting.
`runs` is [(text, bold), ...]. Run 0 inherits the marker's first run's
character formatting, run 1 its second's, and any further runs the first
again. A marker written as "bold lead-in + normal body" therefore produces
exactly that split in the generated clause — which is how the firm's draft
writes its clauses ("Confirmation of Payment: The parties confirm…").
Nothing about typeface or size is set here. That is the point: the template
is the single source of truth, so change the font in the .docx and every
generated clause follows. An earlier version stamped Pt(11)/Pt(10)/Pt(9) onto
each run, which silently overrode the operator's choice.
The one exception is `scale`: the ID / Cell / Email detail lines under each
signature name are rendered at 85% of the inherited size, matching section 4.
That scales whatever the template says rather than replacing it, so it still
follows the template's font and colour.
"""
el = copy.deepcopy(marker_el)
src = _rprs_of(el)
default_rpr = next((x for x in src if x is not None), None)
for child in list(el):
if child.tag == qn("w:r"):
el.remove(child)
for idx, (text, bold) in enumerate(runs):
r_el = el.makeelement(qn("w:r"), {})
rpr = None
if idx < len(src) and src[idx] is not None:
rpr = copy.deepcopy(src[idx])
elif default_rpr is not None:
rpr = copy.deepcopy(default_rpr)
if rpr is not None:
set_bold(rpr, bold)
scale_rpr_size(rpr, scale)
r_el.append(rpr)
t_el = el.makeelement(qn("w:t"), {})
t_el.set(qn("xml:space"), "preserve")
t_el.text = text
r_el.append(t_el)
el.append(r_el)
return el
class Emitter:
"""Appends paragraphs in order, starting at the token marker.
The first paragraph replaces the marker (goes before it); the rest chain after
it. Chaining against a single advancing anchor is what keeps them in source
order — inserting each one relative to the marker reversed them.
"""
def __init__(self, marker_el):
self.marker = marker_el
self.anchor = None
def add(self, el):
if self.anchor is None:
self.marker.addprevious(el)
else:
self.anchor.addnext(el)
self.anchor = el
return el
def line(self, runs, scale=1.0):
return self.add(emit_like(self.marker, runs, scale=scale))
def drop_preceding_blanks(marker_el):
"""Remove the empty paragraphs sitting IMMEDIATELY before the marker.
The master inherits three blank paragraphs between "Approved by:" and the
signature token from the firm's draft. Left in place they inflate the gap
above the first signer only, which is why "Approved by" ended up 77pt from
the first signature line while every later gap was 17pt. The generated block
supplies its own uniform separator instead.
Stops at the first non-empty paragraph, so it can never eat real content.
Returns how many were removed.
"""
removed = 0
node = marker_el.getprevious()
while node is not None and node.tag == qn("w:p") and not _para_text(node):
prev = node.getprevious()
node.getparent().remove(node)
node = prev
removed += 1
return removed
# ─── block builders ──────────────────────────────────────────────────────────
def build_attendees_table(doc, attendees, marker_el):
"""The firm's own attendee block: tab-separated NAME / POSITION lines.
Their draft writes these as plain tabbed lines, not a bordered table, so this
does the same rather than imposing a grid they did not ask for.
The role column is aligned with a REAL left tab stop, the same 2.3" column
section 4 uses. The letterhead font is proportional, so padding with spaces
does NOT line the roles up — a tab stop is what Word actually aligns on, and
without one the roles land wherever the default half-inch stops happen to
fall, which is what made the names look ragged.
"""
em = Emitter(marker_el)
for a in attendees:
p = em.line([(party_label(a), False),
("\t" + ((a.get("position") or "").strip() or "Shareholder"), False)])
_set_tab_stop(p, ROLE_COL_INCHES)
return em.anchor
def build_transfer_clauses(doc, transferors, transferees, marker_el):
"""One WHEREAS triple per transferor, in the firm's own run-in style.
The lead-in ("WHEREAS" / "AND WHEREAS") stays bold and the sentence that
follows it is not — exactly as the draft writes it. With a single seller this
reproduces the draft's wording; with several, each gets its own triple rather
than being crammed into one unreadable sentence.
"""
em = Emitter(marker_el)
buyer = buyer_phrase(transferees)
for t in transferors:
# `ident` is the full identification — name AND ID / enterprise number —
# so it must not be printed alongside `party_label(t)` as well.
ident = or_blank(t.get("id_label"))
pct = percent_label(t.get("percentage"))
cert = or_blank(t.get("certificate_no"))
sold = fmt_int(t.get("shares_sold", 0))
dfrom = or_blank(t.get("distinctive_from"))
dto = or_blank(t.get("distinctive_to"))
retained = fmt_int(t.get("shares_retained", 0))
rfrom = or_blank(t.get("retained_from"))
rto = or_blank(t.get("retained_to"))
consideration = or_blank(t.get("consideration"))
method = or_blank(t.get("payment_method"), "____")
em.line([
("WHEREAS", True),
(f" {ident} (\"the Transferor\") held {pct} of the issued shares in "
f"the Company, represented by Share Certificate No. {cert};", False),
])
em.line([
("AND WHEREAS", True),
(f" the Transferor has agreed to sell {sold} shares (Distinctive "
f"numbers: number from {dfrom} to {dto}) to {buyer} for a total "
f"consideration of R{consideration}, which has been paid in full by "
f"{method};", False),
])
# Only resolve to retain shares if any actually are retained. A seller
# who sells their entire holding produced the nonsense clause "shall
# retain the remaining 0 shares (Distinctive numbers: 52 to 100)".
if (t.get("shares_retained") or 0) > 0:
em.line([
("AND WHEREAS", True),
(f" the Transferor shall retain the remaining {retained} shares "
f"(Distinctive numbers: number from {rfrom} to {rto});", False),
])
return em.anchor
def build_resolved_clauses(doc, transferors, transferees, votes_percent, marker_el):
"""The operative clauses, in the firm's run-in style.
Their draft puts the label and the sentence in ONE paragraph, the label bold
and the rest not — "Confirmation of Payment: The parties confirm…". An earlier
version split them into a numbered heading on its own line, which is a
different document from the one the firm actually signs.
Two clauses are NOT in the draft: MOI compliance and Voting Rights. Both were
added on 2026-10-14 after review and are kept here deliberately.
"""
em = Emitter(marker_el)
buyer = buyer_phrase(transferees)
# 1. Approval of transfer — one per transferor.
for t in transferors:
seller = as_subject(t)
sold = fmt_int(t.get("shares_sold", 0))
consideration = or_blank(t.get("consideration"))
em.line([
(f"Approval of Transfer to {buyer}:", True),
(f" The transfer of {sold} shares from {seller} to {buyer} is "
f"approved at the value of R{consideration}.", False),
])
# 2. Confirmation of payment.
method = or_blank((transferors[0].get("payment_method") if transferors else None),
"____")
em.line([
("Confirmation of Payment:", True),
(" The parties confirm that the shares have been paid for in full, by "
f"{method}, to the Transferor upon the signing of this resolution and "
"the issuance of the new share certificates.", False),
])
# 3. Retention of shares — skipped entirely when nothing is retained, so the
# resolution never resolves to retain shares it is not keeping.
for t in transferors:
if (t.get("shares_retained") or 0) <= 0:
continue
seller = as_subject(t)
retained = fmt_int(t.get("shares_retained", 0))
rfrom = or_blank(t.get("retained_from"))
rto = or_blank(t.get("retained_to"))
em.line([
("Retention of Shares:", True),
(f" {seller} shall retain {retained} shares (Distinctive numbers: "
f"number from {rfrom} to {rto}).", False),
])
# 4. MOI compliance — ADDED 2026-10-14; not in the original draft.
em.line([
("Compliance with the Memorandum of Incorporation:", True),
(" Nothing contained in these resolutions contravenes any restriction on "
"the transfer of shares contained in the Company's Memorandum of "
"Incorporation, and any such restriction has been complied with in all "
"respects.", False),
])
# 5. Voting rights — ADDED 2026-10-14; not in the original draft.
votes = percent_label(votes_percent) if votes_percent else "___%"
em.line([
("Voting Rights:", True),
(f" This special resolution was passed by shareholders holding {votes} "
"of the voting rights of the Company, which constitutes the required "
"majority in terms of the Companies Act 71 of 2008 and the Company's "
"Memorandum of Incorporation.", False),
])
# 6. Administrative actions — from the original draft.
em.line([
("Administrative Actions:", True),
(" The Company Directors are authorized to cancel the existing share "
"certificate, issue new certificates, and update the Securities Register "
"and CIPC Beneficial Ownership records.", False),
])
return em.anchor
def build_signature_blocks(doc, attendees, city, date_text, marker_el):
"""One signature block per SIGNING attendee, in section 4's shape.
Section 4 renders each signer as a name followed by their details:
______________________
Sune Pieters
ID - 9810230091087
Cell - 0827985409
Email - sj@dyfins.com
with the three detail lines at 85% of the name's size, and the operator asked
for section 5 to match. A missing value drops its line rather than leaving a
blank.
SPACING IS UNIFORM BY CONSTRUCTION. Every block is separated from the last by
exactly one blank paragraph — including the first one from "Approved by:".
Previously the gap above the first signer was four line-heights (the master
carried blank spacer paragraphs from the firm's draft) while every later gap
was a single line, so the first signature floated far below the heading. The
spacers are removed and replaced by generated ones, which is the only way to
guarantee the four gaps are identical no matter how many people sign.
Only attendees the operator ticked actually get a block: an unticked
attendee was present but is not a signatory.
"""
# Drop the draft's own spacers first, so the generated separator is the only
# thing shaping the gap under "Approved by:".
drop_preceding_blanks(marker_el)
em = Emitter(marker_el)
signers = [a for a in attendees if a.get("signs")]
if not signers:
# Nothing ticked — say so rather than letting the block silently vanish
# and leave "Approved by:" pointing at nothing.
em.line([("(No attendee was marked as signing.)", False)])
return em.anchor
for a in signers:
# The space above every signature rule is the same explicit value, so
# the first rule sits the same distance below "Approved by:" as each
# later rule sits below the one before it.
rule = em.line([("_" * 33 + "\t", False)])
set_space_before(rule, SIGNATURE_SPACE_BEFORE)
em.line([(party_label(a), True)])
for line in detail_lines(a):
em.line([(line, False)], scale=DETAIL_SIZE_FACTOR)
return em.anchor
def detail_lines(person):
"""'ID - …', 'Cell - …', 'Email - …' for a person, blanks dropped."""
out = []
id_no = str(person.get("id_number") or "").strip()
cell = str(person.get("cell") or "").strip()
email = str(person.get("email") or "").strip()
if id_no:
out.append(f"ID - {id_no}")
if cell:
out.append(f"Cell - {cell}")
if email:
out.append(f"Email - {email}")
return out
# ─── main ────────────────────────────────────────────────────────────────────
def main(argv):
if len(argv) < 4:
print("usage: subst_shareholder.py <template.docx> <out.docx> <context.json>",
file=sys.stderr)
return 2
template, out_path, ctx_path = argv[1], argv[2], argv[3]
# utf-8-sig tolerates a BOM; the Node side does not write one, but failing
# on it would be pointless.
with open(ctx_path, "r", encoding="utf-8-sig") as fh:
ctx = json.load(fh)
company = ctx.get("company", {}) or {}
attendees = ctx.get("attendees", []) or []
transferors = ctx.get("transferors", []) or []
transferees = ctx.get("transferees", []) or []
votes_percent = ctx.get("votes_percent", "")
date_iso = ctx.get("meeting_date") or ""
date_text = pretty_date(date_iso)
city = (company.get("address_city") or "").strip()
doc = Document(template)
# -- Attendees ---------------------------------------------------------
marker = find_marker(doc, "[ATTENDEES_TABLE]")
if marker is not None:
build_attendees_table(doc, attendees, marker._element)
drop(marker)
# -- WHEREAS clauses ---------------------------------------------------
marker = find_marker(doc, "[TRANSFER_CLAUSES]")
if marker is not None:
build_transfer_clauses(doc, transferors, transferees, marker._element)
drop(marker)
# -- Operative clauses -------------------------------------------------
marker = find_marker(doc, "[RESOLVED_CLAUSES]")
if marker is not None:
build_resolved_clauses(doc, transferors, transferees, votes_percent,
marker._element)
drop(marker)
# -- Signature block ---------------------------------------------------
marker = find_marker(doc, "[SIGNATURE_BLOCK]")
if marker is not None:
build_signature_blocks(doc, attendees, city, date_text, marker._element)
drop(marker)
# -- Simple tokens -----------------------------------------------------
walk(doc, [
("[COMPANY NAME]", company.get("name", "")),
("[ENTERPRISE NUMBER]", company.get("enterprise_no", "")),
("[REGISTERED ADDRESS]", company.get("address", "")),
("[DATE OF MEETING]", date_text),
("[CITY]", city),
])
doc.save(out_path)
# Report anything left unsubstituted so a missing value is visible rather
# than silently shipping "[DATE OF MEETING]" to a client.
leftovers = set()
pattern = re.compile(r"\[[A-Z][A-Z _]*\]")
for p in doc.paragraphs:
leftovers.update(pattern.findall(p.text))
for t in doc.tables:
for row in t.rows:
for cell in row.cells:
for p in cell.paragraphs:
leftovers.update(pattern.findall(p.text))
print("subst_shareholder: wrote", os.path.basename(out_path),
f"({len(attendees)} attendees, {len(transferors)} transferors, "
f"{len(transferees)} transferees)")
if leftovers:
print("UNFILLED:", ", ".join(sorted(leftovers)))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))