"""Turn the ops guide (HANDOVER.md) into a Word document ops can open, print and pass around. python to_docx.py HANDOVER.md # writes HANDOVER.docx next to it python to_docx.py HANDOVER.md -o guide.docx python to_docx.py --selfcheck Handles what the template uses: # headings, paragraphs, - bullets, - [ ] checkboxes, 1. steps, | tables |, ``` blocks, **bold** and `code`. Needs python-docx (pip install python-docx). """ import argparse import re import sys import tempfile from pathlib import Path from docx import Document from docx.shared import Pt INLINE = re.compile(r"(\*\*[^*]+\*\*|`[^`]+`)") 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 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) 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) 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 selfcheck(): example = Path(__file__).parent / "reference" / "tebra-auto-post.md" guide = example.read_text(encoding="utf-8").split("# File 2:")[0].split("# File 1: `HANDOVER.md`")[1] out = Path(tempfile.mkdtemp()) / "HANDOVER.docx" convert(guide, out) doc = Document(out) heads = [p.text for p in doc.paragraphs if p.style.name.startswith("Heading")] assert [h.split(".")[0] for h in heads if h[:1].isdigit()] == [str(n) for n in range(1, 12)], heads assert len(doc.tables) >= 6, len(doc.tables) assert doc.tables[0].cell(0, 0).text == "Where", doc.tables[0].cell(0, 0).text assert not any("**" in p.text or "|---" in p.text for p in doc.paragraphs) print(f"selfcheck ok: {len(heads)} headings, {len(doc.tables)} tables -> {out}") if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument("md", nargs="?") ap.add_argument("-o", "--out") 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") convert(src.read_text(encoding="utf-8"), out) print(f"wrote {out}")