#!/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()