|
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- Export MTMS ↔ M18 mapping docs to Word (.docx) and Excel (.xlsx).
-
- Prereqs:
- pip install python-docx openpyxl
-
- Usage (from repo root):
- python scripts/export_m18_mapping_office.py
-
- Outputs:
- docs/exports/MTMS_M18_DATA_MAPPING.docx
- docs/exports/MTMS_M18_DATA_MAPPING.xlsx
- """
-
- from __future__ import annotations
-
- import re
- from datetime import datetime, timezone
- from pathlib import Path
-
- from docx import Document
- from docx.enum.text import WD_ALIGN_PARAGRAPH
- from docx.oxml.ns import qn
- from docx.shared import Cm, Pt, RGBColor
- from openpyxl import Workbook
- from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
- from openpyxl.utils import get_column_letter
-
- ROOT = Path(__file__).resolve().parents[1]
- DOCS = ROOT / "docs"
- GEN = DOCS / "generated"
- OUT = DOCS / "exports"
- HANDBOOK = DOCS / "MTMS_M18_DATA_MAPPING.md"
- ITEM_TYPE_MD = GEN / "m18-item-type-mapping.md"
- STSEARCH_MD = GEN / "m18-stsearch-types.md"
-
-
- def strip_md_inline(s: str) -> str:
- s = s.strip()
- s = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", s) # links
- s = s.replace("**", "").replace("`", "").replace("*", "")
- return s.strip()
-
-
- def parse_md_tables(text: str) -> list[tuple[str, list[str], list[list[str]]]]:
- """
- Return list of (section_title, headers, rows) for each markdown table.
- section_title = nearest preceding ## / ### heading.
- """
- lines = text.splitlines()
- current_h = ""
- tables: list[tuple[str, list[str], list[list[str]]]] = []
- i = 0
- while i < len(lines):
- line = lines[i]
- if line.startswith("#"):
- current_h = strip_md_inline(re.sub(r"^#+\s*", "", line))
- i += 1
- continue
- if line.strip().startswith("|") and i + 1 < len(lines) and re.match(
- r"^\|[\s\-:|]+\|$", lines[i + 1].strip()
- ):
- header = [strip_md_inline(c) for c in line.strip().strip("|").split("|")]
- i += 2
- rows: list[list[str]] = []
- while i < len(lines) and lines[i].strip().startswith("|"):
- row = [strip_md_inline(c) for c in lines[i].strip().strip("|").split("|")]
- rows.append(row)
- i += 1
- tables.append((current_h, header, rows))
- continue
- i += 1
- return tables
-
-
- def add_runs_with_code(paragraph, text: str) -> None:
- """Simple split on backticks for monospace-ish plain text."""
- parts = re.split(r"`([^`]+)`", text)
- for idx, part in enumerate(parts):
- if not part:
- continue
- run = paragraph.add_run(part)
- run.font.name = "Calibri"
- run._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft JhengHei")
- if idx % 2 == 1:
- run.font.name = "Consolas"
- run.font.size = Pt(9)
-
-
- def md_to_docx(md_path: Path, out_path: Path, extra_md_files: list[Path] | None = None) -> None:
- doc = Document()
- section = doc.sections[0]
- section.top_margin = Cm(2)
- section.bottom_margin = Cm(2)
- section.left_margin = Cm(2.2)
- section.right_margin = Cm(2.2)
-
- style = doc.styles["Normal"]
- style.font.name = "Calibri"
- style.font.size = Pt(11)
- style._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft JhengHei")
-
- files = [md_path] + (extra_md_files or [])
- first = True
- for path in files:
- if not path.is_file():
- continue
- if not first:
- doc.add_page_break()
- first = False
- _append_md_file(doc, path)
-
- footer = doc.sections[0].footer.paragraphs[0]
- footer.text = (
- f"MTMS ↔ M18 mapping · exported {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}"
- )
- footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
-
- out_path.parent.mkdir(parents=True, exist_ok=True)
- doc.save(out_path)
- print(f"Wrote {out_path.relative_to(ROOT)}")
-
-
- def _append_md_file(doc: Document, path: Path) -> None:
- lines = path.read_text(encoding="utf-8").splitlines()
- i = 0
- in_code = False
- code_buf: list[str] = []
-
- while i < len(lines):
- line = lines[i]
-
- if line.startswith("<!--"):
- i += 1
- continue
-
- if line.strip().startswith("```"):
- if not in_code:
- in_code = True
- code_buf = []
- else:
- in_code = False
- p = doc.add_paragraph()
- run = p.add_run("\n".join(code_buf))
- run.font.name = "Consolas"
- run.font.size = Pt(9)
- i += 1
- continue
-
- if in_code:
- code_buf.append(line)
- i += 1
- continue
-
- if line.startswith("#"):
- level = len(re.match(r"^#+", line).group(0))
- text = strip_md_inline(re.sub(r"^#+\s*", "", line))
- if level == 1:
- doc.add_heading(text, level=0)
- else:
- doc.add_heading(text, level=min(level, 3))
- i += 1
- continue
-
- if line.strip().startswith("|") and i + 1 < len(lines) and re.match(
- r"^\|[\s\-:|]+\|$", lines[i + 1].strip()
- ):
- header = [strip_md_inline(c) for c in line.strip().strip("|").split("|")]
- i += 2
- rows: list[list[str]] = []
- while i < len(lines) and lines[i].strip().startswith("|"):
- rows.append(
- [strip_md_inline(c) for c in lines[i].strip().strip("|").split("|")]
- )
- i += 1
- table = doc.add_table(rows=1 + len(rows), cols=len(header))
- table.style = "Table Grid"
- for c, h in enumerate(header):
- cell = table.rows[0].cells[c]
- cell.text = h
- for p in cell.paragraphs:
- for r in p.runs:
- r.bold = True
- for r_idx, row in enumerate(rows):
- for c, val in enumerate(row):
- if c < len(header):
- table.rows[r_idx + 1].cells[c].text = val
- doc.add_paragraph()
- continue
-
- if line.strip().startswith("> "):
- p = doc.add_paragraph()
- p.paragraph_format.left_indent = Cm(0.5)
- add_runs_with_code(p, strip_md_inline(line.strip()[2:]))
- for r in p.runs:
- r.italic = True
- i += 1
- continue
-
- if re.match(r"^[-*]\s+", line.strip()):
- text = strip_md_inline(re.sub(r"^[-*]\s+", "", line.strip()))
- p = doc.add_paragraph(style="List Bullet")
- add_runs_with_code(p, text)
- i += 1
- continue
-
- if re.match(r"^\d+\.\s+", line.strip()):
- text = strip_md_inline(re.sub(r"^\d+\.\s+", "", line.strip()))
- p = doc.add_paragraph(style="List Number")
- add_runs_with_code(p, text)
- i += 1
- continue
-
- if line.strip() == "" or line.strip() == "---":
- i += 1
- continue
-
- p = doc.add_paragraph()
- add_runs_with_code(p, strip_md_inline(line))
- i += 1
-
-
- def write_xlsx(out_path: Path) -> None:
- wb = Workbook()
- # remove default later if we create named sheets first
- header_fill = PatternFill("solid", fgColor="D9D9D9")
- header_font = Font(bold=True, name="Calibri", size=11)
- thin = Border(
- left=Side(style="thin", color="B0B0B0"),
- right=Side(style="thin", color="B0B0B0"),
- top=Side(style="thin", color="B0B0B0"),
- bottom=Side(style="thin", color="B0B0B0"),
- )
- wrap = Alignment(wrap_text=True, vertical="center")
-
- def style_sheet(ws, headers: list[str], rows: list[list[str]], title: str) -> None:
- ws["A1"] = title
- ws["A1"].font = Font(bold=True, size=14, name="Calibri")
- ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max(len(headers), 1))
- ws["A2"] = f"Exported {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}"
- ws["A2"].font = Font(italic=True, color="666666", size=9)
-
- start = 4
- for c, h in enumerate(headers, 1):
- cell = ws.cell(start, c, h)
- cell.fill = header_fill
- cell.font = header_font
- cell.border = thin
- cell.alignment = Alignment(wrap_text=True, vertical="center", horizontal="center")
- for r_idx, row in enumerate(rows, start + 1):
- for c, val in enumerate(row, 1):
- cell = ws.cell(r_idx, c, val)
- cell.border = thin
- cell.alignment = wrap
- for c in range(1, len(headers) + 1):
- maxlen = len(headers[c - 1])
- for row in rows:
- if c - 1 < len(row):
- maxlen = max(maxlen, len(row[c - 1]))
- ws.column_dimensions[get_column_letter(c)].width = min(max(12, maxlen + 2), 48)
-
- # Collect tables from generated + handbook
- sheets_spec: list[tuple[str, Path]] = [
- ("ItemType_Sync", ITEM_TYPE_MD),
- ("StSearch", STSEARCH_MD),
- ("Handbook_Tables", HANDBOOK),
- ]
-
- first = True
- for sheet_name, md_path in sheets_spec:
- if not md_path.is_file():
- continue
- tables = parse_md_tables(md_path.read_text(encoding="utf-8"))
- if sheet_name == "Handbook_Tables":
- # one sheet per handbook table (limited name length)
- for idx, (sec, headers, rows) in enumerate(tables, 1):
- name = f"H{idx}_{sec[:20]}" if sec else f"H{idx}"
- name = re.sub(r"[\\/*?:\[\]]", "_", name)[:31]
- ws = wb.active if first else wb.create_sheet(name)
- if first:
- ws.title = name
- first = False
- style_sheet(ws, headers, rows, f"{sec or 'Table'} (from handbook)")
- continue
-
- # For generated files: put Sync mapping as main sheet; other tables as extra sheets
- if not tables:
- continue
- if first:
- ws = wb.active
- ws.title = sheet_name[:31]
- first = False
- else:
- ws = wb.create_sheet(sheet_name[:31])
-
- # Prefer table titled Sync mapping / first table
- main = next((t for t in tables if "Sync" in t[0] or "mapping" in t[0].lower()), tables[0])
- style_sheet(ws, main[1], main[2], main[0] or sheet_name)
-
- for sec, headers, rows in tables:
- if (sec, headers, rows) == main:
- continue
- extra_name = re.sub(r"[\\/*?:\[\]]", "_", f"{sheet_name[:8]}_{sec}")[:31]
- ws2 = wb.create_sheet(extra_name)
- style_sheet(ws2, headers, rows, sec or extra_name)
-
- # Readme sheet
- ws = wb.create_sheet("README", 0)
- ws["A1"] = "MTMS (FPSMS) ↔ M18 資料對照 — Excel 匯出"
- ws["A1"].font = Font(bold=True, size=14)
- ws["A3"] = "來源"
- ws["B3"] = "docs/MTMS_M18_DATA_MAPPING.md + docs/generated/*.md"
- ws["A4"] = "重新產生 Markdown 表"
- ws["B4"] = "python scripts/generate_m18_mapping_docs.py"
- ws["A5"] = "重新匯出 Word/Excel"
- ws["B5"] = "python scripts/export_m18_mapping_office.py"
- ws["A7"] = "說明"
- ws["B7"] = (
- "對照表以 sheet 分開;完整敘述請看 Word 檔 MTMS_M18_DATA_MAPPING.docx。"
- "已知陷阱:M18 udfProducttype=CMB 會落到 items.type=mat(原料)。"
- )
- ws.column_dimensions["A"].width = 28
- ws.column_dimensions["B"].width = 80
- for r in range(3, 8):
- ws.cell(r, 2).alignment = wrap
-
- out_path.parent.mkdir(parents=True, exist_ok=True)
- wb.save(out_path)
- print(f"Wrote {out_path.relative_to(ROOT)}")
-
-
- def main() -> None:
- OUT.mkdir(parents=True, exist_ok=True)
- md_to_docx(
- HANDBOOK,
- OUT / "MTMS_M18_DATA_MAPPING.docx",
- extra_md_files=[ITEM_TYPE_MD, STSEARCH_MD],
- )
- write_xlsx(OUT / "MTMS_M18_DATA_MAPPING.xlsx")
-
-
- if __name__ == "__main__":
- main()
|