Files
STM32F4-Base/tools/arch_diagram/gen_menu_tree_svg.py
T
wandering1 b49e226815 docs(配图): 菜单树总览改由生成器画,登记钩子扫不到的图
- 新增 tools/arch_diagram/gen_menu_tree_svg.py → docs/images/菜单树总览
  .svg:43 节点 = 9 菜单 + 34 叶,30 已绑页 / 4 未绑 / 16 叶带密码门,
  最深 4 级;读数由脚本现算,不在文档里另抄一份
- 生成器内嵌 NODE_SRC(= ui_menu_tree.txt 的 43 行节点):在仓库里跑会
  与真值逐行互验、不一致 exit 1;generated-fresh 在临时目录重跑读不到
  真值 → 用内嵌表,输出逐字节一致(实测临时目录产物同 sha256)
- 产品需求 §8.2 原先挂的是级联框显示几何图(画的是示例级联,从来不含
  整棵树的节点),换成这张树图,并删掉「该图仍是 42 节点时代所画」的
  补丁注
- 图形绘制流程新增 §2.1:登记 generated-fresh 扫不到的四张图 —— 两张
  tools/refgraph 产物(引用关系图 / _模块,不在 gen_*.py 命名面里)+
  两张手画稿(级联框总览、级联框几何计算)。钩子只扫
  tools/arch_diagram/gen_*.py,这些图改了不会有人报警;再多一张先说清
  为什么不能用生成器
- arch_diagram README 脚本一览加行
2026-10-30 00:25:03 +08:00

459 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 "<text " + head + ">" + esc(body) + "</text>"
def rrect(x, y, w, h, fill, stroke, rx=5, sw=1.2):
return "<rect " + attrs([("x", r1(x)), ("y", r1(y)), ("width", r1(w)),
("height", r1(h)), ("rx", rx), ("fill", fill),
("stroke", stroke), ("stroke-width", sw)]) + "/>"
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 "<line " + attrs(pairs) + "/>"
def svg_header(h):
return ["<?xml version=" + QA + "1.0" + QA + " encoding=" + QA + "UTF-8" + QA + "?>",
"<svg " + attrs([("xmlns", "http://www.w3.org/2000/svg"),
("width", W), ("height", h),
("viewBox", "0 0 " + str(W) + " " + str(h)),
("font-family", FONT)]) + ">",
"<rect " + attrs([("width", W), ("height", h), ("fill", "#ffffff")]) + "/>"]
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("</svg>")
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())