#!/usr/bin/env python3 import os import re import sys import zipfile import xml.sax.saxutils as saxutils def escape_xml(text): return saxutils.escape(text) def make_run(text, bold=False, italic=False, font_size=22, font_color=None, font_name="Calibri", is_code=False): rPr = [] rPr.append(f'') if bold or is_code: rPr.append('') if italic: rPr.append('') if font_size: rPr.append(f'') rPr.append(f'') if font_color: rPr.append(f'') if is_code: rPr.append('') rPr.append('') rPr_xml = f"{''.join(rPr)}" if rPr else "" return f"{rPr_xml}{escape_xml(text)}" def parse_inline_formatting(line, default_size=22, default_color="333333"): # Splits inline code `code`, bold **text**, and normal text tokens = re.split(r'(\*\*.*?\*\*|`.*?`)', line) runs = [] for token in tokens: if not token: continue if token.startswith('**') and token.endswith('**') and len(token) >= 4: runs.append(make_run(token[2:-2], bold=True, font_size=default_size, font_color=default_color)) elif token.startswith('`') and token.endswith('`') and len(token) >= 2: runs.append(make_run(token[1:-1], is_code=True, font_size=default_size - 2, font_color="0f172a")) else: runs.append(make_run(token, font_size=default_size, font_color=default_color)) return "".join(runs) def make_paragraph(runs_xml, style=None, align=None, space_before=100, space_after=100, bg_color=None): pPr = [] if style: pPr.append(f'') if align: pPr.append(f'') pPr.append(f'') if bg_color: pPr.append(f'') pPr_xml = f"{''.join(pPr)}" return f"{pPr_xml}{runs_xml}" def make_table(rows, headers=True): # rows is list of list of strings if not rows: return "" num_cols = max(len(r) for r in rows) col_width = int(9000 / max(1, num_cols)) tbl_xml = [''] tbl_xml.append(''' ''') tbl_xml.append('') for _ in range(num_cols): tbl_xml.append(f'') tbl_xml.append('') for row_idx, row in enumerate(rows): is_header_row = (row_idx == 0 and headers) tbl_xml.append('') trPr = '' if is_header_row: trPr += '' trPr += '' tbl_xml.append(trPr) for col_idx in range(num_cols): cell_text = row[col_idx] if col_idx < len(row) else "" cell_bg = "F1F5F9" if is_header_row else ("FFFFFF" if row_idx % 2 == 1 else "F8FAFC") tcPr = f''' ''' tbl_xml.append('') tbl_xml.append(tcPr) runs = parse_inline_formatting(cell_text.strip(), default_size=19 if is_header_row else 18, default_color="0F172A" if is_header_row else "334155") p = make_paragraph(runs, space_before=40, space_after=40) tbl_xml.append(p) tbl_xml.append('') tbl_xml.append('') tbl_xml.append('') return "".join(tbl_xml) def markdown_to_docx(md_path, docx_path): with open(md_path, 'r', encoding='utf-8') as f: md_text = f.read() lines = md_text.split('\n') doc_elements = [] in_code_block = False code_lines = [] in_table = False table_rows = [] for line in lines: stripped = line.strip() # Code block handling if stripped.startswith('```'): if in_code_block: # End code block in_code_block = False code_text = "\n".join(code_lines) code_runs = make_run(code_text, is_code=True, font_size=18, font_color="1E293B") doc_elements.append(make_paragraph(code_runs, space_before=100, space_after=100, bg_color="F1F5F9")) code_lines = [] else: in_code_block = True code_lines = [] continue if in_code_block: code_lines.append(line) continue # Table handling if '|' in line and (line.strip().startswith('|') or line.strip().endswith('|')): # check if it's separator row |---|---| if re.match(r'^[\|\s\-\:]+$', stripped): continue cells = [c.strip() for c in stripped.split('|')] if cells and cells[0] == '': cells.pop(0) if cells and cells[-1] == '': cells.pop() table_rows.append(cells) in_table = True continue else: if in_table: # End table doc_elements.append(make_table(table_rows)) table_rows = [] in_table = False if not stripped: continue # Horizontal rule if stripped in ('---', '***', '___'): doc_elements.append('') continue # Headings if stripped.startswith('# '): runs = parse_inline_formatting(stripped[2:], default_size=36, default_color="1E3A8A") doc_elements.append(make_paragraph(runs, bold=True, space_before=300, space_after=120)) elif stripped.startswith('## '): runs = parse_inline_formatting(stripped[3:], default_size=28, default_color="1E40AF") doc_elements.append(make_paragraph(runs, bold=True, space_before=240, space_after=100)) elif stripped.startswith('### '): runs = parse_inline_formatting(stripped[4:], default_size=24, default_color="0F172A") doc_elements.append(make_paragraph(runs, bold=True, space_before=180, space_after=80)) elif stripped.startswith('#### '): runs = parse_inline_formatting(stripped[5:], default_size=22, default_color="334155") doc_elements.append(make_paragraph(runs, bold=True, space_before=140, space_after=60)) elif stripped.startswith('- ') or stripped.startswith('* '): bullet_run = make_run("• ", bold=True, font_size=22, font_color="2563EB") text_runs = parse_inline_formatting(stripped[2:], default_size=21, default_color="334155") doc_elements.append(make_paragraph(bullet_run + text_runs, space_before=40, space_after=40)) elif re.match(r'^\d+\.\s', stripped): num_match = re.match(r'^(\d+\.)\s', stripped) prefix = num_match.group(1) + " " num_run = make_run(prefix, bold=True, font_size=22, font_color="1E40AF") text_runs = parse_inline_formatting(stripped[len(prefix):], default_size=21, default_color="334155") doc_elements.append(make_paragraph(num_run + text_runs, space_before=40, space_after=40)) elif stripped.startswith('> '): quote_text = stripped[2:] quote_runs = parse_inline_formatting(quote_text, default_size=21, default_color="475569") doc_elements.append(make_paragraph(quote_runs, space_before=100, space_after=100, bg_color="F8FAFC")) else: runs = parse_inline_formatting(stripped, default_size=22, default_color="334155") doc_elements.append(make_paragraph(runs, space_before=60, space_after=60)) if in_table and table_rows: doc_elements.append(make_table(table_rows)) document_xml = f''' {''.join(doc_elements)} ''' content_types_xml = ''' ''' rels_xml = ''' ''' doc_rels_xml = ''' ''' styles_xml = ''' ''' settings_xml = ''' ''' with zipfile.ZipFile(docx_path, 'w', compression=zipfile.ZIP_DEFLATED) as z: z.writestr('[Content_Types].xml', content_types_xml) z.writestr('_rels/.rels', rels_xml) z.writestr('word/_rels/document.xml.rels', doc_rels_xml) z.writestr('word/styles.xml', styles_xml) z.writestr('word/settings.xml', settings_xml) z.writestr('word/document.xml', document_xml) print(f"Successfully generated DOCX file at: {docx_path}") if __name__ == '__main__': md_file = sys.argv[1] if len(sys.argv) > 1 else 'docs/SYSTEM_SPECIFICATIONS_AND_LIFECYCLE_MANUAL.md' docx_file = sys.argv[2] if len(sys.argv) > 2 else md_file.replace('.md', '.docx') markdown_to_docx(md_file, docx_file)