426 lines
13 KiB
Python
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()
|