Workflow-App/Workflow/scripts/build_resolution_master.py

191 lines
7.3 KiB
Python
Raw Normal View History

#!/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 <w:r> 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
<w:t> 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"<w:p\b[^>]*>(.*?)</w:p>", re.DOTALL
)
# Match each <w:r ...> ... </w:r> block within a paragraph.
RUN_RE = re.compile(r"<w:r\b[^>]*>(.*?)</w:r>", re.DOTALL)
# Within a run, the visible text is wrapped in <w:t>...</w:t>.
WT_RE = re.compile(r"<w:t[^>]*>(.*?)</w:t>", re.DOTALL)
def patch_xml(xml: str) -> tuple[str, dict[str, int]]:
"""Strategy:
1. Walk every <w:p>...</w:p>.
2. Build a list of (wt_position_in_xml, text_content) for every <w:t> 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 <w:t> elements by walking the XML.
# Note: w:t can have attributes like xml:space="preserve".
wt_re = re.compile(r"(<w:t\b[^>]*>)(.*?)(</w:t>)", 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"<w:p\b[^>]*>.*?</w:p>", 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 = "</w:t>"
# 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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
# Ensure xml:space="preserve" so leading/trailing spaces render.
if "xml:space=" not in first_open:
open_with_space = first_open.replace(
"<w:t", '<w:t xml:space="preserve"', 1
)
else:
open_with_space = first_open
# Defensive: only one replacement of '<w_t' opening
if open_with_space.count("<w:t") > 1:
open_with_space = open_with_space.replace(
"<w:t", "<w_t", 1
).replace("<w_t", "<w:t", 1) # messy, just take first as-is
new_pieces.append(f"{open_with_space}{safe}{wt_close}")
else:
# Empty wt — keep element, just empty content.
# Reuse the original opening tag but with no content.
orig_open = w.group(1)
new_pieces.append(f"{orig_open}{wt_close}")
cursor = we
new_pieces.append(p_inner[cursor:])
return "".join(new_pieces)
new_xml = par_re.sub(process_p, xml)
return new_xml, counts
def main() -> 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())