"""Word (.docx) renderer — Markdown → python-docx. Line-based Markdown parser mapping to python-docx objects. Supports: - Headings (# H1 .. ###### H6) - Bullet lists (- / * / +) - Numbered lists (1. / 2.) - GFM tables (| col | col |) - Bold (**text**) and italic (*text_) inline formatting - Plain paragraphs Unsupported Markdown features (images, code blocks, blockquotes) fall back to plain text — v1 scope per plan U2. """ from __future__ import annotations import re from pathlib import Path from docx import Document from docx.table import Table from docx.text.paragraph import Paragraph class WordRenderer: """Render Markdown content into a .docx file via python-docx.""" def render(self, markdown_content: str, output_path: Path) -> Path: """Render Markdown to a .docx file at output_path. Args: markdown_content: Markdown-formatted text. output_path: Destination .docx path. Returns: The output_path (for chaining). """ doc = Document() lines = markdown_content.splitlines() i = 0 while i < len(lines): line = lines[i] i += 1 # Skip empty lines if not line.strip(): continue # Heading: # .. ###### heading_match = re.match(r"^(#{1,6})\s+(.+)$", line) if heading_match: level = len(heading_match.group(1)) text = heading_match.group(2).strip() doc.add_heading(text, level=level) continue # GFM table: line starts with | and next line is a separator if line.lstrip().startswith("|") and i < len(lines) and re.match( r"^\s*\|[\s:|-]+\|\s*$", lines[i] ): # Collect table rows: header, separator, data rows table_lines = [line, lines[i]] i += 1 while i < len(lines) and lines[i].lstrip().startswith("|"): table_lines.append(lines[i]) i += 1 self._add_table(doc, table_lines) continue # Bullet list: - / * / + bullet_match = re.match(r"^(\s*)[-*+]\s+(.+)$", line) if bullet_match: text = bullet_match.group(2) para = doc.add_paragraph(style="List Bullet") self._add_inline_runs(para, text) continue # Numbered list: 1. / 2. etc. num_match = re.match(r"^(\s*)\d+\.\s+(.+)$", line) if num_match: text = num_match.group(2) para = doc.add_paragraph(style="List Number") self._add_inline_runs(para, text) continue # Plain paragraph para = doc.add_paragraph() self._add_inline_runs(para, line) output_path.parent.mkdir(parents=True, exist_ok=True) doc.save(str(output_path)) return output_path def _add_table(self, doc: Document, table_lines: list[str]) -> Table: """Parse GFM table lines and add a python-docx table.""" rows: list[list[str]] = [] for idx, line in enumerate(table_lines): # Skip the separator row (|---|---|) if idx == 1: continue # Split by | and strip edges cells = [c.strip() for c in line.strip().strip("|").split("|")] rows.append(cells) if not rows: return doc.add_table(rows=0, cols=0) ncols = max(len(r) for r in rows) table = doc.add_table(rows=len(rows), cols=ncols) table.style = "Table Grid" for r_idx, row in enumerate(rows): for c_idx, cell_text in enumerate(row): if c_idx < ncols: cell = table.cell(r_idx, c_idx) cell.text = cell_text return table def _add_inline_runs(self, para: Paragraph, text: str) -> None: """Add runs with bold/italic inline formatting. Supports **bold** and *italic*. Nested formatting is not supported in v1 — the first match wins. """ # Pattern: **bold** or *italic* pattern = re.compile(r"(\*\*(.+?)\*\*|\*(.+?)\*)") pos = 0 for match in pattern.finditer(text): # Add preceding plain text if match.start() > pos: para.add_run(text[pos : match.start()]) if match.group(2): # **bold** run = para.add_run(match.group(2)) run.bold = True elif match.group(3): # *italic* run = para.add_run(match.group(3)) run.italic = True pos = match.end() # Add trailing plain text if pos < len(text): para.add_run(text[pos:])