#!/usr/bin/env python3 # -*- coding: utf-8 -*- """生成 docs/images/菜单树总览.svg —— 菜单树的层级、绑页与密码门鸟瞰图。 数据源 = ui_menu_tree.txt(真值,tools/menu_gen/gen_menu.py 的输入)。本脚本内嵌同一张 节点表 NODE_SRC,两种运行环境都要有确定行为: · 在仓库里跑:先与 ui_menu_tree.txt 的节点行逐行互验,不一致直接 exit 1 打印差异 —— 改了菜单树必须同步内嵌表,图才会跟着变(漂移摆在台面上,而不是让 SVG 悄悄失真); · 在临时目录跑(pre-commit 的 generated-fresh 钩子把脚本拷出去重跑):读不到输入 文件 → 退回内嵌表 → 输出逐字节一致。 """ import html import pathlib import sys import xml.etree.ElementTree as ET ROOT = pathlib.Path(__file__).resolve().parents[2] TREE_FILE = ROOT / "ui_menu_tree.txt" OUT = ROOT / "docs" / "images" / "菜单树总览.svg" # ui_menu_tree.txt 的节点行(缩进 2 空格 = 一层;#P = 需密码;#page=x = 已绑页面模块)。 # 改菜单树 → 同步这张表 → 重跑本脚本 → 脚本与产物同一笔提交。 NODE_SRC = [ "主界面", " 装置信息 #page=device_info", " 实时数据", " 交流量 #page=ac", " 直流量 #page=dc", " 遥信量 #page=yx", " 参数定值", " 整定", " 参数 #P #page=view_param", " 定值 #P #page=view_set", " 控制字 #P #page=view_word", " 软压板 #P #page=view_plate", " 查看", " 参数 #page=view_param", " 定值 #page=view_set", " 控制字 #page=view_word", " 软压板 #page=view_plate", " 三遥设置", " 遥测死区 #P #page=deadband", " 遥测系数 #P #page=calib", " 遥信类型 #P", " 遥信防抖 #P", " 双点遥信 #P", " 装置维护", " 时钟设置 #P #page=clock", " 强制复归 #P #page=reset", " 手动录波 #P #page=wave_manual", " 清除记录 #P #page=clear", " 通讯参数", " 通讯设置 #P #page=comm", " 网口设置 #P #page=net_cfg", " SNTP设置 #P", " 记录查询", " SOE记录 #page=soe", " 事故记录 #page=accident", " 操作记录 #page=oper", " 保护告警 #page=alarm", " 保护启动 #page=start", " 遥控记录 #page=yk", " 自检记录 #page=selftest", " 运行记录 #page=runlog", " 运行报告 #page=runreport", " 录波回放 #page=wave_play", ] # ---- 版面 ---- FS_LEAF = 12 FS_MENU = 12 FS_ROOT = 13 FS_PAGE = 9 PITCH = 18 ROW_TOP = 96 X_COL = (28, 150, 306, 474) TRUNK_OFF = 22 LEAF_TEXT = 14 PAD_X = 9 BAND_X = X_COL[1] - 12 BAND_R = 624 PANEL_X = 640 W = 990 TREE_LIMIT = 630 INK = "#0f172a" MUTED = "#475569" FAINT = "#94a3b8" LINE = "#334155" RULE = "#cbd5e1" C_BLUE = "#1d4ed8" BG_BLUE = "#eff6ff" C_VIOLET = "#6d28d9" BG_VIOLET = "#f5f3ff" C_AMBER = "#b45309" C_ROSE = "#be123c" C_GREEN = "#15803d" BG_GREEN = "#f0fdf4" BG_GRAY = "#f8fafc" FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif" _over = [] def emit(msg): """控制台可能是 GBK:中文/符号打印不能把生成器打崩(假红 = 钩子重跑失败)。""" enc = getattr(sys.stdout, "encoding", None) or "utf-8" safe = str(msg).encode(enc, errors="replace").decode(enc, errors="replace") sys.stdout.write(safe + "\n") def tw(text, fs): """近似字宽:CJK 约 1.0 em,其余 0.58 em。""" return sum(fs * (1.0 if ord(c) > 0x2E80 else 0.58) for c in str(text)) def esc(text): return html.escape(str(text), quote=True) def fit(x_right, limit, what): if x_right > limit: _over.append("%s: %.0f > %.0f" % (what, x_right, limit)) def node_lines(raw): """取节点行:丢空行与 # 指令行(指令改了不影响图的形状)。""" out = [] for ln in raw.splitlines(): ln = ln.rstrip("\r") if not ln.strip() or ln.strip().startswith("#"): continue out.append(ln) return out def verify_src(): """仓库里跑 = 与真值互验;临时目录里跑 = 静默用内嵌表。""" if not TREE_FILE.exists(): emit("note: 未找到 ui_menu_tree.txt(临时目录重跑场景)→ 用内嵌 NODE_SRC") return 0 live = node_lines(TREE_FILE.read_text(encoding="utf-8-sig")) if live == list(NODE_SRC): emit("互验 OK:%d 行节点与 ui_menu_tree.txt 一致" % len(live)) return 0 emit("[FAIL] ui_menu_tree.txt 与本脚本内嵌 NODE_SRC 不一致 —— 图会失真,请同步内嵌表后重跑") for i in range(max(len(live), len(NODE_SRC))): a = live[i] if i < len(live) else "<缺>" b = NODE_SRC[i] if i < len(NODE_SRC) else "<缺>" if a != b: emit(" 第 %d 行:真值 %r / 内嵌 %r" % (i + 1, a, b)) return 1 def parse(lines): """节点行 → (根, 文档序节点表)。""" root = None docs = [] stack = [] for ln in lines: depth = (len(ln) - len(ln.lstrip(" "))) // 2 page = None pwd = False keep = [] for tok in ln.strip().split(): if tok == "#P": pwd = True elif tok.startswith("#page="): page = tok[len("#page="):] else: keep.append(tok) node = {"depth": depth, "label": " ".join(keep), "page": page, "pwd": pwd, "kids": [], "y": 0.0, "w": 0.0} docs.append(node) while stack and stack[-1]["depth"] >= depth: stack.pop() if stack: stack[-1]["kids"].append(node) else: root = node stack.append(node) return root, docs def layout(root): """叶按文档序占行;菜单 y = 首末子节点 y 的中点。返回叶行数。""" state = [0] def walk(n): if not n["kids"]: n["y"] = ROW_TOP + state[0] * PITCH state[0] += 1 return for k in n["kids"]: walk(k) n["y"] = (n["kids"][0]["y"] + n["kids"][-1]["y"]) / 2.0 walk(root) return state[0] def yspan(n): """子树占用的 y 区间。""" if not n["kids"]: return n["y"], n["y"] lo, _ = yspan(n["kids"][0]) _, hi = yspan(n["kids"][-1]) return lo, hi def tally(docs): menus = [n for n in docs if n["kids"]] leaves = [n for n in docs if not n["kids"]] unbound = [n for n in leaves if not n["page"]] return {"total": len(docs), "menus": len(menus), "leaves": len(leaves), "bound": len(leaves) - len(unbound), "unbound": unbound, "pwd": len([n for n in leaves if n["pwd"]]), "depth": max(n["depth"] for n in docs) + 1} QA = chr(34) # 双引号:拼 SVG 属性值用,免得在字面量里嵌引号 def r1(v): """一位小数(决定输出逐字节稳定)。""" return format(float(v), ".1f") def attrs(pairs): out = [] for key, val in pairs: out.append(key + "=" + QA + str(val) + QA) return " ".join(out) def txt(x, y, body, fs, fill, weight="normal", anchor="start"): head = attrs([("x", r1(x)), ("y", r1(y)), ("font-size", fs), ("fill", fill), ("font-weight", weight), ("text-anchor", anchor)]) return "" + esc(body) + "" def rrect(x, y, w, h, fill, stroke, rx=5, sw=1.2): return "" def line(x1, y1, x2, y2, stroke, sw=1.2, dash=None): pairs = [("x1", r1(x1)), ("y1", r1(y1)), ("x2", r1(x2)), ("y2", r1(y2)), ("stroke", stroke), ("stroke-width", sw)] if dash: pairs.append(("stroke-dasharray", dash)) return "" def svg_header(h): return ["", "", ""] def draw_tree(docs, s): """分组带 -> 连接线 -> 方框与叶(后画的压在前面之上)。""" band = 0 for n in docs: if n["depth"] != 1: continue lo, hi = yspan(n) fill = BG_GRAY if band % 2 == 0 else "#ffffff" s.append(rrect(BAND_X, lo - 9, BAND_R - BAND_X, (hi - lo) + 18, fill, "none", 6, 0)) band += 1 for n in docs: if n["depth"] == 2 and n["kids"]: lo, hi = yspan(n) s.append(rrect(X_COL[2] - 10, lo - 9, BAND_R - (X_COL[2] - 10), (hi - lo) + 18, BG_VIOLET, "none", 5, 0)) for n in docs: if not n["kids"]: continue d = n["depth"] tx = X_COL[d + 1] - TRUNK_OFF s.append(line(tx, n["kids"][0]["y"], tx, n["kids"][-1]["y"], RULE)) s.append(line(X_COL[d] + n["w"], n["y"], tx, n["y"], RULE)) for k in n["kids"]: kx = X_COL[k["depth"]] if k["kids"] else X_COL[k["depth"]] + 3 s.append(line(tx, k["y"], kx, k["y"], RULE)) for n in docs: if n["kids"]: d = n["depth"] fs = FS_ROOT if d == 0 else FS_MENU if d == 0: ink, fill, stroke, bh = C_BLUE, BG_BLUE, C_BLUE, 26 elif d == 1: ink, fill, stroke, bh = INK, "#ffffff", LINE, 22 else: ink, fill, stroke, bh = C_VIOLET, "#ffffff", C_VIOLET, 20 s.append(rrect(X_COL[d], n["y"] - bh / 2.0, n["w"], bh, fill, stroke, 5)) s.append(txt(X_COL[d] + n["w"] / 2.0, n["y"] + fs * 0.36, n["label"], fs, ink, "bold", "middle")) if d + 1 < len(X_COL): fit(X_COL[d] + n["w"], X_COL[d + 1] - TRUNK_OFF - 4, "menu " + n["label"]) for n in docs: if n["kids"]: continue x = X_COL[n["depth"]] y = n["y"] wave = n["label"] in ("手动录波", "录波回放") bound = bool(n["page"]) lw = tw(n["label"], FS_LEAF) if wave: s.append(rrect(x + 1, y - 8.5, LEAF_TEXT + lw + 9, 17, BG_GREEN, C_GREEN, 4, 0.9)) if bound: s.append(line(x + 2, y, x + 9, y, RULE)) else: s.append(line(x + 2, y, x + 9, y, C_ROSE, 1.2, "3,2")) ink = INK if not bound: ink = C_ROSE elif wave: ink = C_GREEN s.append(txt(x + LEAF_TEXT, y + 4, n["label"], FS_LEAF, ink)) cur = x + LEAF_TEXT + lw if bound: s.append(txt(cur + 10, y + 3.5, n["page"], FS_PAGE, FAINT)) cur += 10 + tw(n["page"], FS_PAGE) if n["pwd"]: s.append(txt(cur + 9, y + 3.5, "P", 9, C_AMBER, "bold")) cur += 10 + tw("P", 9) fit(cur, TREE_LIMIT, "leaf " + n["label"]) def panel_rows(c): rows = [{"k": "h", "t": "本图读数"}] rows.append({"k": "t", "t": "节点 %d = 菜单 %d + 叶 %d" % (c["total"], c["menus"], c["leaves"])}) rows.append({"k": "t", "t": "已绑页 %d · 未绑页 %d · 密码门 %d 叶" % (c["bound"], len(c["unbound"]), c["pwd"])}) rows.append({"k": "t", "t": "最深 %d 级(主界面 → 叶)" % c["depth"]}) rows.append({"k": "s", "t": ""}) rows.append({"k": "h", "t": "怎么看"}) rows.append({"k": "sw", "fill": BG_BLUE, "stroke": C_BLUE, "t": "根:主界面本身,不是一个页面"}) rows.append({"k": "sw", "fill": "#ffffff", "stroke": LINE, "t": "白框:一级菜单,可展开、不绑页"}) rows.append({"k": "sw", "fill": BG_VIOLET, "stroke": C_VIOLET, "t": "紫底:二级菜单与它那一支的行带"}) rows.append({"k": "sw", "fill": BG_GREEN, "stroke": C_GREEN, "t": "绿底:录波两叶,已绑页面模块"}) rows.append({"k": "sw", "fill": "#ffffff", "stroke": C_ROSE, "dash": "3,2", "t": "红字 + 虚线:未绑页的叶,进去只有菜单"}) rows.append({"k": "t", "t": "灰色小字 = 页面模块名(页面注册表的键)"}) rows.append({"k": "t", "t": "行末的 P = 进这一叶要先过密码"}) rows.append({"k": "t", "t": "整定 与 查看 复用同一批页面模块"}) rows.append({"k": "s", "t": ""}) rows.append({"k": "h", "t": "未绑页的 %d 叶" % len(c["unbound"])}) for n in c["unbound"]: par = n["parent"]["label"] if n["parent"] else "?" rows.append({"k": "t", "t": "· " + n["label"] + "(" + par + " 下)"}) return rows def draw_panel(c, s): """右侧读数与图例:数字全部现算,文档里不必再抄一份。""" rows = panel_rows(c) x = PANEL_X pw = W - 30 - x lh = 18.0 hh = 25.0 gap = 12.0 body_h = 0.0 for r in rows: if r["k"] == "h": body_h += hh elif r["k"] == "s": body_h += gap else: body_h += lh y0 = ROW_TOP - 24 s.append(rrect(x - 16, y0, pw + 30, body_h + 44, "#ffffff", RULE, 8)) y = y0 + 26 for r in rows: if r["k"] == "s": y += gap continue if r["k"] == "h": s.append(txt(x, y + 4, r["t"], 12.5, INK, "bold")) fit(x + tw(r["t"], 12.5), W - 40, "panel " + r["t"]) y += hh continue tx = x if r["k"] == "sw": sw = r.get("dash") s.append(rrect(x, y - 8, 15, 12, r["fill"], r["stroke"], 3, 1.0)) if sw: s.append(line(x, y - 2, x + 15, y - 2, r["stroke"], 1.0, sw)) tx = x + 22 s.append(txt(tx, y + 2, r["t"], 10.5, MUTED)) fit(tx + tw(r["t"], 10.5), W - 40, "panel " + r["t"][:8]) y += lh def main(): if verify_src(): return 1 root, docs = parse(NODE_SRC) for n in docs: n["parent"] = None for n in docs: for k in n["kids"]: k["parent"] = n leaf_rows = layout(root) for n in docs: if n["kids"]: fs = FS_ROOT if n["depth"] == 0 else FS_MENU n["w"] = 2 * PAD_X + tw(n["label"], fs) c = tally(docs) h = ROW_TOP + leaf_rows * PITCH + 76 s = svg_header(h) s.append(txt(28, 40, "菜单树总览(层级 · 绑页 · 密码门)", 17, INK, "bold")) s.append(txt(28, 61, "方框 = 菜单(可展开);细线 = 叶(对应一个页面模块,或一处空缺)", 11, MUTED)) draw_tree(docs, s) draw_panel(c, s) foot = ROW_TOP + leaf_rows * PITCH + 42 note = ("真值 = ui_menu_tree.txt(tools/menu_gen/gen_menu.py 的输入);本图由 " "tools/arch_diagram/gen_menu_tree_svg.py 生成 —— 改菜单树要同步脚本内的 " "NODE_SRC 重跑,勿手改 SVG") s.append(txt(28, foot, note, 9.5, FAINT)) fit(28 + tw(note, 9.5), W - 20, "foot") s.append("") body = "\n".join(s) + "\n" OUT.parent.mkdir(parents=True, exist_ok=True) OUT.write_bytes(body.encode("utf-8")) try: ET.fromstring(body) except ET.ParseError as exc: emit("XML FAIL: %s" % exc) return 1 emit("SVG %dx%d 节点 %d 菜单 %d 叶 %d 绑页 %d 未绑 %d 密码门 %d 最深 %d 级" % (W, h, c["total"], c["menus"], c["leaves"], c["bound"], len(c["unbound"]), c["pwd"], c["depth"])) if _over: for item in _over: emit("OVERFLOW " + item) emit("[FAIL] 版面溢出 %d 处 —— 调版面常量后重跑" % len(_over)) return 1 emit("OVERFLOW 0 → " + str(OUT.relative_to(ROOT))) return 0 if __name__ == "__main__": sys.exit(main())