#!/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 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 ``. Writing a CHILD element — `` — leaves `` 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 ", 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))