191 lines
7.3 KiB
Python
191 lines
7.3 KiB
Python
|
|
#!/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("&", "&")
|
||
|
|
.replace("<", "<")
|
||
|
|
.replace(">", ">")
|
||
|
|
)
|
||
|
|
# 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())
|