#!/usr/bin/env python
"""
build_shareholder_master.py
===========================
Turns the FIRM'S OWN draft letter into the master template that
`subst_shareholder.py` fills in.
python build_shareholder_master.py
Reads assets/source/shareholder_letter_original.docx (the firm's draft)
Writes assets/templates/shareholder_change_resolution_master.docx
WHY IT COPIES THE ORIGINAL RATHER THAN DRAWING A NEW DOCUMENT
-------------------------------------------------------------
An earlier version built the template from scratch with python-docx and styled
it "Calibri 11, roomy". That is not the firm's letter. The real draft is Arial
10 with tight spacing and run-in bold lead-ins — "*Confirmation of Payment:* the
parties confirm…", not a bold heading on its own line. Rebuilding it by hand also
meant the operator's typography could not be honoured: change a font in the .docx
and the generated clauses ignored it.
So the original is the source of truth. Every marker paragraph below is a DEEP
COPY of the corresponding paragraph in the firm's draft — same runs, same rPr,
same pPr — with only the text replaced by a token. Nothing is re-styled, so what
the operator sees in the .docx is exactly what the letter renders as.
TOKENS THE SUBSTITUTION SCRIPT FILLS
------------------------------------
[COMPANY NAME] [ENTERPRISE NUMBER] [REGISTERED ADDRESS] [DATE OF MEETING]
[CITY]
[ATTENDEES_TABLE] the firm's tab-separated NAME / POSITION lines
[TRANSFER_CLAUSES] one WHEREAS triple per transferor
[RESOLVED_CLAUSES] the operative clauses, run-in bold lead-in style
[SIGNATURE_BLOCK] one block per SIGNING attendee
Re-running this script OVERWRITES the template. If the operator has hand-tuned the
master's typography, this is what destroys it — edit the template directly, or
re-run and re-apply. That is the intended workflow: the template is theirs.
"""
import copy
import os
import sys
from docx import Document
from docx.oxml.ns import qn
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SRC = os.path.join(ROOT, "assets", "source", "shareholder_letter_original.docx")
DST = os.path.join(ROOT, "assets", "templates", "shareholder_change_resolution_master.docx")
# ─── XML helpers ─────────────────────────────────────────────────────────────
def _rprs(paragraph):
"""The rPr of each run in `paragraph`, in order (None where unset)."""
out = []
for r in paragraph.runs:
out.append(r._element.find(qn("w:rPr")))
return out
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 markup is
``. A CHILD element (``) leaves
`` valueless, which means bold ON. And w:b must sit in its schema
position, right after w:rStyle / w:rFonts.
"""
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)
def clone_with(src_par, runs):
"""Deep-copy `src_par`, replacing its runs with `runs`.
`runs` is [(text, bold), ...]. Run i inherits the formatting of the source's
run i, falling back to the source's first run. Bold is forced on or off so the
lead-in/body split is exactly what was asked for rather than whatever the
source happened to have.
Copying the paragraph element (not rebuilding it) is the whole point: the
paragraph's spacing, indentation and alignment come across untouched.
"""
el = copy.deepcopy(src_par._element)
for child in list(el):
if child.tag == qn("w:r"):
el.remove(child)
src = _rprs(src_par)
default_rpr = next((x for x in src if x is not None), None)
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)
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
def replace_paragraph(paragraph, runs):
"""Swap a paragraph's content in place, keeping its position and pPr."""
old = paragraph._element
new = clone_with(paragraph, runs)
old.getparent().replace(old, new)
return new
def replace_block(paragraphs, src_par, runs):
"""Collapse several paragraphs into one token paragraph.
`paragraphs` are removed in order and the token paragraph is put where the
first one stood.
"""
el = clone_with(src_par, runs)
first = paragraphs[0]._element
first.getparent().replace(first, el)
for p in paragraphs[1:]:
drop = p._element
parent = drop.getparent()
if parent is not None:
parent.remove(drop)
return el
def find(doc, needle, start=0):
"""First paragraph containing `needle`, searching from `start`."""
for i, p in enumerate(doc.paragraphs):
if i >= start and needle in p.text:
return i
raise SystemExit(f"FATAL: the source draft no longer contains {needle!r}. "
"Re-check assets/source/shareholder_letter_original.docx against "
"this script before regenerating the template.")
def find_all(doc, predicate):
return [p for p in doc.paragraphs if predicate(p.text)]
# ─── build ───────────────────────────────────────────────────────────────────
def build():
if not os.path.exists(SRC):
raise SystemExit(
f"Source draft not found: {SRC}\n"
"Drop the firm's letter in there, then run this again."
)
doc = Document(SRC)
# Header block — token wording only, formatting untouched.
i = find(doc, "HELD AT:")
replace_paragraph(doc.paragraphs[i], [("HELD AT: [REGISTERED ADDRESS]", True)])
i = find(doc, "ON THE:")
replace_paragraph(doc.paragraphs[i], [("ON THE: [DATE OF MEETING]", True)])
# Attendees — the draft has two placeholder lines (a leftover from typing);
# collapse them into one marker carrying the same 12pt tabbed formatting.
att = find_all(doc, lambda t: "[NAME AND SURNAME]" in t)
if not att:
raise SystemExit("FATAL: no attendee placeholder rows found in the source draft.")
replace_block(att, att[0], [("[ATTENDEES_TABLE]", False)])
# WHEREAS block — one marker, formatted like the draft's first WHEREAS so the
# generated clauses inherit its Arial 10 and its bold lead-in. Two runs, one
# bold and one not: the generator takes run 0's formatting for "WHEREAS" and
# run 1's for the sentence that follows, which is the draft's own split.
wh = find_all(doc, lambda t: t.strip().startswith(("WHEREAS", "AND WHEREAS")))
if not wh:
raise SystemExit("FATAL: no WHEREAS clauses found in the source draft.")
replace_block(wh, wh[0], [("[TRANSFER_CLAUSES]", True), ("", False)])
# Resolved clauses — one marker, formatted like "Confirmation of Payment:"
# (bold lead-in run, then a normal run), which is the draft's run-in style.
resolved_heads = (
"Approval of Transfer to",
"Confirmation of Payment:",
"Retention of Shares:",
"Administrative Actions:",
)
rs = [p for p in doc.paragraphs
if any(p.text.strip().startswith(h) for h in resolved_heads)]
if not rs:
raise SystemExit("FATAL: no resolved clauses found in the source draft.")
replace_block(rs, rs[0], [("[RESOLVED_CLAUSES]", True), ("", False)])
# Closing wording. "adjourned" -> "closed" (agreed 2026-10-14: a meeting that
# passed a resolution is closed, not adjourned).
i = find(doc, "adjourned")
replace_paragraph(doc.paragraphs[i],
[("There being no further business, the meeting was closed.", False)])
i = find(doc, "Signed at")
replace_paragraph(doc.paragraphs[i], [("Signed at [CITY] on [DATE OF MEETING].", False)])
# Signature area — collapse the draft's two sample blocks into one marker
# formatted like the first signature line.
sig_start = find(doc, "_____")
sig_paras = [p for p in doc.paragraphs[sig_start:]
if "_____" in p.text or "attenmdee" in p.text]
if not sig_paras:
raise SystemExit("FATAL: no signature block found in the source draft.")
replace_block(sig_paras, sig_paras[0], [("[SIGNATURE_BLOCK]", False)])
os.makedirs(os.path.dirname(DST), exist_ok=True)
doc.save(DST)
print(f"Wrote {DST}")
print(f" from {SRC}")
print(" typography copied from the firm's draft — nothing re-styled")
if __name__ == "__main__":
build()