# -*- coding: utf-8 -*- """Convert Persian CRM markdown docs into proper RTL Word documents.""" from __future__ import annotations import argparse import re from pathlib import Path from docx import Document from docx.enum.table import WD_TABLE_ALIGNMENT from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Cm, Pt, RGBColor ROOT = Path(__file__).resolve().parent FONT = "B Nazanin" CODE_FONT = "Consolas" NAVY = RGBColor(0x12, 0x2B, 0x4A) TEAL = RGBColor(0x0F, 0x5C, 0x63) BODY = RGBColor(0x22, 0x22, 0x22) MUTED = RGBColor(0x55, 0x55, 0x55) QUOTE_BG = "E8F3F4" CODE_BG = "F4F1EA" HEADER_BG = "122B4A" ROW_ALT = "F6F8FA" PRESETS = { "roadmap": { "md": "راهنما-و-نقشه-راه.md", "docx": "راهنما-و-نقشه-راه.docx", "header": "سند محصول و نقشه راه CRM", "footer": "راهنما و نقشه راه ساخت CRM | نسخه ۰.۵ | آماده‌ی شروع", }, "user-guide": { "md": "راهنمای-کاربری.md", "docx": "راهنمای-کاربری.docx", "header": "راهنمای کاربری منتو (Mento CRM)", "footer": "راهنمای کاربری منتو | آموزش گام‌به‌گام برای همه همکاران", }, } def ensure_child(parent, tag: str): el = parent.find(qn(f"w:{tag}")) if el is None: el = OxmlElement(f"w:{tag}") parent.append(el) return el def set_run_font(run, size: int, bold: bool = False, color: RGBColor | None = None, code: bool = False): run.bold = bold run.font.size = Pt(size) run.font.color.rgb = color or BODY font_name = CODE_FONT if code else FONT run.font.name = font_name r_pr = run._r.get_or_add_rPr() r_fonts = ensure_child(r_pr, "rFonts") for attr in ("ascii", "hAnsi", "cs", "eastAsia"): r_fonts.set(qn(f"w:{attr}"), font_name) sz_cs = ensure_child(r_pr, "szCs") sz_cs.set(qn("w:val"), str(size * 2)) if bold: ensure_child(r_pr, "bCs") rtl = ensure_child(r_pr, "rtl") rtl.set(qn("w:val"), "0" if code else "1") if not code: ensure_child(r_pr, "cs") def set_paragraph_rtl(paragraph, align=WD_ALIGN_PARAGRAPH.RIGHT, space_after=8, space_before=0): paragraph.paragraph_format.space_after = Pt(space_after) paragraph.paragraph_format.space_before = Pt(space_before) paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE paragraph.paragraph_format.line_spacing = 1.2 p_pr = paragraph._p.get_or_add_pPr() bidi = ensure_child(p_pr, "bidi") bidi.set(qn("w:val"), "1") if align == WD_ALIGN_PARAGRAPH.CENTER: jc_val = "center" paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER elif align == WD_ALIGN_PARAGRAPH.LEFT: jc_val = "left" paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT else: jc_val = "right" paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT jc = ensure_child(p_pr, "jc") jc.set(qn("w:val"), jc_val) def shade_paragraph(paragraph, fill: str): p_pr = paragraph._p.get_or_add_pPr() shd = ensure_child(p_pr, "shd") shd.set(qn("w:val"), "clear") shd.set(qn("w:color"), "auto") shd.set(qn("w:fill"), fill) def set_cell_shading(cell, fill: str): tc_pr = cell._tc.get_or_add_tcPr() shd = ensure_child(tc_pr, "shd") shd.set(qn("w:val"), "clear") shd.set(qn("w:color"), "auto") shd.set(qn("w:fill"), fill) def set_table_rtl(table): tbl = table._tbl tbl_pr = tbl.tblPr if tbl_pr is None: tbl_pr = OxmlElement("w:tblPr") tbl.insert(0, tbl_pr) ensure_child(tbl_pr, "bidiVisual") borders = ensure_child(tbl_pr, "tblBorders") for edge in ("top", "left", "bottom", "right", "insideH", "insideV"): el = borders.find(qn(f"w:{edge}")) if el is None: el = OxmlElement(f"w:{edge}") borders.append(el) el.set(qn("w:val"), "single") el.set(qn("w:sz"), "4") el.set(qn("w:space"), "0") el.set(qn("w:color"), "C5CDD6") def add_inline(paragraph, text: str, size: int = 13, color: RGBColor | None = None): parts = re.split(r"(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))", text) for part in parts: if not part: continue if part.startswith("**") and part.endswith("**"): run = paragraph.add_run(part[2:-2]) set_run_font(run, size, bold=True, color=color or NAVY) elif part.startswith("`") and part.endswith("`"): run = paragraph.add_run(part[1:-1]) set_run_font(run, max(size - 1, 9), color=TEAL, code=True) elif part.startswith("[") and "](" in part: m = re.match(r"\[([^\]]+)\]\(([^)]+)\)", part) label = m.group(1) if m else part run = paragraph.add_run(label) set_run_font(run, size, color=TEAL) run.underline = True else: run = paragraph.add_run(part) set_run_font(run, size, color=color) def add_heading(doc, text: str, level: int): sizes = {1: 26, 2: 18, 3: 15} colors = {1: NAVY, 2: NAVY, 3: TEAL} spaces = {1: (4, 14), 2: (16, 8), 3: (12, 6)} p = doc.add_paragraph() before, after = spaces[level] align = WD_ALIGN_PARAGRAPH.CENTER if level == 1 else WD_ALIGN_PARAGRAPH.RIGHT set_paragraph_rtl(p, align=align, space_before=before, space_after=after) run = p.add_run(text) set_run_font(run, sizes[level], bold=True, color=colors[level]) def add_body(doc, text: str): p = doc.add_paragraph() set_paragraph_rtl(p, space_after=8) add_inline(p, text, 13) def add_quote(doc, text: str): p = doc.add_paragraph() set_paragraph_rtl(p, space_after=10, space_before=4) p.paragraph_format.right_indent = Cm(0.35) shade_paragraph(p, QUOTE_BG) add_inline(p, text, 13, color=TEAL) def add_code(doc, text: str): p = doc.add_paragraph() set_paragraph_rtl(p, align=WD_ALIGN_PARAGRAPH.LEFT, space_after=10, space_before=4) shade_paragraph(p, CODE_BG) run = p.add_run(text.strip("\n")) set_run_font(run, 10, color=NAVY, code=True) def add_list_item(doc, text: str, ordered: bool = False, number: int = 1): p = doc.add_paragraph() set_paragraph_rtl(p, space_after=3) p.paragraph_format.right_indent = Cm(0.25) prefix = f"{number}. " if ordered else "• " run = p.add_run(prefix) set_run_font(run, 13, bold=True, color=TEAL) add_inline(p, text, 13) def add_table(doc, header: list[str], rows: list[list[str]]): header_rtl = list(reversed(header)) rows_rtl = [list(reversed(r)) for r in rows] table = doc.add_table(rows=1 + len(rows_rtl), cols=len(header_rtl)) table.alignment = WD_TABLE_ALIGNMENT.CENTER table.autofit = True set_table_rtl(table) for i, cell_text in enumerate(header_rtl): cell = table.rows[0].cells[i] set_cell_shading(cell, HEADER_BG) p = cell.paragraphs[0] set_paragraph_rtl(p, align=WD_ALIGN_PARAGRAPH.CENTER, space_after=2, space_before=2) run = p.add_run(cell_text.strip()) set_run_font(run, 11, bold=True, color=RGBColor(255, 255, 255)) for r_idx, row in enumerate(rows_rtl): for c_idx, cell_text in enumerate(row): if c_idx >= len(table.rows[r_idx + 1].cells): continue cell = table.rows[r_idx + 1].cells[c_idx] if r_idx % 2 == 1: set_cell_shading(cell, ROW_ALT) p = cell.paragraphs[0] set_paragraph_rtl(p, space_after=2, space_before=2) add_inline(p, cell_text.strip(), 11) spacer = doc.add_paragraph() set_paragraph_rtl(spacer, space_after=8) def add_hr(doc): p = doc.add_paragraph() set_paragraph_rtl(p, space_after=8, space_before=4) p_pr = p._p.get_or_add_pPr() p_bdr = ensure_child(p_pr, "pBdr") bottom = ensure_child(p_bdr, "bottom") bottom.set(qn("w:val"), "single") bottom.set(qn("w:sz"), "8") bottom.set(qn("w:space"), "1") bottom.set(qn("w:color"), "C5CDD6") def configure_document(doc): section = doc.sections[0] section.page_width = Cm(21) section.page_height = Cm(29.7) section.top_margin = Cm(2) section.bottom_margin = Cm(2) section.left_margin = Cm(2) section.right_margin = Cm(2) sect_pr = section._sectPr bidi = ensure_child(sect_pr, "bidi") bidi.set(qn("w:val"), "1") style = doc.styles["Normal"] style.font.name = FONT style.font.size = Pt(13) r_pr = style.element.get_or_add_rPr() r_fonts = ensure_child(r_pr, "rFonts") for attr in ("ascii", "hAnsi", "cs", "eastAsia"): r_fonts.set(qn(f"w:{attr}"), FONT) sz_cs = ensure_child(r_pr, "szCs") sz_cs.set(qn("w:val"), "26") rtl = ensure_child(r_pr, "rtl") rtl.set(qn("w:val"), "1") p_pr = style.element.get_or_add_pPr() bidi_p = ensure_child(p_pr, "bidi") bidi_p.set(qn("w:val"), "1") jc = ensure_child(p_pr, "jc") jc.set(qn("w:val"), "right") def parse_table(lines: list[str], start: int) -> tuple[list[str], list[list[str]], int]: header = [c.strip() for c in lines[start].strip().strip("|").split("|")] rows = [] i = start + 2 while i < len(lines) and lines[i].strip().startswith("|"): rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")]) i += 1 return header, rows, i def convert(md_path: Path, docx_path: Path, header_text: str, footer_text: str): if not md_path.exists(): raise FileNotFoundError(md_path) lines = md_path.read_text(encoding="utf-8").splitlines() doc = Document() configure_document(doc) i = 0 in_code = False code_lines: list[str] = [] while i < len(lines): line = lines[i] stripped = line.strip() if stripped.startswith("```"): if in_code: add_code(doc, "\n".join(code_lines)) code_lines = [] in_code = False else: in_code = True i += 1 continue if in_code: code_lines.append(line) i += 1 continue if not stripped: i += 1 continue if stripped == "---": add_hr(doc) i += 1 continue if stripped.startswith("# "): add_heading(doc, stripped[2:], 1) i += 1 continue if stripped.startswith("## "): add_heading(doc, stripped[3:], 2) i += 1 continue if stripped.startswith("### "): add_heading(doc, stripped[4:], 3) i += 1 continue if stripped.startswith("|") and i + 1 < len(lines) and re.match(r"^\|?\s*:?---", lines[i + 1].strip()): header, rows, i = parse_table(lines, i) add_table(doc, header, rows) continue if stripped.startswith("> "): add_quote(doc, stripped[2:]) i += 1 continue m_num = re.match(r"^(\d+)\.\s+(.*)$", stripped) if m_num: add_list_item(doc, m_num.group(2), ordered=True, number=int(m_num.group(1))) i += 1 continue if stripped.startswith("- "): add_list_item(doc, stripped[2:]) i += 1 continue add_body(doc, stripped) i += 1 header = doc.sections[0].header hp = header.paragraphs[0] set_paragraph_rtl(hp, align=WD_ALIGN_PARAGRAPH.CENTER, space_after=0) run = hp.add_run(header_text) set_run_font(run, 10, bold=True, color=NAVY) footer = doc.sections[0].footer fp = footer.paragraphs[0] set_paragraph_rtl(fp, align=WD_ALIGN_PARAGRAPH.CENTER, space_after=0) run = fp.add_run(footer_text) set_run_font(run, 9, color=MUTED) doc.save(docx_path) print("DOCX_OK") def main(): parser = argparse.ArgumentParser(description="Export Persian markdown docs to RTL DOCX") parser.add_argument( "--preset", choices=sorted(PRESETS.keys()), default="user-guide", help="Built-in document preset (default: user-guide)", ) args = parser.parse_args() preset = PRESETS[args.preset] convert( ROOT / preset["md"], ROOT / preset["docx"], preset["header"], preset["footer"], ) if __name__ == "__main__": main()