Files
crm/docs/export-doc.py

426 lines
13 KiB
Python

# -*- 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": "راهنما و نقشه راه منتکت (Montact CRM)",
"footer": "نقشه راه منتکت | نسخه ۱.۰ | وضعیت زنده محصول",
},
"user-guide": {
"md": "راهنمای-کاربری.md",
"docx": "راهنمای-کاربری.docx",
"header": "راهنمای کاربری منتکت (Montact CRM)",
"footer": "راهنمای کاربری منتکت | آموزش گام‌به‌گام برای همه همکاران",
},
"features": {
"md": "قابلیت‌های-منتکت.md",
"docx": "قابلیت‌های-منتو.docx",
"header": "قابلیت‌های منتکت (Montact CRM)",
"footer": "فهرست قابلیت‌های محصول | تیتر + توضیح مختصر",
},
"progress": {
"md": "وضعیت-پیشرفت.md",
"docx": "وضعیت-پیشرفت.docx",
"header": "وضعیت پیشرفت منتکت (Montact 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()), "all"],
default="user-guide",
help="Built-in document preset (default: user-guide). Use 'all' to export every preset.",
)
args = parser.parse_args()
names = sorted(PRESETS.keys()) if args.preset == "all" else [args.preset]
for name in names:
preset = PRESETS[name]
convert(
ROOT / preset["md"],
ROOT / preset["docx"],
preset["header"],
preset["footer"],
)
print(f"OK preset={name}")
if __name__ == "__main__":
main()