"""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 " 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 ''})")