Files
Rpa_Skills/skills/rpa-bot-handoff/templates/to_docx.py
T
VasanthandClaude Opus 5.5 b35796e1a5 rpa-bot-handoff: one 54-line SKILL.md, no reference or template files
The skill is now one SKILL.md (54 lines) with the SOP shape inline, plus
templates/to_docx.py for the Word and PDF. Removed: the template, the MEK
reference example and the skill README. The converter's selfcheck uses a
built-in sample. Tests now check the skill is that one short file, has the
6 don't-miss checks, and its SOP shape has the 5 sections and To/CC lines.

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

196 lines
7.5 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
SAMPLE = """# Sample Bot Handover SOP
Version 1.0 · Problems: email dev@example.com, CC manager@example.com (section 5)
## 1. How to run
1. Open **Sample Bot** and click **Run**.
## 2. What you see while it runs
| Where on the screen | What it shows |
|---|---|
| Status | "Running…" then "Finished" |
## 3. Result
| Status | Meaning | What you do |
|---|---|---|
| Done | Posted | Nothing |
## 4. When something goes wrong
| ID | Message you see | What it means | What you do |
|---|---|---|---|
| E01 | "Couldn't open *Item*" | The app was slow | Run again |
## 5. Email us
```
To: dev@example.com
CC: manager@example.com
```
"""
def selfcheck():
sop = SAMPLE
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) == 3, 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 ''})")