767 lines
28 KiB
Python
767 lines
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))
|