from __future__ import annotations import hashlib import re from pathlib import Path from docx import Document from docx.oxml.ns import qn from docx.table import Table from docx.text.paragraph import Paragraph ROOT = Path(__file__).resolve().parents[1] DOC_DIR = ROOT / "docs" / "admin_reference_prd" SOURCE = DOC_DIR / "后台管理系统需求文档.docx" TARGET = DOC_DIR / "后台管理系统需求文档.md" ASSET_DIR = DOC_DIR / "assets" IMAGE_ALTS = { "prototype_01_dashboard.png": "后台首页看板脱敏原型", "prototype_02_member_list.png": "会员列表脱敏原型", "prototype_03_member_modal.png": "会员编辑弹窗原型", "prototype_04_withdrawal.png": "提现审核脱敏原型", "prototype_05_rbac.png": "RBAC 角色权限原型", "prototype_06_flow.png": "C 端与后台业务闭环原型", } def iter_blocks(document: Document): for child in document.element.body.iterchildren(): if child.tag == qn("w:p"): yield Paragraph(child, document) elif child.tag == qn("w:tbl"): yield Table(child, document) def media_map(document: Document) -> dict[str, str]: assets_by_hash = { hashlib.sha256(path.read_bytes()).hexdigest(): path.name for path in ASSET_DIR.glob("*.png") } mapping: dict[str, str] = {} for rel_id, rel in document.part.rels.items(): if "image" not in rel.reltype: continue digest = hashlib.sha256(rel.target_part.blob).hexdigest() name = assets_by_hash.get(digest) if name: mapping[rel_id] = f"assets/{name}" return mapping def paragraph_images(paragraph: Paragraph, images: dict[str, str]) -> list[str]: result: list[str] = [] for blip in paragraph._p.xpath(".//a:blip"): rel_id = blip.get(qn("r:embed")) if rel_id in images: result.append(images[rel_id]) return result def escape_cell(value: str) -> str: return value.strip().replace("|", "\\|").replace("\n", "
") or "—" def table_markdown(table: Table) -> list[str]: rows = [[escape_cell(cell.text) for cell in row.cells] for row in table.rows] if not rows: return [] width = max(len(row) for row in rows) rows = [row + ["—"] * (width - len(row)) for row in rows] lines = [ "| " + " | ".join(rows[0]) + " |", "| " + " | ".join(["---"] * width) + " |", ] lines.extend("| " + " | ".join(row) + " |" for row in rows[1:]) return lines def github_slug(title: str) -> str: slug = title.strip().lower() slug = re.sub(r"[^\w\-\u4e00-\u9fff ]", "", slug) return re.sub(r"\s+", "-", slug) def convert() -> None: document = Document(SOURCE) images = media_map(document) body: list[str] = [] top_level: list[str] = [] image_number = 0 for block in iter_blocks(document): if isinstance(block, Table): body.extend(table_markdown(block)) body.append("") continue text = block.text.strip() style = block.style.name if block.style else "Normal" embedded = paragraph_images(block, images) if style == "Title" and text: body.extend([f"# {text}", ""]) elif style == "Heading 1" and text: top_level.append(text) body.extend([f"## {text}", ""]) elif style == "Heading 2" and text: body.extend([f"### {text}", ""]) elif style == "List Bullet" and text: body.extend([f"- {text}", ""]) elif style == "List Number" and text: body.extend([f"1. {text}", ""]) elif text: if text.startswith("结论:"): body.extend([f"> **{text[:3]}** {text[3:]}", ""]) elif text.startswith("P0:"): body.extend([f"> **P0 风险:** {text[3:]}", ""]) else: body.extend([text, ""]) for path in embedded: image_number += 1 alt = IMAGE_ALTS.get(Path(path).name, f"需求原型图 {image_number}") body.extend([f"![{alt}]({path})", ""]) # Word 封面之后放置 Markdown 可点击目录,去掉 Word 中无意义的空段落。 first_section = next(i for i, line in enumerate(body) if line.startswith("## 1 ")) toc = ["## 目录", ""] toc.extend(f"- [{title}](#{github_slug(title)})" for title in top_level) toc.extend(["", "---", ""]) output = body[:first_section] + toc + body[first_section:] compact: list[str] = [] for line in output: if line == "" and compact and compact[-1] == "": continue compact.append(line) rendered = "\n".join(compact).rstrip() + "\n" rendered = re.sub(r"(^- .+)\n\n(?=- )", r"\1\n", rendered, flags=re.MULTILINE) rendered = re.sub(r"(^1\. .+)\n\n(?=1\. )", r"\1\n", rendered, flags=re.MULTILINE) TARGET.write_text(rendered, encoding="utf-8") if __name__ == "__main__": convert()