147 lines
4.9 KiB
Python
147 lines
4.9 KiB
Python
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", "<br>") 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"", ""])
|
||
|
||
# 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()
|