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>
196 lines
7.5 KiB
Python
196 lines
7.5 KiB
Python
"""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 ''})")
|