""" subst.py — Phase 6 letter substitution engine. Standalone Python script invoked by lib/letter.ts via: python3 subst.py What it does: 1. Reads context.json (company, attendees, signers, resolution info) 2. Substitutes every [PLACEHOLDER] token in the docx 3. Embeds signature PNGs above the signature lines 4. Saves the result as out.docx (caller then runs soffice → PDF) The signatures themselves come from PocketBase file URLs in the signer records — we download them to /.png BEFORE invoking this script (the Node side handles PB auth). """ import sys import os import re import json from copy import deepcopy from docx import Document from docx.enum.text import WD_TAB_ALIGNMENT from docx.shared import Inches, Pt from docx.oxml.ns import qn from docx.text.paragraph import Paragraph # ─── Helpers ───────────────────────────────────────────────────────────── def _label(ctx): r = ctx.get("resigning_director_label") n = ctx.get("newly_appointed_director_label") # Treat "None this meeting" as the empty-sentinel that letter-runner # emits when there are no directors of that kind on this resolution. has_resignations = r and r != "None this meeting" has_appointments = n and n != "None this meeting" if has_resignations and has_appointments: return "resignation and appointment" if has_resignations: return "resignation" if has_appointments: return "appointment" return "resignation and/or appointment" def _resigning_label(ctx): n = ctx.get("resigning_director_label") if not n or n == "None this meeting": # Letter-runner emitted the empty-sentinel. _neutralise_empty_sections # will rewrite paragraphs that need this token. return "None this meeting" return n def _newly_appointed_label(ctx): n = ctx.get("newly_appointed_director_label") if not n or n == "None this meeting": return "None this meeting" return n def _role_key(role): """Fold director_role to a canonical key. The app writes LOWERCASE short values ("resigned" / "appointed" / "existing" / "external") — see app/api/resolutions/create/route.ts — while older fixtures used "RESIGNED DIRECTOR" / "NEW DIRECTOR". Matching the literal uppercase string silently found nothing, so the appointment date fell back to the meeting date. Keep both spellings working. """ key = (role or "").lower() key = "".join(ch for ch in key if ch.isalpha()) if key in ("resigned", "resigneddirector", "resignation"): return "resigned" if key in ("appointed", "appointment", "newdirector", "newappointment"): return "appointed" if key in ("existing", "existingdirector"): return "existing" if key == "external": return "external" return key def _new_appointment_date(ctx): apts = [a for a in ctx["attendees"] if _role_key(a.get("director_role")) == "appointed"] if apts and apts[0].get("effective_date"): return apts[0]["effective_date"] return ctx["meeting_date"] ROLE_LEGAL_LABEL = { "resigned": "RESIGNED DIRECTOR", "appointed": "NEW DIRECTOR", "existing": "DIRECTOR", "external": "DIRECTOR", } def _attendee_roles(ctx): """Roles for [POSITION/DIRECTOR/NEW DIRECTOR/RESIGNED DIRECTOR]. Rendered in the letter's legal wording, not the raw DB value — the DB stores "resigned"/"existing" but the template's position column expects "RESIGNED DIRECTOR"/"DIRECTOR". """ labels = [] for a in ctx["attendees"]: if a.get("director_role"): key = _role_key(a["director_role"]) label = ROLE_LEGAL_LABEL.get(key, a["director_role"].upper()) if label not in labels: labels.append(label) return labels or ["DIRECTOR"] def _attendee_emails(ctx): emails = [a["email"] for a in ctx["attendees"] if a.get("email")] return ", ".join(emails) if emails else "[EMAIL ADDRESS OF THE ADDITIONAL ATTENDEES]" def _build_placeholders(ctx): """Ordered list of (token, value). Order matters (longest first).""" return [ ("[COMPANY NAME]", ctx["company"]["name"]), ("[ENTERPRISE NO]", ctx["company"]["enterprise_no"]), ( "[ADDRESS LINE 1, ADDRESS LINE 2, TOWN /CITY, PROVINCE, POSTAL CODE]", ctx["company"]["address"], ), ("[ADDRESS_CITY]", ctx["company"]["address_city"]), ("[DATE EMAIL WAS SENT]", ctx["meeting_date"]), ("[DATE LETTER WAS SENT]", ctx["meeting_date"]), ("[date the letter was sent]", ctx["meeting_date"]), ("[as per insert]", ctx["meeting_date"]), ("[date as per insert]", ctx["meeting_date"]), ("[Effective Date of Appointment]", _new_appointment_date(ctx)), ("[resignation and/or appointment]", _label(ctx)), ("[director that resigned name , surname and id]", _resigning_label(ctx)), ("[director name surname and id]", _resigning_label(ctx)), ("[resigning director name, surname and id]", _resigning_label(ctx)), ("[newly appointed director name, surname and id]", _newly_appointed_label(ctx)), ("[newly appointed director name surname and id]", _newly_appointed_label(ctx)), ("[POSITION/DIRECTOR/NEW DIRECTOR/RESIGNED DIRECTOR]", "/".join(sorted(set(_attendee_roles(ctx))))), ("[EMAIL ADDRESS OF THE ADDITIONAL ATTENDEES]", _attendee_emails(ctx)), ] def _replace_token_across_runs(runs, token, value): """Replace `token` even when it spans several runs. Formatting must survive the substitution. The replacement text is written into the run where the token STARTS, so that run's bold/italic applies. This matters a lot in the master template: every Ordinary Resolution opens with a bold label run ("Ordinary Resolution 1") followed by non-bold body runs. The old implementation collapsed the whole paragraph into run 0, which meant that substituting ANY placeholder turned the entire resolution bold. Here, "Ordinary Resolution 1" stays bold and the RESOLVED text does not. In §2 the director-name token is itself bold in the template, and that bold is preserved because the token starts in a bold run. """ joined = "".join(r.text for r in runs) guard = 0 while token in joined and guard < 50: guard += 1 idx = joined.index(token) # Map the character span [idx, idx+len(token)) onto run indices. pos = 0 start_run = end_run = None start_offset = end_offset = 0 for i, r in enumerate(runs): length = len(r.text) if start_run is None and pos + length > idx: start_run = i start_offset = idx - pos if pos + length >= idx + len(token): end_run = i end_offset = idx + len(token) - pos break pos += length if start_run is None or end_run is None: return head = runs[start_run].text[:start_offset] tail = runs[end_run].text[end_offset:] 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) def _substitute_runs(paragraph, placeholders): """Find/replace every token in a paragraph, preserving run formatting.""" runs = paragraph.runs if not runs: return for token, value in placeholders: if token in "".join(r.text for r in runs): _replace_token_across_runs(runs, token, value) def _walk(doc, placeholders): for p in doc.paragraphs: _substitute_runs(p, placeholders) for table in doc.tables: for row in table.rows: for cell in row.cells: for p in cell.paragraphs: _substitute_runs(p, placeholders) def _fix_section3_director_token(doc, ctx): """The master docx uses `[director name surname and id]` in BOTH Section 2 (resignations) AND Section 3 (appointments). Without context-aware substitution the same token maps to the resigning director everywhere and Section 3 names the wrong person. This pre-pass targets the FIRST `[director name surname and id]` occurrence AFTER the `3. Appointment of Director(s)` heading and substitutes it with the NEWLY APPOINTED label. After this pass the global walk still substitutes remaining occurrences (i.e. Section 2) with the resigning label, as intended by _build_placeholders(). When the resolution has no newly-appointed director, the sentinel "None this meeting" flows through here. We skip the section-3 substitution entirely — _neutralise_empty_sections will rewrite Section 3 prose to "None this meeting." instead of leaving the literal "None this meeting" name in place. """ newly_appointed = _newly_appointed_label(ctx) if newly_appointed.startswith("[") or newly_appointed == "None this meeting": # Nothing real to substitute; the empty-section pre-pass handles it. return # Find paragraph index where Section 3 starts. section3_idx = None for i, p in enumerate(doc.paragraphs): if "3. Appointment of Director" in p.text: section3_idx = i break if section3_idx is None: return # Walk paragraphs from Section 3 onward and replace the FIRST occurrence # of the target token. target = "[director name surname and id]" for i in range(section3_idx, len(doc.paragraphs)): p = doc.paragraphs[i] if target not in p.text: continue _substitute_runs(p, [(target, newly_appointed)]) return APPOINT_HEADINGS = ("3. Appointment of Director", "3 . Appointment of Director") ORDINARY_HEADINGS = ("4. Ordinary Resolutions", "4 . Ordinary Resolutions") CLOSE_HEADINGS = ("5. Close", "5 . Close") APPOINT_OR_HEADINGS = ("Ordinary Resolution 3", "Ordinary Resolution 4") def _set_paragraph_text(p, text): """Replace a paragraph's text, keeping the first run's formatting.""" if p.runs: p.runs[0].text = text for r in p.runs[1:]: r.text = "" else: p.add_run(text) def _delete_paragraphs(paras): """Remove elements from the document body.""" for p in paras: el = p._element parent = el.getparent() if parent is not None: parent.remove(el) def _heading_index(paras, prefixes, start=0): """Index of the first paragraph whose text starts with any prefix.""" for i in range(start, len(paras)): full = "".join(r.text for r in paras[i].runs) if any(full.startswith(h) for h in prefixes): return i return None def _drop_appointment_sections(doc): """No new directors this meeting → remove §3, OR3 and OR4, then renumber. Leaves the minutes reading 1, 2, 3 (Ordinary Resolutions), 4 (Close) with no gap and no "(None this meeting.)" stub in a formal document. §4 → §3 and §5 → §4. Called AFTER the _neutralise_empty_sections collapse pass, so it works on a document where §3/OR3/OR4 have already been blanked to the empty notice; either way the paragraphs are removed here. """ # 1. §3 heading + its body paragraph(s), up to §4 (or §5 / end of doc). paras = list(doc.paragraphs) start = _heading_index(paras, APPOINT_HEADINGS) if start is not None: end = _heading_index(paras, ORDINARY_HEADINGS + CLOSE_HEADINGS, start=start + 1) if end is None: end = len(paras) _delete_paragraphs(paras[start:end]) # 2. OR3 / OR4 paragraphs anywhere in the body. paras = list(doc.paragraphs) ors = [] for p in paras: full = "".join(r.text for r in p.runs) if any(full.startswith(h) for h in APPOINT_OR_HEADINGS): ors.append(p) _delete_paragraphs(ors) # 3. Renumber §4 → §3 and §5 → §4. paras = list(doc.paragraphs) ordinary_idx = _heading_index(paras, ORDINARY_HEADINGS) if ordinary_idx is not None: _set_paragraph_text(paras[ordinary_idx], "3. Ordinary Resolutions:") close_idx = _heading_index(paras, CLOSE_HEADINGS) if close_idx is not None: _set_paragraph_text(paras[close_idx], "4. Close:") def _neutralise_empty_sections(doc, ctx): """When a resolution has no resignations and/or no appointments, the imperative-prose paragraphs in §2, §3 and OR1..OR4 would read broken ("...resignation of None this meeting from their positions..."). This pre-pass finds the affected paragraphs BEFORE the global _walk runs and replaces them with a one-line "(None this meeting.)" notice. Strategy: - Identify which paragraphs belong to the resignation sections (§2, OR1, OR2) and which belong to appointment sections (§3, OR3, OR4) based on PARAGRAPH POSITION in the doc — not on token presence, because tokens in those paragraphs would have been replaced by real director names in some cases (e.g. the §3 paragraph that contains [director name surname and id] still mentions [Effective Date of Appointment] which makes the section detectable). - For each resignation/appointment section, replace the WHOLE paragraph (heading + body) with "(None this meeting.)" when the corresponding director list is empty. Docx structure (master docx): - paragraphs[0..7]: header / addressee / intro / §2 (heading) / §2 (resignation prose) - paragraphs[8..]: §3, §4 ORs, §5 Rather than relying on brittle position indices we use a HEADING DETECTION approach: walk paragraphs and find the "2. Resignations of Director" heading, then the "3. Appointment of Director" heading, then "Ordinary Resolution N" markers. Each section's body paragraphs are the ones between its heading and the next heading. """ resigning = _resigning_label(ctx) # "None this meeting" if empty appointed = _newly_appointed_label(ctx) if resigning != "None this meeting" and appointed != "None this meeting": return # Both populated — nothing to neutralise. empty_placeholder = "(None this meeting.)" # Headings that identify each section we care about. We rewrite the # whole paragraph (including its heading) so the visual structure # of the document collapses cleanly. resignation_headings = ["2 . Resignations of Director", "2. Resignations of Director"] appointment_headings = ["3 . Appointment of Director", "3. Appointment of Director"] # ORs to rewrite by ordinal. The master docx has OR1, OR2 (resignation), # OR3, OR4 (appointment). We rewrite each OR pair when its # corresponding list is empty. resignation_or_headings = ["Ordinary Resolution 1", "Ordinary Resolution 2"] appointment_or_headings = ["Ordinary Resolution 3", "Ordinary Resolution 4"] # When NO new directors were appointed we don't want a dangling # "3. Appointment of Director(s): (None this meeting.)" heading in a # formal minutes document. Drop §3 + OR3 + OR4 and renumber so the letter # reads 1, 2, 3 (Ordinary Resolutions), 4 (Close) with no gap. # # This MUST run BEFORE the collapse pass below: the collapse rewrites each # section's heading to "(None this meeting.)", after which the heading is # no longer findable and the paragraphs can never be removed. if appointed == "None this meeting": _drop_appointment_sections(doc) paragraphs = doc.paragraphs # Build a list of (index, kind) where kind is "resign" / "appoint" / # "ordinary" / "or_resign" / "or_appoint". §4 "Ordinary Resolutions" is ALSO # a boundary — without it the §2 range ran all the way to OR1 and wiped the # ordinary-resolutions heading plus its "proposed, seconded and passed" # intro line, so the letter silently lost that header. It may read "3." by # now if §3 was dropped and the document renumbered above. ordinary_heading = [ "4 . Ordinary Resolutions", "4. Ordinary Resolutions", "3 . Ordinary Resolutions", "3. Ordinary Resolutions", ] close_headings = ["5. Close", "5 . Close", "4. Close", "4 . Close"] section_starts = [] close_idx = len(paragraphs) for i, p in enumerate(paragraphs): full = "".join(r.text for r in p.runs) if any(full.startswith(h) for h in resignation_headings): section_starts.append((i, "resign")) elif any(full.startswith(h) for h in appointment_headings): section_starts.append((i, "appoint")) elif any(full.startswith(h) for h in ordinary_heading): section_starts.append((i, "ordinary")) elif any(full.startswith(h) for h in resignation_or_headings): section_starts.append((i, "or_resign")) elif any(full.startswith(h) for h in appointment_or_headings): section_starts.append((i, "or_appoint")) elif any(full.startswith(h) for h in close_headings): close_idx = i break # §4 is a boundary but is never itself neutralised — it always keeps OR1 # and/or OR3 content. for idx, (start, kind) in enumerate(section_starts): if kind == "ordinary": continue if kind in ("resign", "or_resign") and resigning != "None this meeting": continue if kind in ("appoint", "or_appoint") and appointed != "None this meeting": continue # End of this section = next section_start index in section_starts, # or close_idx (start of §5), whichever comes first. end = len(paragraphs) if idx + 1 < len(section_starts): end = min(end, section_starts[idx + 1][0]) end = min(end, close_idx) # Rewrite the first paragraph in the range to "(None this meeting.)" # and clear the rest. if start < len(paragraphs): target_p = paragraphs[start] if target_p.runs: target_p.runs[0].text = empty_placeholder for r in target_p.runs[1:]: r.text = "" for j in range(start + 1, end): if j >= len(paragraphs): break p = paragraphs[j] if p.runs: for r in p.runs: r.text = "" def _title_case(text): """'SUNE PIETERS' -> 'Sune Pieters'. Director names are stored SHOUTED in PocketBase. Shouting every attendee in a formal minutes document reads badly, so the attendee block is rendered in normal title case. Hyphenated and apostrophied names keep their separators. """ words = (text or "").split() return " ".join(w[:1].upper() + w[1:].lower() for w in words) # Role wording for the attendee block. Kept separate from ROLE_LEGAL_LABEL # (ALL CAPS) because that one feeds the [POSITION/...] placeholder in body # prose, whereas the attendee list is a readable table. ROLE_ATTENDEE_LABEL = { "resigned": "Director Resigned", "appointed": "Director Appointed", "existing": "Director", "external": "Guest", } def _attendee_rows(attendees): """(name, role) per attendee, with names title-cased and no email column.""" rows = [] for a in attendees: name = _title_case(f"{a.get('name') or ''} {a.get('surname') or ''}".strip()) if not name: continue role = ROLE_ATTENDEE_LABEL.get( _role_key(a.get("director_role")), _title_case(a.get("director_role")) or "Director", ) rows.append((name, role)) return rows def _first_paragraph_starting(doc, prefix): """The first body paragraph whose text starts with `prefix` (case-insensitive).""" low = prefix.lower() for p in doc.paragraphs: if "".join(r.text for r in p.runs).strip().lower().startswith(low): return p return None def _rewrite_paragraph(p, text): """Collapse a single-line paragraph into `text`, keeping the first run's look.""" if not p.runs: p.add_run(text) return p.runs[0].text = text for r in p.runs[1:]: r.text = "" def _format_held_at(doc): """Tidy the "HELD AT:" line. Two problems in the master template: 1. It reads "HELD AT: COMPANY ADDRESS
". "COMPANY ADDRESS" is a leftover label from the template author — it is not part of any address and the operator never wants it in the finished letter. Strip it. 2. The address is stored inconsistently cased ("138, RIETVLEI, RUSTENBURG, North West, 0299"). Force the whole line to capitals so it reads as a single heading, matching "ON THE:" beneath it. """ p = _first_paragraph_starting(doc, "HELD AT") if p is None: return joined = "".join(r.text for r in p.runs) joined = re.sub(r"\s*COMPANY\s+ADDRESS\s*", " ", joined, flags=re.IGNORECASE) # Upper-case only what follows the "HELD AT:" label, so the label keeps its # own casing if the template ever changes it. m = re.match(r"^(HELD\s+AT\s*:)(.*)$", joined, flags=re.IGNORECASE | re.DOTALL) if m: joined = m.group(1).upper() + " " + m.group(2).strip().upper() else: joined = joined.upper() _rewrite_paragraph(p, joined) def _fix_signed_at_date(doc): """Collapse the duplicated date in the sign-off line. The template reads: Signed at [CITY] on this [DATE] day of [DATE] which renders the same date twice: "Signed at RUSTENBURG on this 9 October 2026 day of 9 October 2026" Only collapsed when both halves are actually the same date, so a template that genuinely wants two different dates is left alone. """ p = _first_paragraph_starting(doc, "Signed at") if p is None: return joined = "".join(r.text for r in p.runs) m = re.match( r"^(.*?)\s*on\s*this\s+(.+?)\s+day\s+of\s+(.+?)\s*$", joined.strip(), flags=re.IGNORECASE | re.DOTALL, ) if not m: return if m.group(2).strip().lower() != m.group(3).strip().lower(): return # two different dates — leave the template's wording alone _rewrite_paragraph(p, f"{m.group(1).strip()} on {m.group(2).strip()}") def _fill_first_attendee_block(doc, attendees): """Replace paragraph index 8 with one neatly aligned row per attendee. Layout: "Sune PietersDirector Resigned" The role column is aligned with a REAL left tab stop, not with space padding. The letterhead font is proportional, so padding to a character count does NOT line the roles up — a 12-character name and an 18-character name occupy very different widths. A tab stop is what Word actually aligns on. """ if not attendees or len(doc.paragraphs) <= 8: return target = doc.paragraphs[8] rows = _attendee_rows(attendees) if not rows: return # Where the role column starts. Measured from the left margin, so it is # independent of how long any individual name is. ROLE_COL = Inches(2.3) orig_size = target.runs[0].font.size if target.runs else None orig_name = target.runs[0].font.name if target.runs else None orig_color = None if target.runs and target.runs[0].font.color: orig_color = target.runs[0].font.color.rgb # Build new elements in REVERSE order, each added AFTER target. # addnext() appends, so iterating in reverse gives us correct final order. inserted = [] for _a in reversed(rows): np_elem = deepcopy(target._element) for r in np_elem.findall(qn("w:r")): t = r.find(qn("w:t")) if t is not None: t.text = "" target._element.addnext(np_elem) inserted.append(np_elem) inserted.reverse() # now in attendees[] order for i, (name, role) in enumerate(rows): para = Paragraph(inserted[i], doc.part) para.text = "" # Replace whatever tab stops the template paragraph carried with a # single explicit left stop, so every row's role lines up. para.paragraph_format.tab_stops.clear_all() para.paragraph_format.tab_stops.add_tab_stop( ROLE_COL, WD_TAB_ALIGNMENT.LEFT ) run = para.add_run(f"{name}\t{role}") if orig_size: run.font.size = orig_size if orig_name: run.font.name = orig_name if orig_color: run.font.color.rgb = orig_color # REMOVE the original empty paragraph from the body (so we don't get # a stray empty bullet in the rendered output) target._element.getparent().remove(target._element) def _person_name(person): """'Name Surname', title-cased. The ID is NOT appended here — it gets its own line under the name in the signature block.""" return _title_case(f"{person.get('name') or ''} {person.get('surname') or ''}".strip()) def _labelled(value, label): """'ID - 9810230091087', or '' when there's no value.""" v = (value or "").strip() return f"{label} - {v}" if v else "" def _signature_people(ctx): """(name, id_line, cell_line, email_line) for each signature slot. The template has a fixed number of `ATTENDEE [NAME AND SURNAME ID]` slots but the meeting may have a different number of attendees. Two cases: * someone has already signed → use the signed signers (the ones whose signature image is being embedded above the line). * nobody has signed yet → use the attendees who MUST sign. This is the normal state for a letter being emailed out for approval. Each person renders as: Name Surname ID - 9810230091087 Cell - 0827985409 Email - sj@dyfins.com A missing value simply drops its line rather than leaving a blank. """ signed = [s for s in ctx.get("signers", []) if s.get("status") == "signed"] if signed: pool = [s.get("attendee") or {} for s in signed] else: attendees = ctx.get("attendees", []) must_sign = [a for a in attendees if a.get("must_sign", True)] pool = must_sign or attendees out = [] seen = set() for a in pool: name = _person_name(a) if not name or name in seen: continue seen.add(name) out.append( ( name, _labelled(a.get("director_id_number"), "ID"), _labelled(a.get("telephone"), "Cell"), _labelled(a.get("email"), "Email"), ) ) return out def _insert_detail_line(after_p, doc, text): """Add a smaller sub-line directly beneath `after_p`. Cloned from the source paragraph so it inherits the font and colour, then scaled to 85% so it reads as a detail under the name rather than a heading. Returns the new element. """ new_p = deepcopy(after_p._element) for r in new_p.findall(qn("w:r")): t = r.find(qn("w:t")) if t is not None: t.text = "" after_p._element.addnext(new_p) para = Paragraph(new_p, doc.part) para.text = "" run = para.add_run(text) if after_p.runs and after_p.runs[0].font.size: run.font.size = Pt(int(after_p.runs[0].font.size.pt * 0.85)) if after_p.runs and after_p.runs[0].font.name: run.font.name = after_p.runs[0].font.name return new_p def _fill_attendee_lines(doc, ctx): """Fill the template's `ATTENDEE [NAME AND SURNAME ID]` slots. Each slot becomes: Name Surname ID - 9810230091087 Cell - 0827985409 Email - sj@dyfins.com """ people = _signature_people(ctx) slot_idx = 0 for p in list(doc.paragraphs): if "ATTENDEE [NAME AND SURNAME ID]" not in p.text: continue name, id_line, cell_line, email_line = ( people[slot_idx] if slot_idx < len(people) else ("—", "", "", "") ) # Just the name — the template's "ATTENDEE" label is dropped so the # line reads "Sune Pieters", not "ATTENDEE Sune Pieters". replaced = False for run in p.runs: if "ATTENDEE [NAME AND SURNAME ID]" in run.text: run.text = run.text.replace("ATTENDEE [NAME AND SURNAME ID]", name) replaced = True if not replaced: joined = "".join(r.text for r in p.runs).replace( "ATTENDEE [NAME AND SURNAME ID]", name ) p.runs[0].text = joined for r in p.runs[1:]: r.text = "" # Chain each detail line off the previous one so the order stays # name → ID → Cell → Email. addnext() inserts directly after the given # element, so inserting them all relative to `p` would reverse them. cursor = p for detail in (id_line, cell_line, email_line): if not detail: continue cursor = Paragraph( _insert_detail_line(cursor, doc, detail), doc.part ) slot_idx += 1 def _embed_signatures(doc, signers, workdir): """Insert signature images ABOVE the underscore lines. Strategy: walk the body element directly and find with text that is ONLY underscores, then prepend a new holding the signature image. """ from docx.oxml.ns import qn from docx.text.paragraph import Paragraph from copy import deepcopy from docx.shared import Inches body = doc.element.body signed = [s for s in signers if s["status"] == "signed" and s.get("signature_path")] slot_idx = 0 # Iterate over a snapshot of children to avoid mutation issues paragraphs = list(body.findall(qn("w:p"))) for p_elem in paragraphs: text_runs = p_elem.findall(qn("w:r")) text = "".join( (t.text or "") for r in text_runs for t in r.findall(qn("w:t")) ).strip() if not text or not all(c == "_" for c in text): continue if slot_idx >= len(signed): break sig_path = signed[slot_idx]["signature_path"] slot_idx += 1 if not os.path.exists(sig_path): continue # Create a new cloned from this one, insert before new_p = deepcopy(p_elem) for r in new_p.findall(qn("w:r")): t = r.find(qn("w:t")) if t is not None: t.text = "" p_elem.addprevious(new_p) # Wrap as Paragraph and add the image np = Paragraph(new_p, doc.part) np.text = "" run = np.add_run() run.add_picture(sig_path, width=Inches(2.0)) # ─── Main ──────────────────────────────────────────────────────────────── def main(): if len(sys.argv) != 5: print( "usage: subst.py ", file=sys.stderr, ) sys.exit(2) template_path = sys.argv[1] output_path = sys.argv[2] context_path = sys.argv[3] workdir = sys.argv[4] with open(context_path) as f: ctx = json.load(f) doc = Document(template_path) placeholders = _build_placeholders(ctx) # Pre-pass: Section 3 uses the same [director name surname and id] # token as Section 2 but must map to the NEWLY APPOINTED director. _fix_section3_director_token(doc, ctx) # Pre-pass: collapse §2 / §3 / OR1..OR4 paragraphs to "None this # meeting." when the resolution has no resignations and/or no # appointments on this meeting. _neutralise_empty_sections(doc, ctx) _walk(doc, placeholders) _fill_first_attendee_block(doc, ctx["attendees"]) _fill_attendee_lines(doc, ctx) _embed_signatures(doc, ctx["signers"], workdir) # Cosmetic clean-up of two template lines. _format_held_at(doc) _fix_signed_at_date(doc) doc.save(output_path) print(f"OK {output_path}") if __name__ == "__main__": main()