Files
Rpa_Skills/skills/rpa-bot-handoff/templates/to_docx.py
T
VasanthandClaude Opus 5.5 36e621b153 rpa-bot-handoff: short skill, MEK SOP as the model
The skill itself read as too detailed. SKILL.md is now a short checklist
(460 words, was 1,321): 5 steps, 5 rules, and 6 one-line "don't miss"
checks that replace the 12 Tebra failure classes (failure-classes.md
removed). The MEK Charge Reconciliation SOP - the 3-page PDF ops liked -
replaces the Tebra example as the model (emails swapped for examples).
New test keeps SKILL.md under 500 words with 6 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:28:23 +05:30

162 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Turn the Handover SOP (HANDOVER-SOP.md) into a Word document, and a PDF, ops can open, print and pass around.
python to_docx.py HANDOVER-SOP.md # writes HANDOVER-SOP.docx next to it
python to_docx.py HANDOVER-SOP.md -o "<Project Name> Handover SOP.docx" --pdf # + the .pdf next to it
python to_docx.py --selfcheck
Prints the word count (at most MAX_WORDS) and, with --pdf, the real page count (at most MAX_PAGES).
The PDF is made by Word on Windows (through PowerShell) or by LibreOffice (soffice) elsewhere.
Handles what the template uses: # headings, paragraphs, - bullets, - [ ] checkboxes, 1. steps,
| tables |, ``` blocks, **bold**, *italic* and `code`. Needs python-docx (pip install python-docx).
"""
import argparse
import re
import subprocess
import sys
import tempfile
from pathlib import Path
from docx import Document
from docx.shared import Cm, Pt
MAX_WORDS = 1400 # about 4 pages with the margins and table font below
MAX_PAGES = 4
INLINE = re.compile(r"(\*\*[^*]+\*\*|\*[^*\s][^*]*\*|`[^`]+`)")
def add_runs(par, text):
for part in INLINE.split(text):
if part.startswith("**") and part.endswith("**"):
par.add_run(part[2:-2]).bold = True
elif len(part) > 2 and part.startswith("*") and part.endswith("*"):
par.add_run(part[1:-1]).italic = True
elif part.startswith("`") and part.endswith("`"):
par.add_run(part[1:-1]).font.name = "Consolas"
elif part:
par.add_run(part)
def cells(line):
return [c.strip() for c in line.strip().strip("|").split("|")]
def convert(md_text, out_path):
doc = Document()
doc.styles["Normal"].font.name = "Calibri"
doc.styles["Normal"].font.size = Pt(10.5)
for section in doc.sections: # Word's 2.5 cm default margins push a 4-page SOP onto 5
section.left_margin = section.right_margin = Cm(1.8)
section.top_margin = section.bottom_margin = Cm(1.6)
lines = md_text.splitlines()
i = 0
while i < len(lines):
line = lines[i]
if line.startswith("```"):
block = []
i += 1
while i < len(lines) and not lines[i].startswith("```"):
block.append(lines[i])
i += 1
run = doc.add_paragraph().add_run("\n".join(block))
run.font.name = "Consolas"
run.font.size = Pt(9.5)
elif line.startswith("|"):
rows = []
while i < len(lines) and lines[i].startswith("|"):
if not re.match(r"^\|[\s:|-]+\|\s*$", lines[i]): # skip the |---| separator
rows.append(cells(lines[i]))
i += 1
i -= 1
width = max(len(r) for r in rows)
table = doc.add_table(rows=len(rows), cols=width)
table.style = "Table Grid"
for r, row in enumerate(rows):
for c, text in enumerate(row):
par = table.cell(r, c).paragraphs[0]
add_runs(par, text)
for run in par.runs:
run.font.size = Pt(9.5)
if r == 0:
for run in par.runs:
run.bold = True
elif m := re.match(r"^(#{1,3})\s+(.*)", line):
doc.add_heading(m[2], level=len(m[1]) - 1 if len(m[1]) > 1 else 0)
elif m := re.match(r"^\s*- \[ \]\s+(.*)", line):
add_runs(doc.add_paragraph(style="List Bullet"), "☐ " + m[1])
elif m := re.match(r"^\s*[-*]\s+(.*)", line):
add_runs(doc.add_paragraph(style="List Bullet"), m[1])
elif m := re.match(r"^\s*\d+\.\s+(.*)", line):
add_runs(doc.add_paragraph(style="List Number"), m[1])
elif line.strip() and line.strip() != "---":
add_runs(doc.add_paragraph(), line.strip())
i += 1
doc.save(out_path)
return doc
def words(md_text):
return len(re.findall(r"[A-Za-z0-9][\w'’.-]*", re.sub(r"^\|[\s:|-]+\|\s*$", "", md_text, flags=re.M)))
def to_pdf(docx_path):
"""Write the PDF next to the docx. Returns (pdf path, page count or None); pdf path is None on failure."""
docx_path = Path(docx_path).resolve()
pdf = docx_path.with_suffix(".pdf")
pages = None
if sys.platform == "win32":
q = lambda p: str(p).replace("'", "''") # PowerShell single-quote escape
ps = (f"$w = New-Object -ComObject Word.Application; $w.Visible = $false; "
f"$d = $w.Documents.Open('{q(docx_path)}', $false, $true); "
f"Write-Output ('pages=' + $d.ComputeStatistics(2)); $d.SaveAs2('{q(pdf)}', 17); "
f"try {{ $d.Close(0) }} catch {{}}; try {{ $w.Quit() }} catch {{}}")
r = subprocess.run(["powershell", "-NoProfile", "-Command", ps], capture_output=True, text=True)
if m := re.search(r"pages=(\d+)", r.stdout):
pages = int(m[1])
else:
subprocess.run(["soffice", "--headless", "--convert-to", "pdf", "--outdir", str(pdf.parent), str(docx_path)],
capture_output=True)
return (pdf if pdf.exists() else None), pages
def selfcheck():
example = Path(__file__).parent / "reference" / "mek-charge-reconciliation.md"
sop = "# " + example.read_text(encoding="utf-8").split("\n---\n", 1)[1].split("\n# ", 1)[1]
out = Path(tempfile.mkdtemp()) / "HANDOVER-SOP.docx"
convert(sop, out)
doc = Document(out)
heads = [p.text for p in doc.paragraphs if p.style.name.startswith("Heading")]
assert doc.paragraphs[0].text.endswith("Handover SOP"), doc.paragraphs[0].text
assert [h.split(".")[0] for h in heads if h[:1].isdigit()] == [str(n) for n in range(1, 6)], heads
assert len(doc.tables) >= 4, len(doc.tables)
assert doc.tables[0].cell(0, 0).text == "Where on the screen", doc.tables[0].cell(0, 0).text
assert not any("**" in p.text or "|---" in p.text for p in doc.paragraphs)
assert words(sop) <= MAX_WORDS, words(sop)
print(f"selfcheck ok: {len(heads)} headings, {len(doc.tables)} tables, {words(sop)} words -> {out}")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("md", nargs="?")
ap.add_argument("-o", "--out")
ap.add_argument("--pdf", action="store_true", help="also write the PDF next to the docx")
ap.add_argument("--selfcheck", action="store_true")
a = ap.parse_args()
if a.selfcheck:
selfcheck()
sys.exit(0)
if not a.md:
ap.error("give the HANDOVER.md path, or --selfcheck")
src = Path(a.md)
out = Path(a.out) if a.out else src.with_suffix(".docx")
text = src.read_text(encoding="utf-8")
convert(text, out)
n = words(text)
print(f"wrote {out} ({n} words{'' if n <= MAX_WORDS else f', OVER the {MAX_WORDS}-word limit: cut it'})")
if a.pdf:
pdf, pages = to_pdf(out)
if not pdf:
sys.exit("no PDF: needs Word (Windows) or LibreOffice (soffice). Hand over the docx instead.")
over = pages and pages > MAX_PAGES
print(f"wrote {pdf} ({pages or '?'} pages{f', OVER the {MAX_PAGES}-page limit: cut it' if over else ''})")