244 lines
9.3 KiB
Python
244 lines
9.3 KiB
Python
|
|
#!/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
|
||
|
|
`<w:b w:val="0"/>`. A CHILD element (`<w:b><w:val w:val="0"/></w:b>`) leaves
|
||
|
|
`<w:b>` 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()
|