#!/usr/bin/env python3 """ Convert markdown files to DOCX (Microsoft Word). Usage: md2doc.py input.md # Creates input.docx in same directory md2doc.py input.md -o output.docx # Specify output file md2doc.py docs/ # Convert all .md files in directory md2doc.py docs/ -o docx_output/ md2doc.py input.md --style mono # Use monospace style Requires: pip install python-docx markdown beautifulsoup4 lxml """ import argparse import sys from pathlib import Path import markdown from bs4 import BeautifulSoup from docx import Document from docx.shared import Pt, RGBColor, Cm from docx.oxml.ns import qn from docx.oxml import OxmlElement STYLES = { "elegant": { "font": "Helvetica", "size": 11, "heading_color": "1a1a1a", "link_color": "2c2c2c", "table_header_fill": "e0e0e0", "code_fill": "f8f8f8", "blockquote_fill": "f8f8f8", }, "report": { "font": "Helvetica", "size": 10, "heading_color": "1e3a8a", "link_color": "1e3a8a", "table_header_fill": "1e3a8a", "code_fill": "f7f8fa", "blockquote_fill": "f7f8fa", }, "default": { "font": "Helvetica", "size": 10, "heading_color": "1a1a1a", "link_color": "0066cc", "table_header_fill": "f5f5f5", "code_fill": "f5f5f5", "blockquote_fill": "fafafa", }, "dark": { "font": "Helvetica", "size": 11, "heading_color": "818cf8", "link_color": "818cf8", "table_header_fill": "4f46e5", "code_fill": "1f2937", "blockquote_fill": "1f2937", }, "mono": { "font": "Menlo", "size": 10, "heading_color": "000000", "link_color": "000000", "table_header_fill": "f0f0f0", "code_fill": "f5f5f5", "blockquote_fill": "f5f5f5", }, } # Common rules applied to all styles via deep-merge. # Note: python-docx doesn't expose orphans/widows directly via API; # keep_with_next IS available via paragraph_format.keep_with_next = True. # page_break_before_h1 is per-style flag (False by default; can be overridden). COMMON = { "keep_with_next_headings": True, # applied to H1-H4 "page_break_before_h1": False, # can be overridden per style "table_cell_valign": "top", } def _bulletize(text: str) -> str: """Turn markdown "- " list markers into bullets, skipping code/HTML blocks. Copied verbatim from md2pdf.py per spec §3.3. """ import re out, in_fence, in_html = [], False, False for line in text.split("\n"): stripped = line.lstrip() if stripped.startswith("```") or stripped.startswith("~~~"): in_fence = not in_fence elif not in_fence: if re.match(r"^<(table|div|section|figure)\b", stripped, re.I): in_html = True elif re.match(r"^", stripped, re.I): in_html = False if not in_fence and not in_html and line.startswith("- "): line = "• " + line[2:] out.append(line) return "\n".join(out) # --------------------------------------------------------------------------- # XML manipulation helpers # Spec ref: docs/specs/2026-07-27-md2doc-design.md §4.1 — verified empirically # that python-docx 1.2 has no public API for borders/shading/numbering, so # we manipulate the OOXML directly via OxmlElement. # --------------------------------------------------------------------------- def add_paragraph_bottom_border(paragraph, color: str = "000000", size: str = "6"): """Attach a bottom border to a paragraph (e.g., Heading 1 underline). Spec §4.1 H1: SURVIVED — verified via XML inspection. """ pPr = paragraph._p.get_or_add_pPr() pBdr = pPr.find(qn('w:pBdr')) if pBdr is None: pBdr = OxmlElement('w:pBdr') pPr.append(pBdr) bottom = OxmlElement('w:bottom') bottom.set(qn('w:val'), 'single') bottom.set(qn('w:sz'), size) bottom.set(qn('w:space'), '1') bottom.set(qn('w:color'), color) pBdr.append(bottom) def add_paragraph_shading(paragraph, fill: str): """Apply background shading to a paragraph (code, blockquote). Spec §4.1 H2: SURVIVED. """ pPr = paragraph._p.get_or_add_pPr() shd = OxmlElement('w:shd') shd.set(qn('w:val'), 'clear') shd.set(qn('w:color'), 'auto') shd.set(qn('w:fill'), fill) pPr.append(shd) def add_cell_shading(cell, fill: str): """Apply background shading to a table cell (header row). Spec §4.1 H4: SURVIVED. Inserts w:shd inside w:tcPr. """ tcPr = cell._tc.get_or_add_tcPr() shd = OxmlElement('w:shd') shd.set(qn('w:val'), 'clear') shd.set(qn('w:color'), 'auto') shd.set(qn('w:fill'), fill) tcPr.append(shd) def register_multilevel_numbering(doc, levels: int = 3, kind: str = "bullet") -> int: """Register a multi-level numbering definition in the document. Returns the numId to reference from paragraphs. Spec §4.1 H3 Route B: SURVIVED at XML level. Mandatory because style-name approach ('List Bullet 2') produces flat lists in Word (single-level abstractNums in default template). Args: doc: python-docx Document levels: number of nesting levels to define (1=flat, 3=three levels deep) kind: 'bullet' or 'decimal' for numbered lists """ numbering = doc.part.numbering_part.element existing_abstract_ids = [ int(an.get(qn('w:abstractNumId'))) for an in numbering.findall(qn('w:abstractNum')) ] abstract_num_id = max(existing_abstract_ids, default=-1) + 1 abstract_num = OxmlElement('w:abstractNum') abstract_num.set(qn('w:abstractNumId'), str(abstract_num_id)) multi_level = OxmlElement('w:multiLevelType') multi_level.set(qn('w:val'), 'hybridMultilevel') abstract_num.append(multi_level) for ilvl in range(levels): lvl = OxmlElement('w:lvl') lvl.set(qn('w:ilvl'), str(ilvl)) lvl.set(qn('w:tplc'), '0409000F' if kind == "bullet" else '04070001') start = OxmlElement('w:start') start.set(qn('w:val'), '1') lvl.append(start) numFmt = OxmlElement('w:numFmt') numFmt.set(qn('w:val'), 'bullet' if kind == "bullet" else 'decimal') lvl.append(numFmt) lvl_text = OxmlElement('w:lvlText') lvl_text.set(qn('w:val'), '•' if kind == "bullet" else f'%{ilvl + 1}.') lvl.append(lvl_text) lvl_jc = OxmlElement('w:lvlJc') lvl_jc.set(qn('w:val'), 'left') lvl.append(lvl_jc) pPr = OxmlElement('w:pPr') ind = OxmlElement('w:ind') ind.set(qn('w:left'), str(720 * (ilvl + 1))) ind.set(qn('w:hanging'), '360') pPr.append(ind) lvl.append(pPr) abstract_num.append(lvl) numbering.insert(0, abstract_num) existing_num_ids = [ int(n.get(qn('w:numId'))) for n in numbering.findall(qn('w:num')) ] num_id = max(existing_num_ids, default=0) + 1 num = OxmlElement('w:num') num.set(qn('w:numId'), str(num_id)) abstract_ref = OxmlElement('w:abstractNumId') abstract_ref.set(qn('w:val'), str(abstract_num_id)) num.append(abstract_ref) numbering.append(num) return num_id def attach_list_numbering(paragraph, num_id: int, ilvl: int): """Attach numbering to a paragraph at the given indent level. Spec §4.1 H3 Route B. Must be paired with register_multilevel_numbering. """ pPr = paragraph._p.get_or_add_pPr() existing = pPr.find(qn('w:numPr')) if existing is not None: pPr.remove(existing) numPr = OxmlElement('w:numPr') ilvl_el = OxmlElement('w:ilvl') ilvl_el.set(qn('w:val'), str(ilvl)) numId_el = OxmlElement('w:numId') numId_el.set(qn('w:val'), str(num_id)) numPr.append(ilvl_el) numPr.append(numId_el) pPr.append(numPr) # --------------------------------------------------------------------------- # Element renderers (walk BeautifulSoup DOM → emit docx elements) # --------------------------------------------------------------------------- def _warn_skip_image(node): """Warn to stderr when an is encountered (out of scope per spec §10). Defined early so _render_paragraph (used in Task 4) and convert_md_to_doc (Task 7) can both reference it without forward-declaration issues. """ src = node.get("src", "(no src)") print(f"Warning: image skipped (out of scope): {src}", file=sys.stderr) def _add_run_shading(run, fill: str): """Apply background shading to a run (for inline highlight). Inserts w:shd inside w:rPr. """ rPr = run._r.get_or_add_rPr() shd = OxmlElement('w:shd') shd.set(qn('w:val'), 'clear') shd.set(qn('w:color'), 'auto') shd.set(qn('w:fill'), fill) rPr.append(shd) def _render_heading(doc, node, level: int, style: dict): """Render

