Files
GSB-Construction/scripts/build_docx.py

282 lines
13 KiB
Python

#!/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'<w:rFonts w:ascii="{font_name}" w:hAnsi="{font_name}" w:cs="{font_name}"/>')
if bold or is_code:
rPr.append('<w:b/>')
if italic:
rPr.append('<w:i/>')
if font_size:
rPr.append(f'<w:sz w:val="{font_size}"/>')
rPr.append(f'<w:szCs w:val="{font_size}"/>')
if font_color:
rPr.append(f'<w:color w:val="{font_color}"/>')
if is_code:
rPr.append('<w:highlight w:val="lightGray"/>')
rPr.append('<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:cs="Consolas"/>')
rPr_xml = f"<w:rPr>{''.join(rPr)}</w:rPr>" if rPr else ""
return f"<w:r>{rPr_xml}<w:t xml:space=\"preserve\">{escape_xml(text)}</w:t></w:r>"
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'<w:pStyle w:val="{style}"/>')
if align:
pPr.append(f'<w:jc w:val="{align}"/>')
pPr.append(f'<w:spacing w:before="{space_before}" w:after="{space_after}" w:line="276" w:lineRule="auto"/>')
if bg_color:
pPr.append(f'<w:shd w:val="clear" w:color="auto" w:fill="{bg_color}"/>')
pPr_xml = f"<w:pPr>{''.join(pPr)}</w:pPr>"
return f"<w:p>{pPr_xml}{runs_xml}</w:p>"
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 = ['<w:tbl>']
tbl_xml.append('''<w:tblPr>
<w:tblStyle w:val="TableGrid"/>
<w:tblW w:w="9000" w:type="dxa"/>
<w:tblBorders>
<w:top w:val="single" w:sz="4" w:space="0" w:color="CBD5E1"/>
<w:left w:val="single" w:sz="4" w:space="0" w:color="CBD5E1"/>
<w:bottom w:val="single" w:sz="4" w:space="0" w:color="CBD5E1"/>
<w:right w:val="single" w:sz="4" w:space="0" w:color="CBD5E1"/>
<w:insideH w:val="single" w:sz="4" w:space="0" w:color="E2E8F0"/>
<w:insideV w:val="single" w:sz="4" w:space="0" w:color="E2E8F0"/>
</w:tblBorders>
<w:tblCellMar>
<w:top w:w="120" w:type="dxa"/>
<w:bottom w:w="120" w:type="dxa"/>
<w:left w:w="160" w:type="dxa"/>
<w:right w:w="160" w:type="dxa"/>
</w:tblCellMar>
</w:tblPr>''')
tbl_xml.append('<w:tblGrid>')
for _ in range(num_cols):
tbl_xml.append(f'<w:gridCol w:w="{col_width}"/>')
tbl_xml.append('</w:tblGrid>')
for row_idx, row in enumerate(rows):
is_header_row = (row_idx == 0 and headers)
tbl_xml.append('<w:tr>')
trPr = '<w:trPr><w:cantSplit/>'
if is_header_row:
trPr += '<w:tblHeader/>'
trPr += '</w: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'''<w:tcPr>
<w:tcW w:w="{col_width}" w:type="dxa"/>
<w:shd w:val="clear" w:color="auto" w:fill="{cell_bg}"/>
<w:vAlign w:val="center"/>
</w:tcPr>'''
tbl_xml.append('<w:tc>')
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('</w:tc>')
tbl_xml.append('</w:tr>')
tbl_xml.append('</w:tbl>')
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('<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="CBD5E1"/></w:pBdr></w:pPr></w:p>')
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'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<w:body>
{''.join(doc_elements)}
<w:sectPr>
<w:pgSz w:w="12240" w:h="15840"/>
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>
<w:cols w:space="720"/>
<w:docGrid w:linePitch="360"/>
</w:sectPr>
</w:body>
</w:document>'''
content_types_xml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>
</Types>'''
rels_xml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>'''
doc_rels_xml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>
</Relationships>'''
styles_xml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:docDefaults>
<w:rPrDefault>
<w:rPr>
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="Calibri" w:cs="Calibri"/>
<w:sz w:val="22"/>
<w:szCs w:val="22"/>
<w:lang w:val="en-US"/>
</w:rPr>
</w:rPrDefault>
</w:docDefaults>
</w:styles>'''
settings_xml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:zoom w:percent="100"/>
</w:settings>'''
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)