Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

export_m18_mapping_office.py 12 KiB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Export MTMS ↔ M18 mapping docs to Word (.docx) and Excel (.xlsx).
  5. Prereqs:
  6. pip install python-docx openpyxl
  7. Usage (from repo root):
  8. python scripts/export_m18_mapping_office.py
  9. Outputs:
  10. docs/exports/MTMS_M18_DATA_MAPPING.docx
  11. docs/exports/MTMS_M18_DATA_MAPPING.xlsx
  12. """
  13. from __future__ import annotations
  14. import re
  15. from datetime import datetime, timezone
  16. from pathlib import Path
  17. from docx import Document
  18. from docx.enum.text import WD_ALIGN_PARAGRAPH
  19. from docx.oxml.ns import qn
  20. from docx.shared import Cm, Pt, RGBColor
  21. from openpyxl import Workbook
  22. from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
  23. from openpyxl.utils import get_column_letter
  24. ROOT = Path(__file__).resolve().parents[1]
  25. DOCS = ROOT / "docs"
  26. GEN = DOCS / "generated"
  27. OUT = DOCS / "exports"
  28. HANDBOOK = DOCS / "MTMS_M18_DATA_MAPPING.md"
  29. ITEM_TYPE_MD = GEN / "m18-item-type-mapping.md"
  30. STSEARCH_MD = GEN / "m18-stsearch-types.md"
  31. def strip_md_inline(s: str) -> str:
  32. s = s.strip()
  33. s = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", s) # links
  34. s = s.replace("**", "").replace("`", "").replace("*", "")
  35. return s.strip()
  36. def parse_md_tables(text: str) -> list[tuple[str, list[str], list[list[str]]]]:
  37. """
  38. Return list of (section_title, headers, rows) for each markdown table.
  39. section_title = nearest preceding ## / ### heading.
  40. """
  41. lines = text.splitlines()
  42. current_h = ""
  43. tables: list[tuple[str, list[str], list[list[str]]]] = []
  44. i = 0
  45. while i < len(lines):
  46. line = lines[i]
  47. if line.startswith("#"):
  48. current_h = strip_md_inline(re.sub(r"^#+\s*", "", line))
  49. i += 1
  50. continue
  51. if line.strip().startswith("|") and i + 1 < len(lines) and re.match(
  52. r"^\|[\s\-:|]+\|$", lines[i + 1].strip()
  53. ):
  54. header = [strip_md_inline(c) for c in line.strip().strip("|").split("|")]
  55. i += 2
  56. rows: list[list[str]] = []
  57. while i < len(lines) and lines[i].strip().startswith("|"):
  58. row = [strip_md_inline(c) for c in lines[i].strip().strip("|").split("|")]
  59. rows.append(row)
  60. i += 1
  61. tables.append((current_h, header, rows))
  62. continue
  63. i += 1
  64. return tables
  65. def add_runs_with_code(paragraph, text: str) -> None:
  66. """Simple split on backticks for monospace-ish plain text."""
  67. parts = re.split(r"`([^`]+)`", text)
  68. for idx, part in enumerate(parts):
  69. if not part:
  70. continue
  71. run = paragraph.add_run(part)
  72. run.font.name = "Calibri"
  73. run._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft JhengHei")
  74. if idx % 2 == 1:
  75. run.font.name = "Consolas"
  76. run.font.size = Pt(9)
  77. def md_to_docx(md_path: Path, out_path: Path, extra_md_files: list[Path] | None = None) -> None:
  78. doc = Document()
  79. section = doc.sections[0]
  80. section.top_margin = Cm(2)
  81. section.bottom_margin = Cm(2)
  82. section.left_margin = Cm(2.2)
  83. section.right_margin = Cm(2.2)
  84. style = doc.styles["Normal"]
  85. style.font.name = "Calibri"
  86. style.font.size = Pt(11)
  87. style._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft JhengHei")
  88. files = [md_path] + (extra_md_files or [])
  89. first = True
  90. for path in files:
  91. if not path.is_file():
  92. continue
  93. if not first:
  94. doc.add_page_break()
  95. first = False
  96. _append_md_file(doc, path)
  97. footer = doc.sections[0].footer.paragraphs[0]
  98. footer.text = (
  99. f"MTMS ↔ M18 mapping · exported {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}"
  100. )
  101. footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
  102. out_path.parent.mkdir(parents=True, exist_ok=True)
  103. doc.save(out_path)
  104. print(f"Wrote {out_path.relative_to(ROOT)}")
  105. def _append_md_file(doc: Document, path: Path) -> None:
  106. lines = path.read_text(encoding="utf-8").splitlines()
  107. i = 0
  108. in_code = False
  109. code_buf: list[str] = []
  110. while i < len(lines):
  111. line = lines[i]
  112. if line.startswith("<!--"):
  113. i += 1
  114. continue
  115. if line.strip().startswith("```"):
  116. if not in_code:
  117. in_code = True
  118. code_buf = []
  119. else:
  120. in_code = False
  121. p = doc.add_paragraph()
  122. run = p.add_run("\n".join(code_buf))
  123. run.font.name = "Consolas"
  124. run.font.size = Pt(9)
  125. i += 1
  126. continue
  127. if in_code:
  128. code_buf.append(line)
  129. i += 1
  130. continue
  131. if line.startswith("#"):
  132. level = len(re.match(r"^#+", line).group(0))
  133. text = strip_md_inline(re.sub(r"^#+\s*", "", line))
  134. if level == 1:
  135. doc.add_heading(text, level=0)
  136. else:
  137. doc.add_heading(text, level=min(level, 3))
  138. i += 1
  139. continue
  140. if line.strip().startswith("|") and i + 1 < len(lines) and re.match(
  141. r"^\|[\s\-:|]+\|$", lines[i + 1].strip()
  142. ):
  143. header = [strip_md_inline(c) for c in line.strip().strip("|").split("|")]
  144. i += 2
  145. rows: list[list[str]] = []
  146. while i < len(lines) and lines[i].strip().startswith("|"):
  147. rows.append(
  148. [strip_md_inline(c) for c in lines[i].strip().strip("|").split("|")]
  149. )
  150. i += 1
  151. table = doc.add_table(rows=1 + len(rows), cols=len(header))
  152. table.style = "Table Grid"
  153. for c, h in enumerate(header):
  154. cell = table.rows[0].cells[c]
  155. cell.text = h
  156. for p in cell.paragraphs:
  157. for r in p.runs:
  158. r.bold = True
  159. for r_idx, row in enumerate(rows):
  160. for c, val in enumerate(row):
  161. if c < len(header):
  162. table.rows[r_idx + 1].cells[c].text = val
  163. doc.add_paragraph()
  164. continue
  165. if line.strip().startswith("> "):
  166. p = doc.add_paragraph()
  167. p.paragraph_format.left_indent = Cm(0.5)
  168. add_runs_with_code(p, strip_md_inline(line.strip()[2:]))
  169. for r in p.runs:
  170. r.italic = True
  171. i += 1
  172. continue
  173. if re.match(r"^[-*]\s+", line.strip()):
  174. text = strip_md_inline(re.sub(r"^[-*]\s+", "", line.strip()))
  175. p = doc.add_paragraph(style="List Bullet")
  176. add_runs_with_code(p, text)
  177. i += 1
  178. continue
  179. if re.match(r"^\d+\.\s+", line.strip()):
  180. text = strip_md_inline(re.sub(r"^\d+\.\s+", "", line.strip()))
  181. p = doc.add_paragraph(style="List Number")
  182. add_runs_with_code(p, text)
  183. i += 1
  184. continue
  185. if line.strip() == "" or line.strip() == "---":
  186. i += 1
  187. continue
  188. p = doc.add_paragraph()
  189. add_runs_with_code(p, strip_md_inline(line))
  190. i += 1
  191. def write_xlsx(out_path: Path) -> None:
  192. wb = Workbook()
  193. # remove default later if we create named sheets first
  194. header_fill = PatternFill("solid", fgColor="D9D9D9")
  195. header_font = Font(bold=True, name="Calibri", size=11)
  196. thin = Border(
  197. left=Side(style="thin", color="B0B0B0"),
  198. right=Side(style="thin", color="B0B0B0"),
  199. top=Side(style="thin", color="B0B0B0"),
  200. bottom=Side(style="thin", color="B0B0B0"),
  201. )
  202. wrap = Alignment(wrap_text=True, vertical="center")
  203. def style_sheet(ws, headers: list[str], rows: list[list[str]], title: str) -> None:
  204. ws["A1"] = title
  205. ws["A1"].font = Font(bold=True, size=14, name="Calibri")
  206. ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max(len(headers), 1))
  207. ws["A2"] = f"Exported {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}"
  208. ws["A2"].font = Font(italic=True, color="666666", size=9)
  209. start = 4
  210. for c, h in enumerate(headers, 1):
  211. cell = ws.cell(start, c, h)
  212. cell.fill = header_fill
  213. cell.font = header_font
  214. cell.border = thin
  215. cell.alignment = Alignment(wrap_text=True, vertical="center", horizontal="center")
  216. for r_idx, row in enumerate(rows, start + 1):
  217. for c, val in enumerate(row, 1):
  218. cell = ws.cell(r_idx, c, val)
  219. cell.border = thin
  220. cell.alignment = wrap
  221. for c in range(1, len(headers) + 1):
  222. maxlen = len(headers[c - 1])
  223. for row in rows:
  224. if c - 1 < len(row):
  225. maxlen = max(maxlen, len(row[c - 1]))
  226. ws.column_dimensions[get_column_letter(c)].width = min(max(12, maxlen + 2), 48)
  227. # Collect tables from generated + handbook
  228. sheets_spec: list[tuple[str, Path]] = [
  229. ("ItemType_Sync", ITEM_TYPE_MD),
  230. ("StSearch", STSEARCH_MD),
  231. ("Handbook_Tables", HANDBOOK),
  232. ]
  233. first = True
  234. for sheet_name, md_path in sheets_spec:
  235. if not md_path.is_file():
  236. continue
  237. tables = parse_md_tables(md_path.read_text(encoding="utf-8"))
  238. if sheet_name == "Handbook_Tables":
  239. # one sheet per handbook table (limited name length)
  240. for idx, (sec, headers, rows) in enumerate(tables, 1):
  241. name = f"H{idx}_{sec[:20]}" if sec else f"H{idx}"
  242. name = re.sub(r"[\\/*?:\[\]]", "_", name)[:31]
  243. ws = wb.active if first else wb.create_sheet(name)
  244. if first:
  245. ws.title = name
  246. first = False
  247. style_sheet(ws, headers, rows, f"{sec or 'Table'} (from handbook)")
  248. continue
  249. # For generated files: put Sync mapping as main sheet; other tables as extra sheets
  250. if not tables:
  251. continue
  252. if first:
  253. ws = wb.active
  254. ws.title = sheet_name[:31]
  255. first = False
  256. else:
  257. ws = wb.create_sheet(sheet_name[:31])
  258. # Prefer table titled Sync mapping / first table
  259. main = next((t for t in tables if "Sync" in t[0] or "mapping" in t[0].lower()), tables[0])
  260. style_sheet(ws, main[1], main[2], main[0] or sheet_name)
  261. for sec, headers, rows in tables:
  262. if (sec, headers, rows) == main:
  263. continue
  264. extra_name = re.sub(r"[\\/*?:\[\]]", "_", f"{sheet_name[:8]}_{sec}")[:31]
  265. ws2 = wb.create_sheet(extra_name)
  266. style_sheet(ws2, headers, rows, sec or extra_name)
  267. # Readme sheet
  268. ws = wb.create_sheet("README", 0)
  269. ws["A1"] = "MTMS (FPSMS) ↔ M18 資料對照 — Excel 匯出"
  270. ws["A1"].font = Font(bold=True, size=14)
  271. ws["A3"] = "來源"
  272. ws["B3"] = "docs/MTMS_M18_DATA_MAPPING.md + docs/generated/*.md"
  273. ws["A4"] = "重新產生 Markdown 表"
  274. ws["B4"] = "python scripts/generate_m18_mapping_docs.py"
  275. ws["A5"] = "重新匯出 Word/Excel"
  276. ws["B5"] = "python scripts/export_m18_mapping_office.py"
  277. ws["A7"] = "說明"
  278. ws["B7"] = (
  279. "對照表以 sheet 分開;完整敘述請看 Word 檔 MTMS_M18_DATA_MAPPING.docx。"
  280. "已知陷阱:M18 udfProducttype=CMB 會落到 items.type=mat(原料)。"
  281. )
  282. ws.column_dimensions["A"].width = 28
  283. ws.column_dimensions["B"].width = 80
  284. for r in range(3, 8):
  285. ws.cell(r, 2).alignment = wrap
  286. out_path.parent.mkdir(parents=True, exist_ok=True)
  287. wb.save(out_path)
  288. print(f"Wrote {out_path.relative_to(ROOT)}")
  289. def main() -> None:
  290. OUT.mkdir(parents=True, exist_ok=True)
  291. md_to_docx(
  292. HANDBOOK,
  293. OUT / "MTMS_M18_DATA_MAPPING.docx",
  294. extra_md_files=[ITEM_TYPE_MD, STSEARCH_MD],
  295. )
  296. write_xlsx(OUT / "MTMS_M18_DATA_MAPPING.xlsx")
  297. if __name__ == "__main__":
  298. main()