first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
@@ -0,0 +1,273 @@
---
name: insight-diagram
description: 为任意项目生成 UML 图、架构图和流程图。分析代码库后让用户选择要生成的图表类型,使用 architecture-diagram skill 渲染为 HTML+SVG,保存到 docs/ 目录。适用于任何软件项目的文档可视化。
---
# Insight Diagram — 项目图表生成技能
分析任意代码库,自动生成 UML 14种图 + 架构图 + 流程图,使用 `/architecture-diagram` 渲染为 HTML+SVG。
## 图表分类与清单
### 结构性图形 (Structural Diagrams — 静态)
描述系统的物理组成和静态结构。
| 编号 | 图表类型 | 英文标识 | 关注点 |
|------|---------|---------|--------|
| 1 | 系统架构图 | architecture | 组件关系、全局视角(非UML,最常用) |
| 2 | 类图 | class | 定义类、属性、操作及关系 |
| 3 | 对象图 | object | 特定时刻的对象实例及其关系 |
| 4 | 组件图 | component | 系统组件及其依赖关系 |
| 5 | 部署图 | deployment | 物理硬件、节点及软件部署 |
| 6 | 包图 | package | 将模型元素分组组织 |
| 7 | 复合结构图 | composite-structure | 类的内部结构 |
| 8 | 剖面图 | profile | 扩展UML元模型、自定义构造型 |
### 行为性图形 (Behavioral Diagrams — 动态)
描述系统与外部参与者或系统内部的交互过程。
| 编号 | 图表类型 | 英文标识 | 关注点 |
|------|---------|---------|--------|
| 9 | 流程图 | flowchart | 主流程与分支(非UML,最常用) |
| 10 | 用例图 | usecase | 从用户角度展示系统功能 |
| 11 | 活动图 | activity | 过程的流程或步骤 |
| 12 | 状态机图 | state-machine | 对象生命周期的状态变迁 |
| 13 | 序列图 | sequence | 按时间顺序展示对象间交互 |
| 14 | 通信图 | communication | 侧重于对象间的组织关系 |
| 15 | 定时图 | timing | 侧重于状态变化的时间约束 |
| 16 | 交互概览图 | interaction-overview | 结合活动图和时序图 |
| 17 | 泳道图 | swimlane | 跨组件/角色职责流程(活动图变体) |
## 示例参考
本技能的 `examples/` 目录包含 13 个已完成的图表 HTML 文件,作为视觉样式和内容结构的参考模板。**生成任何图表前,必须先阅读对应的示例文件**,以确保风格一致、结构规范。
### 示例文件清单
| 文件 | 图表类型 | 英文标识 |
|------|---------|---------|
| `examples/architecture.html` | 系统架构图 | architecture |
| `examples/class.html` | 类图 | class |
| `examples/object.html` | 对象图 | object |
| `examples/component.html` | 组件图 | component |
| `examples/deployment.html` | 部署图 | deployment |
| `examples/flowchart.html` | 流程图 | flowchart |
| `examples/usecase.html` | 用例图 | usecase |
| `examples/activity.html` | 活动图 | activity |
| `examples/sequence.html` | 序列图 | sequence |
| `examples/communication.html` | 通信图 | communication |
| `examples/dfd.html` | 数据流图 | dfd |
| `examples/interaction-overview.html` | 交互概览图 | interaction-overview |
| `examples/swimlane.html` | 泳道图 | swimlane |
### 参考规则
1. **生成前必读**: 调用 `/architecture-diagram` 前,先用 Read 工具阅读对应类型的示例文件,从中提取:
- SVG 布局策略(节点间距、分组方式、箭头走向)
- 节点样式层级(核心节点 accent 高亮、普通节点实线边框、可选节点虚线边框)
- 标注风格(阶段标签、Legend 图例、卡片摘要)
- 信息密度(每个节点显示多少字段/属性)
2. **结构对齐**: 生成的图表应与示例保持相同的结构层次:
- 页面顶部:标题 + 副标题 + 图表类型说明
- 中间主体:SVG 图表区域(带浅色边框容器)
- 底部:信息摘要卡片 + 页脚
3. **内容替换而非照搬**: 示例中的业务数据(NovaShield 风控系统)是虚构的参考案例,生成时需替换为目标项目的真实架构信息。只参考布局和样式,不复制业务内容。
4. **无对应示例的类型**: 对于包图 (package)、复合结构图 (composite-structure)、剖面图 (profile)、状态机图 (state-machine)、定时图 (timing) 这 5 种没有示例文件的图表类型,参考最相近的已有示例(如包图参考组件图,状态机图参考活动图),并沿用相同的视觉语言。
## 执行流程
### 步骤 1:分析代码库
读取项目关键文件,提取架构信息:
1. 读取项目根目录的 `CLAUDE.md`(如存在)获取项目概览
2. 读取各子目录的 `CLAUDE.md`(如存在)获取模块细节
3. 用 Glob 扫描源码文件结构(`**/*.go`, `**/*.py`, `**/*.ts` 等)
4. 读取入口文件(`main.go`, `app.py`, `index.ts` 等)识别顶层组件
5. 用 Grep 搜索关键模式:接口定义、函数签名、依赖注入、配置项
从以上信息中提炼出:
- **组件清单**: 服务、模块、外部依赖
- **关系图**: 谁调用谁、谁依赖谁、数据流向
- **核心类型**: 结构体/类、接口、枚举
- **流程**: 主业务流程、异常处理流程
- **部署**: 进程、中间件、外部服务
### 步骤 2:选择图表
使用 AskUserQuestion 让用户选择要生成的图表(multiSelect: true),分4组展示:
**第1组 — 结构性图形(静态):**
- 系统架构图 (architecture)
- 类图 (class)
- 对象图 (object)
- 组件图 (component)
**第2组 — 结构性图形续 + 部署:**
- 部署图 (deployment)
- 包图 (package)
- 复合结构图 (composite-structure)
- 剖面图 (profile)
**第3组 — 行为性图形(动态):**
- 流程图 (flowchart)
- 用例图 (usecase)
- 活动图 (activity)
- 状态机图 (state-machine)
**第4组 — 交互图 + 常用非UML:**
- 序列图 (sequence)
- 通信图 (communication)
- 交互概览图 (interaction-overview)
- 泳道图 (swimlane)
- 全部生成 (all)
默认推荐:architecture + sequence + flowchart
### 步骤 3:逐个生成
对每个选中的图表类型:
1. **先读示例**: 用 Read 工具阅读 `examples/<标识>.html`(如 `examples/architecture.html`),提取布局模式、节点样式、标注方式
2. 根据步骤 1 提取的架构信息,整理出该图表应展示的元素和关系
3. 调用 `/architecture-diagram` skill,传入图表类型、标题、内容描述、输出路径,**必须指定 light 风格**
4. 输出文件保存到 `docs/<标识>.html`(如 `docs/architecture.html`
5. **生成后必须 review**: 运行几何校验脚本,按结果修正后再继续下一张(见下方「步骤 3.5」)
6. 简要报告完成状态
**生成规则:**
- **风格**: 必须使用 light Claude 风格(暖白背景 #FAF9F6、terracotta/sage/plum/rose 配色、Inter 字体、白色卡片容器),与 Anthropic Claude 品牌视觉一致
- **防遮盖**: 所有 SVG 元素(节点、箭头、标签)不得互相遮盖。具体做法:
- 计算每个元素的边界框,确保无重叠
- 箭头绘制在节点下方(SVG 中先画箭头再画节点)
- 节点间留足间距(垂直最少 40px,水平最少 30px)
- 文字不超出所在节点边界,超长文字截断或换行
- 连接线的标签放置在线段中点偏移处,避免覆盖线段或节点
- 如果元素过多导致图表拥挤,拆分为多个子图或缩小元素尺寸
批量生成顺序(宏观→微观):
architecture → component → deployment → package → composite-structure → profile → class → object → usecase → flowchart → activity → state-machine → swimlane → sequence → communication → timing → interaction-overview
### 步骤 3.5:几何 review(每张图生成后必做)
生成的 SVG 常见三类几何缺陷,必须用脚本逐张校验并修正:
```bash
python3 skills/insight-diagram/scripts/review_svg.py docs/<标识>.html --min-gap 8
# 批量: python3 skills/insight-diagram/scripts/review_svg.py docs/*.html --min-gap 8
```
脚本检查(与三条核心要求一一对应):
1. **箭头落点**:每个带箭头的端点必须恰好落在目标框/椭圆/菱形的**边缘线**上(容差 6px)。
- `ERROR 深入框内`:端点穿入框内部 >8px → 缩短连线,让它止于边缘。
- `WARNING 空接`:端点悬空、距最近框 >8px 且不汇入任何其它连线 → 把端点对齐到框边或汇合点。
- 合法情形:端点落在框边、生命线、或与另一条连线交汇(分支/汇聚)——脚本不会误报。
2. **框重叠**`ERROR`):非嵌套的两个框在水平、垂直两个方向都有交叠 → 必须移开其中一个。嵌套(一个完全包住另一个,如分组边界框包子节点)是允许的。
3. **框间距**`WARNING`):投影相邻的两框净间距 < `--min-gap`(默认 8px)→ 拉开距离。
处理原则:
- **ERROR 必须修复**后再进入下一张;修完重跑脚本确认归零。
- **WARNING 逐条核对**:序列图生命线底部的消息、泳道边界、紧贴的分组等可能是设计本意,确认无误可保留;其余应调整坐标。
- 修正方式是直接编辑 `docs/<标识>.html` 里对应的 `<rect>/<line>/<path>` 坐标,而非重新生成整张图。
- 退出码:有 ERROR 返回 1,干净返回 0;CI 中可加 `--strict` 让 WARNING 也阻断。
### 步骤 4:报告
全部完成后输出:
- 生成的文件列表
- 每个图表的简要描述
## 各图表的内容指南
### 系统架构图 (architecture) — 非UML,最常用
- 展示系统顶层组件及其连接关系
- 区分内部模块与外部依赖
- 标注核心数据流方向
### 类图 (class)
- 核心类型为类节点(名称+字段+方法)
- 继承、组合、依赖关系
- 接口与实现分离
- 限制在 10-15 个核心类型
### 对象图 (object)
- 选取一个典型运行时场景
- 展示对象实例及其属性值
- 对象间的链接关系
### 组件图 (component)
- 每个组件为一个节点
- 箭头表示依赖/调用方向
- 标注接口名称
### 部署图 (deployment)
- 物理节点(服务器、容器、Serverless)
- 中间件(消息队列、缓存、数据库)
- 外部服务(第三方 API
- 标注通信协议
### 包图 (package)
- 按模块/命名空间分组
- 包间依赖关系
- 体现分层架构
### 复合结构图 (composite-structure)
- 类/组件的内部结构
- 部件(Part)与连接器(Connector)
- 端口(Port)与接口
### 剖面图 (profile)
- 自定义构造型(Stereotype)
- 扩展元模型的标签定义(Tagged Values)
- 领域特定建模约束
### 流程图 (flowchart) — 非UML,最常用
- 主流程 + 关键分支
- 失败/异常路径
- 起止节点清晰
### 用例图 (usecase)
- 参与者(人/外部系统)
- 用例椭圆
- include/extend 关系
### 活动图 (activity)
- 阶段/步骤为活动节点
- 并行分支用 fork/join
- 决策点用菱形
### 状态机图 (state-machine)
- 对象的关键状态
- 触发状态变迁的事件
- 动作/守卫条件
- 初始态和终态
### 序列图 (sequence)
- 参与者为纵向生命线
- 水平箭头为消息调用
- 标注关键返回值
- 关注 2-5 个核心交互场景
### 通信图 (communication)
- 组件为节点,消息为连线
- 标注消息序号
- 强调协作关系而非时序
### 定时图 (timing)
- 时间轴横向展开
- 状态变化的时间约束
- 持续时间标注
### 交互概览图 (interaction-overview)
- 控制流节点内嵌交互片段
- 展示条件分支和循环
- 宏观概览各交互场景
### 泳道图 (swimlane) — 活动图变体
- 按组件/角色分泳道
- 流程步骤在对应泳道内
- 跨泳道箭头表示交互
@@ -0,0 +1,62 @@
# 图表类型内容提取策略
每种图表需要从代码库中提取不同维度的信息。以下是通用的提取策略,适用于任何语言/框架的项目。
## 通用提取规则
### 组件识别
- 入口文件中的初始化/注册代码
- 依赖注入容器(Wire, Spring, 等)
- 包/模块的公开接口
- 配置文件中引用的外部服务
### 关系识别
- import/require 语句
- 函数调用链(谁调用了谁)
- 接口实现关系
- 事件发布/订阅
### 数据流识别
- 函数参数和返回值
- 消息队列的 topic/producer/consumer
- API endpoint 的 request/response
- 数据库读写操作
### 流程识别
- 主循环 / 事件循环
- 中间件链 / handler 链
- 状态机转换
- 错误处理 / 重试逻辑
## 按语言的搜索模式
### Go
- 接口: `type \w+ interface`
- 结构体: `type \w+ struct`
- 函数签名: `func \([^)]+\) \w+`
- 依赖注入: `New\w+\(.*\w+Client`
- Goroutine/Channel: `go func`, `chan `
- 错误处理: `if err != nil`
### Python
- 类: `class \w+`
- 函数: `def \w+`
- 装饰器: `@\w+`(路由、依赖注入)
- 异步: `async def`, `await `
- 导入: `from .* import`, `import `
### TypeScript/JavaScript
- 类: `class \w+`
- 接口: `interface \w+`
- 导入: `import .* from`
- 路由: `app\.(get|post|put|delete)`
- 中间件: `\.use\(`
## 信息提取深度
- **架构图/组件图/部署图**: 只需包级/模块级信息,读 CLAUDE.md + 入口文件即可
- **序列图/通信图**: 需要函数调用链,读关键源码文件
- **类图/对象图**: 需要类型定义,读 types/model 文件
- **流程图/活动图/泳道图**: 需要主流程代码,读 pipeline/orchestrator/handler 文件
- **数据流图**: 需要数据结构 + 变换逻辑,读 processor/converter 文件
- **用例图**: 读 CLAUDE.md + router/api 文件
@@ -0,0 +1,432 @@
#!/usr/bin/env python3
"""审查 insight-diagram 生成的 SVG 图,做几何校验。
检查项(对应三条要求):
1. 箭头两端是否落在框图边缘线上 —— 既不"深入"框内,也不"空接"悬空。
2. 非嵌套框图之间是否重叠。
3. 框图之间是否留出足够间距。
用法:
python3 review_svg.py docs/architecture.html [more.html ...]
python3 review_svg.py docs/*.html --min-gap 8 --json
退出码: 发现 ERROR 时为 1;仅 WARNING(或干净)为 0;加 --strict 让 WARNING 也返回 1。
实现说明:用 html.parser(而非 XML 解析器)遍历,以兼容 SVG-in-HTML 中
未转义的 & 或文本里的 <<include>> 等;箭头端点允许落在任意图形边缘、
任意连线/生命线上(汇合点),故序列图/通信图等不会误报"空接"
"""
import argparse
import json
import math
import re
import sys
from html.parser import HTMLParser
# ---- 几何容差(像素)----
BOUNDARY_TOL = 6.0 # 端点距图形边 <= 此值视为"落在边上",正常
PENETRATION = 8.0 # 端点在框内且距最近边 > 此值视为"深入"ERROR
FLOATING = 8.0 # 端点距所有图形 > 此值且不接任何连线 → "空接"WARNING
JUNCTION_TOL = 6.0 # 端点距其他连线/生命线 <= 此值视为合法汇合点
OVERLAP_EPS = 2.0 # 两方向交叠都 > 此值才算重叠
CONTAIN_MARGIN = 2.0 # 判定包含时允许内框略微超出
SAME_BOX_TOL = 3.0 # 各边相差 <= 此值视为同一个框(去重)
# 只有 w/h 同时达到阈值的图形才算"框图节点",参与重叠/间距校验;
# 更小的(标签底衬、终止圆点、图例色块)仅作为箭头落点目标。
MIN_BOX_W = 36.0
MIN_BOX_H = 22.0
# 这些子树内的图形只是装饰/定义,不收集
SKIP_SUBTREES = {'defs', 'marker', 'pattern', 'clippath', 'lineargradient',
'radialgradient', 'symbol', 'mask'}
# =====================================================================
# 基础工具
# =====================================================================
def _floats(s):
return [float(x) for x in re.findall(r'-?\d+(?:\.\d+)?', s or '')]
def _num(v):
"""解析 SVG 数值属性;含 '%' 或无法解析时返回 None。"""
if v is None or '%' in v:
return None
m = re.match(r'\s*(-?\d+(?:\.\d+)?)', v)
return float(m.group(1)) if m else None
def _parse_translate(transform):
"""累加 transform 中的 translate 偏移,返回 (dx, dy)。"""
dx = dy = 0.0
for m in re.finditer(r'translate\(\s*([-\d.]+)[\s,]*([-\d.]+)?\s*\)', transform or ''):
dx += float(m.group(1))
dy += float(m.group(2)) if m.group(2) is not None else 0.0
return dx, dy
def _point_seg_dist(px, py, ax, ay, bx, by):
"""点到线段的最短距离。"""
dx, dy = bx - ax, by - ay
if dx == 0 and dy == 0:
return math.hypot(px - ax, py - ay)
t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)
t = max(0.0, min(1.0, t))
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
def parse_path_points(d):
"""提取 path 各命令的落点(曲线只取终点),用于求首/末锚点。"""
pts = []
cx = cy = 0.0
cmd, nums = None, []
tokens = re.findall(r'([MmLlHhVvCcSsQqTtAaZz])|(-?\d+(?:\.\d+)?)', d or '')
def flush():
nonlocal cx, cy
if cmd in ('M', 'L', 'T'):
for i in range(0, len(nums) - 1, 2):
cx, cy = nums[i], nums[i + 1]; pts.append((cx, cy))
elif cmd in ('m', 'l', 't'):
for i in range(0, len(nums) - 1, 2):
cx, cy = cx + nums[i], cy + nums[i + 1]; pts.append((cx, cy))
elif cmd == 'H':
for v in nums: cx = v; pts.append((cx, cy))
elif cmd == 'h':
for v in nums: cx += v; pts.append((cx, cy))
elif cmd == 'V':
for v in nums: cy = v; pts.append((cx, cy))
elif cmd == 'v':
for v in nums: cy += v; pts.append((cx, cy))
elif cmd in ('C', 'S', 'Q') and len(nums) >= 2:
cx, cy = nums[-2], nums[-1]; pts.append((cx, cy))
elif cmd in ('c', 's', 'q') and len(nums) >= 2:
cx, cy = cx + nums[-2], cy + nums[-1]; pts.append((cx, cy))
for tok_cmd, tok_num in tokens:
if tok_cmd:
if cmd is not None:
flush()
cmd, nums = tok_cmd, []
else:
nums.append(float(tok_num))
if cmd is not None:
flush()
return pts
# =====================================================================
# 图形对象
# =====================================================================
class Shape:
"""任意几何图形的统一表示,用 bbox + 类型描述。"""
__slots__ = ('kind', 'x0', 'y0', 'x1', 'y1', 'cx', 'cy', 'rx', 'ry')
def __init__(self, kind, x0, y0, x1, y1):
self.kind = kind
self.x0, self.y0, self.x1, self.y1 = x0, y0, x1, y1
self.cx, self.cy = (x0 + x1) / 2, (y0 + y1) / 2
self.rx, self.ry = (x1 - x0) / 2, (y1 - y0) / 2
@property
def w(self): return self.x1 - self.x0
@property
def h(self): return self.y1 - self.y0
def is_box(self):
return self.w >= MIN_BOX_W and self.h >= MIN_BOX_H
def signed_dist(self, px, py):
"""点到边界的有符号距离:内部为负、外部为正、≈0 在边上。"""
if self.kind == 'ellipse' and self.rx > 0 and self.ry > 0:
nx, ny = (px - self.cx) / self.rx, (py - self.cy) / self.ry
return (math.hypot(nx, ny) - 1.0) * ((self.rx + self.ry) / 2.0)
if self.kind == 'circle' and self.rx > 0:
return math.hypot(px - self.cx, py - self.cy) - self.rx
dx = max(self.x0 - px, 0, px - self.x1)
dy = max(self.y0 - py, 0, py - self.y1)
if dx > 0 or dy > 0:
return math.hypot(dx, dy)
return -min(px - self.x0, self.x1 - px, py - self.y0, self.y1 - py)
def bbox_key(self):
return (round(self.x0, 1), round(self.y0, 1),
round(self.x1, 1), round(self.y1, 1))
def _contains(a, b):
"""框 a 是否(在容差内)包含框 b 且二者不等大。"""
return (b.x0 >= a.x0 - CONTAIN_MARGIN and b.x1 <= a.x1 + CONTAIN_MARGIN and
b.y0 >= a.y0 - CONTAIN_MARGIN and b.y1 <= a.y1 + CONTAIN_MARGIN and
not (abs(a.x0 - b.x0) < SAME_BOX_TOL and abs(a.x1 - b.x1) < SAME_BOX_TOL and
abs(a.y0 - b.y0) < SAME_BOX_TOL and abs(a.y1 - b.y1) < SAME_BOX_TOL))
# =====================================================================
# 用 HTMLParser 遍历 SVG(容忍未转义字符)
# =====================================================================
class SvgCollector(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.stack = [{'ox': 0.0, 'oy': 0.0, 'skip': False}]
self.shapes = [] # 所有图形(含小图形),用于箭头落点目标
self.connectors = [] # 带 marker 的 line/path
self.segments = [] # 所有 line/path 折线段,用于汇合点判定
# void/自闭合元素
def handle_startendtag(self, tag, attrs):
self._emit(tag.lower(), dict(attrs))
def handle_starttag(self, tag, attrs):
tag = tag.lower()
a = dict(attrs)
parent = self.stack[-1]
dx, dy = _parse_translate(a.get('transform', ''))
node = {'ox': parent['ox'] + dx, 'oy': parent['oy'] + dy,
'skip': parent['skip'] or tag in SKIP_SUBTREES}
self.stack.append(node)
self._emit(tag, a, ctx=node)
def handle_endtag(self, tag):
if len(self.stack) > 1:
self.stack.pop()
def _emit(self, tag, a, ctx=None):
ctx = ctx or self.stack[-1]
if ctx['skip'] or tag in SKIP_SUBTREES:
return
ox, oy = ctx['ox'], ctx['oy']
dx, dy = _parse_translate(a.get('transform', '')) # 自闭合元素自身的 translate
if tag in ('rect', 'ellipse', 'circle', 'polygon'):
ox, oy = ox + dx, oy + dy
if tag == 'rect':
x, y = _num(a.get('x', '0')), _num(a.get('y', '0'))
w, h = _num(a.get('width')), _num(a.get('height'))
if None not in (x, y, w, h):
self.shapes.append(Shape('rect', ox + x, oy + y, ox + x + w, oy + y + h))
elif tag == 'ellipse':
cx, cy = _num(a.get('cx', '0')), _num(a.get('cy', '0'))
rx, ry = _num(a.get('rx')), _num(a.get('ry'))
if None not in (cx, cy, rx, ry):
self.shapes.append(Shape('ellipse', ox + cx - rx, oy + cy - ry,
ox + cx + rx, oy + cy + ry))
elif tag == 'circle':
cx, cy = _num(a.get('cx', '0')), _num(a.get('cy', '0'))
r = _num(a.get('r'))
if None not in (cx, cy, r):
self.shapes.append(Shape('circle', ox + cx - r, oy + cy - r,
ox + cx + r, oy + cy + r))
elif tag == 'polygon':
nums = _floats(a.get('points', ''))
pts = list(zip(nums[0::2], nums[1::2]))
if len(pts) >= 3:
xs = [ox + p[0] for p in pts]; ys = [oy + p[1] for p in pts]
self.shapes.append(Shape('polygon', min(xs), min(ys), max(xs), max(ys)))
elif tag == 'line':
x1, y1 = _num(a.get('x1', '0')), _num(a.get('y1', '0'))
x2, y2 = _num(a.get('x2', '0')), _num(a.get('y2', '0'))
if None not in (x1, y1, x2, y2):
seg = [(ox + x1, oy + y1), (ox + x2, oy + y2)]
self.segments.append(seg)
if a.get('marker-end') or a.get('marker-start'):
self.connectors.append({
'a': seg[0], 'b': seg[-1], 'seg': seg,
'arrow_a': bool(a.get('marker-start')),
'arrow_b': bool(a.get('marker-end'))})
elif tag == 'path':
pts = [(ox + px, oy + py) for px, py in parse_path_points(a.get('d', ''))]
if len(pts) >= 2:
self.segments.append(pts)
if a.get('marker-end') or a.get('marker-start'):
self.connectors.append({
'a': pts[0], 'b': pts[-1], 'seg': pts,
'arrow_a': bool(a.get('marker-start')),
'arrow_b': bool(a.get('marker-end'))})
def collect(svg_text):
"""返回 (boxes, all_shapes, connectors, segments)。"""
p = SvgCollector()
p.feed(svg_text)
# 框去重(描边 + 遮罩底衬常画两层完全重合的 rect)
seen, boxes = set(), []
for s in p.shapes:
if s.is_box():
k = s.bbox_key()
if k not in seen:
seen.add(k)
boxes.append(s)
return boxes, p.shapes, p.connectors, p.segments
def extract_svg(text):
m = re.search(r'<svg\b.*?</svg>', text, re.DOTALL | re.IGNORECASE)
return m.group(0) if m else None
# =====================================================================
# 三项检查
# =====================================================================
def check_arrow_endpoints(shapes, connectors, segments):
"""检查 1:箭头端点应恰好落在某图形边缘,或合法汇入另一连线。
会先剔除"装饰性"连线:两端都既不贴任何图形边、也不汇入其它线段
(典型如图例 Legend 里的示例箭头 / 独立标注线),不参与判定。
"""
issues = []
def status(px, py):
dists = [s.signed_dist(px, py) for s in shapes]
on_edge = bool(dists) and any(abs(d) <= BOUNDARY_TOL for d in dists)
deepest = min(dists) if dists else 0.0
nearest_out = min((d for d in dists if d >= 0), default=None)
return on_edge, deepest, nearest_out
for i, c in enumerate(connectors):
sa = status(*c['a'])
sb = status(*c['b'])
a_anchored = sa[0] or _near_other_segment(*c['a'], segments, c['seg'])
b_anchored = sb[0] or _near_other_segment(*c['b'], segments, c['seg'])
# 两端都不锚定 → 视为图例/装饰线,跳过
if not a_anchored and not b_anchored:
continue
ends = []
if c['arrow_b']:
ends.append(('终点', c['b'], sb))
if c['arrow_a']:
ends.append(('起点', c['a'], sa))
for label, (px, py), (on_edge, deepest, nearest_out) in ends:
if on_edge:
continue # 落在某图形边上:正常
if deepest < -PENETRATION:
issues.append(('ERROR',
f'连线#{i+1} {label}({px:.0f},{py:.0f}) 深入框内 '
f'{-deepest:.0f}px,应止于框边缘'))
continue
if nearest_out is not None and nearest_out > FLOATING:
if _near_other_segment(px, py, segments, c['seg']):
continue # 汇入另一连线/生命线
issues.append(('WARNING',
f'连线#{i+1} {label}({px:.0f},{py:.0f}) 悬空,'
f'距最近框边 {nearest_out:.0f}px(空接)'))
return issues
def _near_other_segment(px, py, segments, own):
for seg in segments:
if seg is own:
continue
for k in range(len(seg) - 1):
if _point_seg_dist(px, py, *seg[k], *seg[k + 1]) <= JUNCTION_TOL:
return True
return False
def check_overlap(boxes):
"""检查 2:非嵌套框之间不得重叠。"""
issues = []
for i in range(len(boxes)):
for j in range(i + 1, len(boxes)):
a, b = boxes[i], boxes[j]
if _contains(a, b) or _contains(b, a):
continue
ox = min(a.x1, b.x1) - max(a.x0, b.x0)
oy = min(a.y1, b.y1) - max(a.y0, b.y0)
if ox > OVERLAP_EPS and oy > OVERLAP_EPS:
issues.append(('ERROR',
f'框[{a.x0:.0f},{a.y0:.0f} {a.w:.0f}x{a.h:.0f}] 与 '
f'[{b.x0:.0f},{b.y0:.0f} {b.w:.0f}x{b.h:.0f}] '
f'重叠 {ox:.0f}x{oy:.0f}px'))
return issues
def check_spacing(boxes, min_gap):
"""检查 3:投影相邻、不嵌套、不重叠的框,净间距需 >= min_gap。"""
issues = []
for i in range(len(boxes)):
for j in range(i + 1, len(boxes)):
a, b = boxes[i], boxes[j]
if _contains(a, b) or _contains(b, a):
continue
xo = min(a.x1, b.x1) - max(a.x0, b.x0)
yo = min(a.y1, b.y1) - max(a.y0, b.y0)
if xo > OVERLAP_EPS and yo > OVERLAP_EPS:
continue # 重叠交给 check_overlap
gap, axis = None, ''
if xo > OVERLAP_EPS:
gap, axis = max(a.y0, b.y0) - min(a.y1, b.y1), '垂直'
elif yo > OVERLAP_EPS:
gap, axis = max(a.x0, b.x0) - min(a.x1, b.x1), '水平'
if gap is not None and 0 <= gap < min_gap:
issues.append(('WARNING',
f'框[{a.x0:.0f},{a.y0:.0f}] 与 [{b.x0:.0f},{b.y0:.0f}] '
f'{axis}间距仅 {gap:.0f}px (< {min_gap:.0f}px)'))
return issues
def review_file(path, min_gap):
try:
with open(path, encoding='utf-8') as f:
text = f.read()
except OSError as e:
return {'file': path, 'issues': [('ERROR', f'无法读取: {e}')],
'boxes': 0, 'connectors': 0}
svg = extract_svg(text)
if not svg:
return {'file': path, 'issues': [('ERROR', '未找到 <svg> 块')],
'boxes': 0, 'connectors': 0}
boxes, shapes, connectors, segments = collect(svg)
issues = (check_arrow_endpoints(shapes, connectors, segments)
+ check_overlap(boxes)
+ check_spacing(boxes, min_gap))
return {'file': path, 'issues': issues,
'boxes': len(boxes), 'connectors': len(connectors)}
# =====================================================================
# CLI
# =====================================================================
def main(argv=None):
ap = argparse.ArgumentParser(
description='审查 insight-diagram 生成的 SVG(箭头落点 / 框重叠 / 框间距)')
ap.add_argument('files', nargs='+', help='待检查的 HTML/SVG 文件')
ap.add_argument('--min-gap', type=float, default=8.0,
help='相邻框最小净间距阈值 px,默认 8')
ap.add_argument('--json', action='store_true', help='以 JSON 输出')
ap.add_argument('--strict', action='store_true',
help='存在 WARNING 时也以非零码退出')
args = ap.parse_args(argv)
results = [review_file(p, args.min_gap) for p in args.files]
if args.json:
print(json.dumps([
{'file': r['file'], 'boxes': r['boxes'], 'connectors': r['connectors'],
'issues': [{'level': lv, 'message': m} for lv, m in r['issues']]}
for r in results], ensure_ascii=False, indent=2))
else:
for r in results:
errs = [m for lv, m in r['issues'] if lv == 'ERROR']
warns = [m for lv, m in r['issues'] if lv == 'WARNING']
mark = '' if errs else ('' if warns else '')
print(f'\n{mark} {r["file"]} '
f'({r["boxes"]} 框 / {r["connectors"]} 箭头连线)')
for m in errs:
print(f' ERROR {m}')
for m in warns:
print(f' WARNING {m}')
if not errs and not warns:
print(' 通过:箭头落点、框重叠、框间距均无异常')
total_err = sum(1 for r in results for lv, _ in r['issues'] if lv == 'ERROR')
total_warn = sum(1 for r in results for lv, _ in r['issues'] if lv == 'WARNING')
if not args.json:
print(f'\n汇总:{total_err} 个 ERROR{total_warn} 个 WARNING'
f'{len(results)} 个文件')
return 1 if (total_err or (args.strict and total_warn)) else 0
if __name__ == '__main__':
sys.exit(main())