#!/usr/bin/env python3 """ Build a working master docx for the Ysterbaard Director Change Resolution letter. Replaces the prior naive XML replace (which failed when text was split across multiple runs). Strategy: Read each paragraph's runs as joined text, perform string-level edits on the joined text, then if changed: write the new text into the first and clear the rest. Preserves all paragraph / character formatting. Other parts of the docx (styles, theme, images) are passed through byte-identical. Edits applied: 1. Header "COMPANY NAME" → "[COMPANY NAME]" so subst.py's [COMPANY NAME] substitution target finds it. The trailing " (PTY) LTD" stays where it was. 2. Strip the hardcoded name "Stephanus Gerhardus Barnard" from Ordinary Resolution 2 and replace with the same token used in Ordinary Resolution 1: [director that resigned name , surname and id] (with the extra space, matching the docx style). 3. Cosmetic typo fixes per the user's note that the typos in the old docx (POSSITIONS, RESIGEND, ADDITIOAN, SURANEM) are part of the legal artefact. Run: /Volumes/KIOXIA/.hermes/profiles/ysterbaard/APPS/Workflow/scripts/.venv/bin/python3 \\ /Volumes/KIOXIA/.hermes/profiles/ysterbaard/APPS/Workflow/scripts/build_resolution_master.py """ import re import sys import zipfile import shutil from pathlib import Path PATH = Path("/Volumes/KIOXIA/.hermes/profiles/ysterbaard/assets/templates/director_change_resolution_master.docx") # (find_text, replace_text). Case-sensitive. We do joined-text replacement # inside paragraphs, so multi-run text now works. JOINT_EDITS = [ # 1. Header placeholder ("COMPANY NAME", "[COMPANY NAME]"), # 2. Hardcoded name in OR2 — replace with same token used in OR1. ("Stephanus Gerhardus Barnard", "[director that resigned name , surname and id]"), # 3. Cosmetic typo fixes (per user: "you may fix spelling errors") ("POSSITIONS", "POSITION"), ("RESIGEND", "RESIGNED"), ("SURANEM", "SURNAME"), ("ADDITIOAN", "ADDITIONAL"), ] # Regex on the XML to find each paragraph's runs (in order) so we can # rewrite the joined text cleanly. PAR_RE = re.compile( r"]*>(.*?)", re.DOTALL ) # Match each ... block within a paragraph. RUN_RE = re.compile(r"]*>(.*?)", re.DOTALL) # Within a run, the visible text is wrapped in .... WT_RE = re.compile(r"]*>(.*?)", re.DOTALL) def patch_xml(xml: str) -> tuple[str, dict[str, int]]: """Strategy: 1. Walk every .... 2. Build a list of (wt_position_in_xml, text_content) for every in order. 3. Build the joined text from those. 4. Run JOINT_EDITS on the joined text. 5. If joined text changed, write the new joined text into the FIRST wt and clear all subsequent wts. 6. This loses formatting inside cleared runs — acceptable here since the docx sections we touch (header + OR2) carry uniform formatting inside the same paragraph already. Returns (patched_xml, counts).""" counts: dict[str, int] = {} # Build positions of elements by walking the XML. # Note: w:t can have attributes like xml:space="preserve". wt_re = re.compile(r"(]*>)(.*?)()", re.DOTALL) # We need to handle the WHOLE XML, not per-paragraph, because the joined # text might span across paragraphs (the user said cosmetic), but for our # targeted edits the relevant spans fit inside one paragraph. Let's still # do per-paragraph to avoid surprises — but treat all w:t inside one p # as one segment. par_re = re.compile(r"]*>.*?", re.DOTALL) def process_p(m: re.Match) -> str: p_inner = m.group(0) # extract all wt contents in order wts = list(wt_re.finditer(p_inner)) if not wts: return p_inner joined = "".join(w.group(2) for w in wts) new = joined for find, replace in JOINT_EDITS: if find in new: c = new.count(find) new = new.replace(find, replace) counts[f"{find!r} → {replace!r}"] = counts.get( f"{find!r} → {replace!r}", 0 ) + c if new == joined: return p_inner # Place new text in first wt, empty the rest. Preserve first wt attrs. first = wts[0] first_open = first.group(1) wt_close = "" # Rest of wts → empty new_pieces: list[str] = [] cursor = 0 # Find positions of each wt in p_inner for i, w in enumerate(wts): ws = w.start() we = w.end() new_pieces.append(p_inner[cursor:ws]) if i == 0: # Need to HTML-escape the new text (just & < >). safe = ( new.replace("&", "&") .replace("<", "<") .replace(">", ">") ) # Ensure xml:space="preserve" so leading/trailing spaces render. if "xml:space=" not in first_open: open_with_space = first_open.replace( " 1: open_with_space = open_with_space.replace( " int: if not PATH.exists(): print(f"FAIL: master not found at {PATH}") return 1 # Read xml with zipfile.ZipFile(PATH) as zin: xml = zin.read("word/document.xml").decode("utf-8", errors="ignore") namelist = zin.namelist() new_xml, counts = patch_xml(xml) print("Replacements made:") for k, v in counts.items(): print(f" [{v}x] {k}") if not counts: print(" (no replacements — re-run after restoring BAK or check patterns)") # Write back tmp = PATH.with_suffix(".tmp.docx") with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zout: # Copy other files first, then document.xml last so other files keep # their original timestamps/permissions. with zipfile.ZipFile(PATH) as zin: for item in zin.infolist(): if item.filename == "word/document.xml": continue zout.writestr(item, zin.read(item.filename)) zout.writestr("word/document.xml", new_xml.encode("utf-8")) shutil.move(tmp, PATH) print(f"\nwrote: {PATH} ({PATH.stat().st_size} B)") return 0 if __name__ == "__main__": sys.exit(main())