#!/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)