#!/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 ["",
"")
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())