# -*- coding: utf-8 -*- from __future__ import annotations import re from pathlib import Path from docx import Document from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml import OxmlElement from docx.shared import Inches, Pt from docx.text.paragraph import Paragraph from PIL import Image, ImageDraw, ImageFont BASE = Path(r"D:\Projects\Ai-DOP\项目\项目管理\产互联项目管理\交付文档") MARKER = "【原型页面】" W, H = 1200, 680 REMOVE_SECTION_STARTS = ( "补充:原型设计", "补充:系统数据流", "六、核心业务流程", "七、业务数据流", "补充:核心业务流程", "补充:业务数据流", ) HEADING_RE = re.compile(r"^(FUNC-S(\d+)-(\d+(?:~\d+)?))(?:\s+(.*))?$") DOMAIN_MENU = { "manufacturing": "制造建模", "sales": "产销建模", "supply": "供应建模", "warehouse": "仓储建模", "quality": "质量建模", "kanban": "运营指标建模", } def load_fonts(): try: return ( ImageFont.truetype("msyh.ttc", 11), ImageFont.truetype("msyh.ttc", 12), ImageFont.truetype("msyh.ttc", 14), ImageFont.truetype("msyh.ttc", 18), ) except OSError: d = ImageFont.load_default() return d, d, d, d def delete_paragraph(paragraph: Paragraph): el = paragraph._element el.getparent().remove(el) def paragraph_has_image(paragraph: Paragraph) -> bool: xml = paragraph._element.xml return "w:drawing" in xml or "w:pict" in xml or "pic:pic" in xml def is_func_heading(text: str) -> bool: text = text.strip() if re.fullmatch(r"FUNC-S\d+-\d{3}", text): return False return bool(HEADING_RE.match(text)) def func_code(text: str) -> str | None: m = HEADING_RE.match(text.strip()) return m.group(1) if m else None def clean_document(path: Path) -> int: doc = Document(path) removed = 0 i = 0 while i < len(doc.paragraphs): p = doc.paragraphs[i] text = p.text.strip() if any(text.startswith(s) for s in REMOVE_SECTION_STARTS): delete_paragraph(p) removed += 1 while i < len(doc.paragraphs): t = doc.paragraphs[i].text.strip() if not t or re.match(r"^\d+\.", t): delete_paragraph(doc.paragraphs[i]) removed += 1 continue break continue if text == MARKER: delete_paragraph(p) removed += 1 if i < len(doc.paragraphs) and paragraph_has_image(doc.paragraphs[i]): delete_paragraph(doc.paragraphs[i]) removed += 1 if i < len(doc.paragraphs): t = doc.paragraphs[i].text.strip() if t.startswith("图:"): delete_paragraph(doc.paragraphs[i]) removed += 1 continue if text.startswith("图:") and "页面原型" in text: delete_paragraph(p) removed += 1 continue i += 1 doc.save(path) return removed def insert_after(paragraph: Paragraph, text: str = "") -> Paragraph: new_p = OxmlElement("w:p") paragraph._p.addnext(new_p) new_para = Paragraph(new_p, paragraph._parent) if text: new_para.add_run(text) return new_para def rr(draw, xy, radius=6, **kw): draw.rounded_rectangle(xy, radius=radius, **kw) def draw_text(draw, xy, value, font, fill="#444444"): draw.text(xy, value, fill=fill, font=font) def infer_domain(page_path: str) -> str: page_path = page_path.lower() if "quality" in page_path: return "quality" if "kanban" in page_path: return "kanban" for key in ("manufacturing", "sales", "supply", "warehouse"): if key in page_path: return key return "manufacturing" def infer_headers(desc: str) -> list[str]: patterns = [ r"列表字段包括([^。]+)", r"字段包括([^。]+)", r"字段含([^。,]+)", r"列表字段为:([^。]+)", r"指标字段:([^。]+)", ] for pat in patterns: m = re.search(pat, desc) if not m: continue parts = re.split(r"[、,,/]", re.sub(r"\([^)]*\)", "", m.group(1))) headers = [p.strip() for p in parts if p.strip() and len(p.strip()) <= 12][:6] if headers: if "操作" not in headers: headers.append("操作") return headers if "路线" in desc and "工序" in desc: return ["路线编码", "名称", "适用物料", "版本", "状态", "操作"] return ["编码", "名称", "状态", "更新时间", "操作"] def infer_filters(desc: str) -> list[str]: m = re.search(r"支持按([^筛选]+)筛选", desc) if m: out = [p.strip() for p in re.split(r"[、/]", m.group(1)) if p.strip()][:4] if out: return out if "产线" in desc and "员工" in desc: return ["产线", "员工"] return ["关键字", "公司", "工厂"] def infer_add_label(title: str) -> str: return "新增" + title.replace("管理", "") if "管理" in title else "新增" def infer_form_fields(title: str) -> list[str]: if "工单控制参数" in title: return ["默认批次规则", "下达前置时间", "其他控制参数"] return ["参数项", "参数值", "说明"] def render_crud_png(path, *, module, menu, title, subtitle, filters, headers, add_label, row): xs, s, m, b = load_fonts() img = Image.new("RGB", (W, H), "#fafafa") draw = ImageDraw.Draw(img) draw.rectangle([0, 0, W, 44], fill="#ffffff", outline="#d9d9d9") draw_text(draw, (190, 14), f"Ai-DOP / {module}", s, "#444444") draw.rectangle([0, 44, 170, H], fill="#1f2937") draw_text(draw, (16, 72), module, s, "#ffffff") draw_text(draw, (32, 98), menu, xs, "#cbd5e1") draw.rectangle([170, 44, W, H], fill="#f3f4f6") rr(draw, [190, 58, W - 20, 118], radius=8, fill="#111827") draw_text(draw, (210, 72), title[:18], b, "#ffffff") draw_text(draw, (210, 98), subtitle[:70], xs, "#cbd5e1") x = 210 for label in filters[:4]: draw_text(draw, (x, 132), label[:8], xs, "#444444") rr(draw, [x, 148, x + 110, 174], radius=4, outline="#d9d9d9", fill="#ffffff") x += 130 bx = 210 for label, color in [("查询", "#1677ff"), ("重置", "#ffffff"), (add_label[:8], "#52c41a")]: w = max(64, len(label) * 14 + 24) rr(draw, [bx, 188, bx + w, 216], radius=4, fill=color, outline=color if color != "#ffffff" else "#d9d9d9") draw_text(draw, (bx + 10, 194), label, s, "#ffffff" if color != "#ffffff" else "#444444") bx += w + 10 rr(draw, [190, 232, W - 20, H - 20], radius=8, fill="#ffffff", outline="#d9d9d9") draw.rectangle([190, 232, W - 20, 266], fill="#fafafa", outline="#d9d9d9") col_w = max(80, (W - 230) // max(len(headers), 1)) for i, h in enumerate(headers): draw_text(draw, (200 + i * col_w, 242), h[:8], xs, "#333333") if row: draw.line([190, 276, W - 20, 276], fill="#f0f0f0", width=1) for i, cell in enumerate(row[: len(headers)]): draw_text(draw, (200 + i * col_w, 286), str(cell)[:10], xs, "#666666") path.parent.mkdir(parents=True, exist_ok=True) img.save(path, format="PNG") def render_form_png(path, *, module, menu, title, subtitle, fields): xs, s, m, b = load_fonts() img = Image.new("RGB", (W, H), "#fafafa") draw = ImageDraw.Draw(img) draw.rectangle([170, 44, W, H], fill="#f3f4f6") rr(draw, [190, 58, W - 20, 118], radius=8, fill="#111827") draw_text(draw, (210, 72), title, b, "#ffffff") draw_text(draw, (210, 98), subtitle[:70], xs, "#cbd5e1") rr(draw, [190, 132, W - 20, H - 30], radius=8, fill="#ffffff", outline="#d9d9d9") y = 150 for label in fields: draw_text(draw, (210, y), label, xs, "#444444") rr(draw, [210, y + 16, 520, y + 42], radius=4, outline="#d9d9d9", fill="#ffffff") y += 56 rr(draw, [210, H - 58, 274, H - 30], radius=4, fill="#1677ff") draw_text(draw, (226, H - 52), "保存", s, "#ffffff") path.parent.mkdir(parents=True, exist_ok=True) img.save(path, format="PNG") def render_kpi_graph_png(path, title, subtitle): xs, s, m, b = load_fonts() img = Image.new("RGB", (W, H), "#fafafa") draw = ImageDraw.Draw(img) rr(draw, [190, 58, W - 20, 118], radius=8, fill="#111827") draw_text(draw, (210, 72), title, b, "#ffffff") draw_text(draw, (210, 98), subtitle[:70], xs, "#cbd5e1") rr(draw, [190, 132, W - 340, H - 20], radius=8, fill="#ffffff", outline="#d9d9d9") draw_text(draw, (210, 148), "L1/L2 指标关系图谱", s, "#333333") rr(draw, [W - 310, 132, W - 20, H - 20], radius=8, fill="#ffffff", outline="#d9d9d9") draw_text(draw, (W - 290, 148), "节点编辑抽屉", s, "#333333") img.save(path, format="PNG") def render_kpi_module_png(path, title, subtitle): xs, s, m, b = load_fonts() img = Image.new("RGB", (W, H), "#fafafa") draw = ImageDraw.Draw(img) rr(draw, [190, 58, W - 20, 118], radius=8, fill="#111827") draw_text(draw, (210, 72), title, b, "#ffffff") draw_text(draw, (210, 98), subtitle[:70], xs, "#cbd5e1") rr(draw, [190, 132, 520, H - 20], radius=8, fill="#ffffff", outline="#d9d9d9") draw_text(draw, (210, 148), "L1/L2 指标勾选", s, "#333333") rr(draw, [540, 132, W - 20, H - 20], radius=8, fill="#ffffff", outline="#d9d9d9") draw_text(draw, (560, 148), "布局预览", s, "#333333") img.save(path, format="PNG") def parse_s0_funcs(doc: Document) -> dict[str, dict]: funcs: dict[str, dict] = {} current = None desc_parts: list[str] = [] for p in doc.paragraphs: text = p.text.strip() if is_func_heading(text): if current: funcs[current]["desc"] = "".join(desc_parts) m = HEADING_RE.match(text) current = m.group(1) funcs[current] = { "title": (m.group(4) or "").strip() or current, "desc": "", "req": "", "page": "", } desc_parts = [] continue if not current: continue if text.startswith("**对应需求**:"): funcs[current]["req"] = text.split(":", 1)[1].strip() elif text.startswith("**前端页面**:") or text.startswith("**前端实现**:"): funcs[current]["page"] = text.split(":", 1)[1].strip(" `") elif text.startswith("**功能描述**:"): continue elif text.startswith("FUNC-") or text.startswith("补充:") or text.startswith("【") or text.startswith("图:"): continue elif text.startswith("**") or text in {"功能编号", "功能名称", "页面文件"}: continue elif text: desc_parts.append(text) if current: funcs[current]["desc"] = "".join(desc_parts) return funcs def build_s0_png(code: str, info: dict) -> Path: title = info["title"] desc = info["desc"] req = info.get("req") or code.replace("FUNC", "REQ") page = info.get("page", "") domain = infer_domain(page) menu = DOMAIN_MENU.get(domain, "数据建模") subtitle = f"{req} · {page}" if page else req out = BASE / "S0" / "prototype" / f"{code}.png" if "表单形式" in desc or code == "FUNC-S0-018": render_form_png(out, module="S0 运营建模", menu=menu, title=title, subtitle=subtitle, fields=infer_form_fields(title)) return out if code in ("FUNC-S0-055", "FUNC-S0-056"): render_kpi_graph_png(out, title, subtitle) return out if code == "FUNC-S0-058": render_crud_png( out, module="S0 运营建模", menu=menu, title=title, subtitle=subtitle, filters=["模块", "层级", "关键字"], headers=["指标编码", "显示名称", "模块", "层级", "启用", "操作"], add_label="新增指标", row=["S1_L1_001", "订单评审周期", "S1", "L1", "是", "编辑"], ) return out if code in ("FUNC-S0-057", "FUNC-S0-059"): render_kpi_module_png(out, title, subtitle) return out headers = infer_headers(desc) filters = infer_filters(desc) row = [] for h in headers: if h == "操作": row.append("编辑") elif "编码" in h or h.endswith("号"): row.append("001") elif "名称" in h or "描述" in h: row.append("示例") elif "状态" in h or "启用" in h or "确认" in h: row.append("是") else: row.append("-") render_crud_png( out, module="S0 运营建模", menu=menu, title=title, subtitle=subtitle, filters=filters, headers=headers, add_label=infer_add_label(title), row=row, ) return out def insert_prototypes(doc_path: Path, mapping: dict[str, Path]): doc = Document(doc_path) headings = [(i, func_code(p.text)) for i, p in enumerate(doc.paragraphs) if is_func_heading(p.text)] inserts = [] for idx, (start, code) in enumerate(headings): if not code or code not in mapping: continue end = headings[idx + 1][0] if idx + 1 < len(headings) else len(doc.paragraphs) inserts.append((end - 1, code, mapping[code])) for insert_at, code, png in reversed(inserts): anchor = doc.paragraphs[insert_at] cap = insert_after(anchor, MARKER) cap.alignment = WD_ALIGN_PARAGRAPH.CENTER pic_p = insert_after(cap) pic_p.alignment = WD_ALIGN_PARAGRAPH.CENTER pic_p.add_run().add_picture(str(png), width=Inches(6.2)) note = insert_after(pic_p, f"图:{code}") note.alignment = WD_ALIGN_PARAGRAPH.CENTER for r in note.runs: r.font.size = Pt(9) doc.save(doc_path) def main(): for mod in ["S0", "S1", "S2"]: for kind in ["功能设计说明", "需求说明书"]: n = clean_document(BASE / mod / f"{mod}-{kind}.docx") print(f"cleaned {mod}-{kind}: {n}") s0_doc = Document(BASE / "S0" / "S0-功能设计说明.docx") funcs = parse_s0_funcs(s0_doc) mapping = {code: build_s0_png(code, info) for code, info in funcs.items()} insert_prototypes(BASE / "S0" / "S0-功能设计说明.docx", mapping) print(f"S0 prototypes: {len(mapping)}") from _insert_func_prototypes import build_templates, process_doc templates = build_templates(BASE / "_prototype_templates") for mod in ["S1", "S2"]: process_doc(mod, templates) if __name__ == "__main__": main()