#!/usr/bin/env python3 """Convert a Markdown file to a professionally styled Word document (.docx).""" import argparse import os import re import shutil import subprocess import sys import tempfile # --- Colour palette --- DARK = (0x1A, 0x1A, 0x2E) # Near-black navy (H1, title) ACCENT = (0x1B, 0x4D, 0x89) # Professional blue (H2, H3, links) BODY = (0x33, 0x33, 0x33) # Dark grey (body text) HEADER_BG = "1B4D89" LIGHT_BG = "F2F6FA" def check_dependencies(): """Check that pandoc and python-docx are available. Returns (pandoc_version, docx_version) or exits.""" pandoc_path = shutil.which("pandoc") if not pandoc_path: print("Error: pandoc not found. Install with: sudo apt install pandoc", file=sys.stderr) sys.exit(1) result = subprocess.run([pandoc_path, "--version"], capture_output=True, text=True) pandoc_version = result.stdout.split("\n")[0].replace("pandoc ", "") try: import docx docx_version = docx.__version__ except ImportError: print("Error: python-docx not installed. Install with: pip install python-docx", file=sys.stderr) sys.exit(1) return pandoc_version, docx_version def extract_title(md_path): """Extract the first # heading from a markdown file.""" with open(md_path, "r") as f: for line in f: match = re.match(r"^#\s+(.+)$", line.strip()) if match: return match.group(1) return os.path.splitext(os.path.basename(md_path))[0] def create_styled_reference(output_path): """Generate a pandoc reference.docx with professional styling applied.""" from docx import Document from docx.shared import Pt, Cm, RGBColor from docx.enum.text import WD_ALIGN_PARAGRAPH # Extract pandoc's default reference doc subprocess.run( ["pandoc", "-o", output_path, "--print-default-data-file", "reference.docx"], capture_output=True, check=True, ) doc = Document(output_path) for section in doc.sections: section.top_margin = Cm(2.5) section.bottom_margin = Cm(2.5) section.left_margin = Cm(2.5) section.right_margin = Cm(2.5) style_defs = { "Title": {"size": Pt(28), "bold": True, "color": DARK, "space_after": Pt(6), "alignment": WD_ALIGN_PARAGRAPH.LEFT}, "Subtitle": {"size": Pt(14), "bold": False, "color": ACCENT, "space_after": Pt(24)}, "Heading 1": {"size": Pt(22), "bold": True, "color": DARK, "space_before": Pt(36), "space_after": Pt(12)}, "Heading 2": {"size": Pt(16), "bold": True, "color": ACCENT, "space_before": Pt(24), "space_after": Pt(8)}, "Heading 3": {"size": Pt(13), "bold": True, "color": ACCENT, "space_before": Pt(18), "space_after": Pt(6)}, "Normal": {"size": Pt(11), "bold": False, "color": BODY, "space_after": Pt(8), "line_spacing": Pt(16)}, "Body Text": {"size": Pt(11), "bold": False, "color": BODY, "space_after": Pt(8), "line_spacing": Pt(16)}, "First Paragraph": {"size": Pt(11), "bold": False, "color": BODY, "space_after": Pt(8), "line_spacing": Pt(16)}, "Block Text": {"size": Pt(10), "bold": False, "color": BODY}, "List Paragraph": {"size": Pt(11), "color": BODY}, "Compact": {"size": Pt(11), "color": BODY}, } for style_name, props in style_defs.items(): try: style = doc.styles[style_name] except KeyError: continue style.font.name = "Calibri" if "size" in props: style.font.size = props["size"] if "bold" in props: style.font.bold = props["bold"] if "color" in props: style.font.color.rgb = RGBColor(*props["color"]) pf = style.paragraph_format for attr in ("space_before", "space_after", "line_spacing"): if attr in props: setattr(pf, attr, props[attr]) if "alignment" in props: pf.alignment = props["alignment"] try: hl = doc.styles["Hyperlink"] hl.font.color.rgb = RGBColor(*ACCENT) hl.font.underline = True except KeyError: pass doc.save(output_path) def postprocess_docx(docx_path): """Apply table styling, page breaks, and font normalization.""" from docx import Document from docx.shared import Pt, RGBColor from docx.enum.table import WD_TABLE_ALIGNMENT from docx.oxml.ns import nsdecls, qn from docx.oxml import parse_xml doc = Document(docx_path) # Style tables for table in doc.tables: table.alignment = WD_TABLE_ALIGNMENT.CENTER if not table.rows: continue # Header row for cell in table.rows[0].cells: shading = parse_xml(f'') cell._tc.get_or_add_tcPr().append(shading) for paragraph in cell.paragraphs: for run in paragraph.runs: run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) run.font.bold = True run.font.size = Pt(10) run.font.name = "Calibri" # Data rows with alternating shading for i, row in enumerate(table.rows[1:], 1): for cell in row.cells: if i % 2 == 0: shading = parse_xml(f'') cell._tc.get_or_add_tcPr().append(shading) for paragraph in cell.paragraphs: for run in paragraph.runs: run.font.size = Pt(10) run.font.name = "Calibri" # Table borders tbl_pr = table._tbl.tblPr if tbl_pr is None: tbl_pr = parse_xml(f'') table._tbl.insert(0, tbl_pr) existing = tbl_pr.find(qn("w:tblBorders")) if existing is not None: tbl_pr.remove(existing) borders = parse_xml( f'' ' ' ' ' ' ' ' ' ' ' ' ' "" ) tbl_pr.append(borders) # Page break before "References" heading for para in doc.paragraphs: if para.text.strip() == "References" and para.style.name.startswith("Heading"): pPr = para._p.get_or_add_pPr() pPr.append(parse_xml(f'')) break # Ensure all runs have Calibri for para in doc.paragraphs: for run in para.runs: if run.font.name is None: run.font.name = "Calibri" doc.save(docx_path) def main(): parser = argparse.ArgumentParser( description="Convert a Markdown file to a professionally styled Word document (.docx).", prog="md-to-docx", ) parser.add_argument("input", help="Path to the Markdown file to convert") parser.add_argument("-o", "--output", help="Output file path (default: .docx)") parser.add_argument("-t", "--title", help="Override document title") parser.add_argument("--toc", action="store_true", help="Include a table of contents") parser.add_argument("--toc-depth", type=int, default=2, choices=[1, 2, 3], help="TOC depth (default: 2)") parser.add_argument("-n", "--dryrun", action="store_true", help="Preview without creating files") args = parser.parse_args() # Validate input if not os.path.isfile(args.input): print(f"Error: file not found: {args.input}", file=sys.stderr) sys.exit(1) input_path = os.path.abspath(args.input) if args.output: output_path = os.path.abspath(args.output) else: output_path = os.path.splitext(input_path)[0] + ".docx" title = args.title or extract_title(input_path) pandoc_version, docx_version = check_dependencies() if args.dryrun: print(f"[dryrun] Input: {input_path}") print(f"[dryrun] Output: {output_path}") print(f"[dryrun] Title: {title}") toc_str = f"yes (depth: {args.toc_depth})" if args.toc else "no" print(f"[dryrun] TOC: {toc_str}") print(f"[dryrun] Dependencies OK: pandoc {pandoc_version}, python-docx {docx_version}") print("[dryrun] Would generate styled .docx file") return # Create styled reference doc in a temp directory tmpdir = tempfile.mkdtemp(prefix="md-to-docx-") ref_path = os.path.join(tmpdir, "reference.docx") try: create_styled_reference(ref_path) # Build pandoc command cmd = [ "pandoc", input_path, "-o", output_path, f"--reference-doc={ref_path}", "--metadata", f"title={title}", "-f", "markdown+pipe_tables+inline_notes", "--columns=72", ] if args.toc: cmd.extend(["--toc", f"--toc-depth={args.toc_depth}"]) result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: print(f"Error: pandoc failed:\n{result.stderr}", file=sys.stderr) sys.exit(1) postprocess_docx(output_path) print(output_path) finally: shutil.rmtree(tmpdir, ignore_errors=True) if __name__ == "__main__": main()