-

as Word Heading paragraphs. H1 also gets a bottom paragraph border (per md2pdf pattern). Applies COMMON['keep_with_next_headings'] so headings don't orphan at page bottom. Applies COMMON['page_break_before_h1'] if True (style-overridable). """ text = node.get_text(strip=True) p = doc.add_heading(level=level) p.add_run(text) # Apply style-specific color color_hex = style.get("heading_color", "000000") for run in p.runs: run.font.color.rgb = RGBColor.from_string(color_hex) # Wire COMMON config: keep heading with next paragraph if COMMON.get("keep_with_next_headings", True): p.paragraph_format.keep_with_next = True # H1 page-break-before if style overrides COMMON default if level == 1 and style.get("page_break_before_h1", COMMON.get("page_break_before_h1", False)): p.paragraph_format.page_break_before = True if level == 1: add_paragraph_bottom_border(p, color=color_hex, size="12") def _render_paragraph(doc, node, style: dict): """Render

as a Word paragraph, preserving inline formatting (strong, em, code, a). Also handles children — prints stderr warning and skips (spec §10). """ p = doc.add_paragraph() code_fill = style.get("code_fill", "f5f5f5") def _add_runs(element): for child in element.children: if isinstance(child, str): p.add_run(child) elif child.name == "strong" or child.name == "b": r = p.add_run(child.get_text()) r.bold = True elif child.name == "em" or child.name == "i": r = p.add_run(child.get_text()) r.italic = True elif child.name == "code": r = p.add_run(child.get_text()) r.font.name = "Menlo" _add_run_shading(r, fill=code_fill) elif child.name == "a": r = p.add_run(child.get_text()) r.underline = True r.font.color.rgb = RGBColor.from_string(style.get("link_color", "0000EE")) elif child.name == "img": _warn_skip_image(child) else: # Unknown inline — recurse _add_runs(child) _add_runs(node) def convert_md_to_doc(md_file: Path, output_file: Path, style: str = "elegant") -> Path: """Convert a single markdown file to DOCX. Currently a stub — actual conversion logic in Task 4+. """ raise NotImplementedError("md2doc conversion not yet implemented") def main(): parser = argparse.ArgumentParser( description='Convert Markdown files to DOCX', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s document.md Convert single file (elegant style) %(prog)s document.md -o report.docx Convert with custom output name %(prog)s docs/ Convert all .md files in directory %(prog)s doc.md --style mono Use monospace font %(prog)s doc.md --style dark Use dark theme Available styles: elegant (default), default, dark, mono, report """ ) parser.add_argument('input', help='Input markdown file or directory') parser.add_argument('-o', '--output', help='Output DOCX file or directory') parser.add_argument('--style', choices=list(STYLES.keys()), default='elegant', help='Style template (default: elegant)') parser.add_argument('-q', '--quiet', action='store_true', help='Suppress output') args = parser.parse_args() input_path = Path(args.input).resolve() if not input_path.exists(): print(f"Error: '{args.input}' not found", file=sys.stderr) sys.exit(1) # Determine files to convert (same logic as md2pdf.py) if input_path.is_file(): if not input_path.suffix.lower() == '.md': print(f"Warning: '{input_path.name}' doesn't have .md extension", file=sys.stderr) files = [input_path] if args.output: output_path = Path(args.output).resolve() if output_path.suffix.lower() == '.docx': outputs = [output_path] else: output_path.mkdir(parents=True, exist_ok=True) outputs = [output_path / (input_path.stem + '.docx')] else: outputs = [input_path.with_suffix('.docx')] else: # Directory files = sorted(input_path.glob('**/*.md')) if not files: print(f"No .md files found in '{args.input}'", file=sys.stderr) sys.exit(1) if args.output: output_dir = Path(args.output).resolve() output_dir.mkdir(parents=True, exist_ok=True) else: output_dir = input_path outputs = [output_dir / (f.stem + '.docx') for f in files] # Convert files success = 0 errors = 0 for md_file, docx_file in zip(files, outputs): try: if not args.quiet: print(f"Converting: {md_file.name} -> {docx_file.name}...", end=' ', flush=True) docx_file.parent.mkdir(parents=True, exist_ok=True) convert_md_to_doc(md_file, docx_file, args.style) if not args.quiet: size_kb = docx_file.stat().st_size / 1024 print(f"OK ({size_kb:.1f} KB)") success += 1 except Exception as e: if not args.quiet: print(f"FAILED: {e}") errors += 1 if not args.quiet and len(files) > 1: print(f"\nDone: {success} converted, {errors} failed") sys.exit(0 if errors == 0 else 1) if __name__ == "__main__": main()