文本驱动图表生成:用DSL自动输出SVG架构图

发布时间:2026/10/15 23:26:23
文本驱动图表生成:用DSL自动输出SVG架构图 diagram-design 这个项目一句话讲是我写的一个把结构化文本自动变成矢量图表的命令行小工具。起因很简单平时做技术方案和项目汇报总要画架构图、流程图手动拖框拉线特别消耗时间而且需求一旦变动又得挨个改连线、挪框维护成本相当高。当时就想能不能让“文字描述”本身成为图的唯一来源改一行文本图就自动跟着变。diagram-design 就是围绕这个方向做的一次完整落地尝试目前已经能支持多层节点、分支连线、属性标注并直接输出 SVG 文件。这个工具适合三类人一是经常画方案图但不想装重型绘图软件的开发者二是需要把流程文档快速转成可视稿的产品和运营同学三是对解析器、布局算法感兴趣、想自己动手写一个类似东西的学习者。整个项目从语法设计、解析器实现、布局计算到渲染输出全部自研不依赖第三方绘图库代码量不大但技术链路非常完整用来当练手项目能学到不少东西。1. 项目整体规划与设计思路拆解1.1 为什么选“文本驱动”而不是“可视化拖拽”一开始我纠结过做可视化拖拽的编辑器会不会更容易被接受毕竟市面上这类工具不少用户也习惯了。但仔细想拖拽编辑器的开发成本完全不在一个量级要处理选区、拖拽、缩放、连线命中、撤销重做……每一项都是深水区实现到可用状态周期太长。文本驱动的核心优势是“可版本化”。我在实际写需求文档时流程图往往就是几行缩进加箭头文档还能进 Git 做 diff。哪天流程改了改一行文字重新生成就行不用再对着线头反复对齐。对开发者尤其友好所有的图都可以沉淀成文本作为代码仓库的一部分。所以我把项目定位成面向 Markdown 使用者习惯的极简绘图 DSL。用户写一段结构化的文本结束。工具生成图不提供拖拽。用第一版验证流程等模型跑通了以后有人需要图形化再叠 GUI 也不迟。1.2 技术选型零依赖自研的取舍我最初也考虑过用现成的绘图库和解析器生成库但很快就放弃了。原因不复杂这个项目的核心价值不在“渲染 SVG”而在于“自己理解 DSL 并安排布局”。如果解析用别人的、布局用别人的那自己就只剩拼字符串学习价值大打折扣。最后我选了 Node.js 环境不引入任何第三方依赖全部用内置能力完成。CLI 参数解析直接用process.argvSVG 生成用字符串拼接文本宽度用一个简化的字符宽度表估算。这样做有三个好处环境干净clone 下来直接node index.js就能跑不担心依赖版本冲突。每个环节都能自己控制后面想加主题、加布局策略改动成本很低。给自己留了空间未来就算不写 Node换成 Python 或 Go核心算法代码也可以照着翻译。坏处也明显功能没有现成库丰富比如 SVG 里复杂的滤镜、阴影都要自己拼。但这些对架构图来说不是刚需我并不后悔这个选择。1.3 模块划分解析、布局、渲染三层怎么写项目虽小我也没把代码全部堆到一个文件里。分层思路参照了编译器前端的经典三段式词法/语法解析 → 语义与布局 → 代码生成只不过最后生成的不是机器码而是一张 SVG 图。目录结构如下diagram-design/ ├── index.js # CLI 入口负责读文件、调流程、写输出 ├── parser.js # 文本解析把 DSL 转成节点和边的数据 ├── layout.js # 布局算法计算每个节点的坐标和层级 ├── renderer.js # SVG 渲染把节点坐标拼成 SVG 字符串 └── example.txt # 示例 DSL 文件parser 输出的结构是{ nodes: [], edges: [] }layout 在 parser 输出上补充每个节点的x、y、width、heightrenderer 只消费 layout 的结果不关心 DSL 长什么样。这样每一层都能单独测试后面替换任意一层都不影响其他部分。2. 核心原理剖析从一行 DSL 到一张 SVG 图2.1 DSL 语法设计少即是多这套 DSL 的语法约束非常少我只保留了四类表达节点、连线、方向链、属性。最核心的规则就是“一段文本里用-表示连接”一行可以是一个完整连接也可以写链式连接。示例登录模块 - 网关 注册模块 - 网关 网关 - 用户服务 网关 - 订单服务 用户服务 - 数据库 订单服务 - 数据库这种写法有向无环非常接近我们平时说的“A 依赖 B”“A 流向 B”。为了让节点看起来更清楚我允许在节点名后用括号指定形状比如数据库(cylinder)以后可以扩展成圆柱体第一版统一渲染成圆角矩形先留好扩展位。边的属性我放在中括号里网关 - 用户服务 [labelHTTP, colorblue]解析器会读取 label 和 colorlabel 用来在线上标注文字color 用来设置线的颜色。属性解析的核心是keyvalue的映射结构简单后期加新属性只要在渲染器里补消费逻辑。2.2 解析器实现状态机加行扫描parser 我没用正则一次性吞整块文本因为多个-的链式表达式用正则处理很容易出错。我选择逐行扫描每一行拆成若干段段与段之间用-分隔。核心逻辑如下function parseLine(line) { const clean line.trim(); if (!clean || clean.startsWith(//)) return null; const parts clean.split(-).map((s) s.trim()); if (parts.length 2) return null; const nodes []; const edges []; for (let i 0; i parts.length; i) { const node parseNodePart(parts[i]); nodes.push(node); if (i 0) { edges.push({ from: nodes[i - 1].id, to: node.id }); } } return { nodes, edges }; } function parseNodePart(part) { const attrMatch part.match(/\[(.*?)\]/); const attributes attrMatch ? parseAttributes(attrMatch[1]) : {}; const namePart attrMatch ? part.replace(attrMatch[0], ).trim() : part; const shapeMatch namePart.match(/^(.*?)\((.*?)\)$/); const text shapeMatch ? shapeMatch[1].trim() : namePart; const shape shapeMatch ? shapeMatch[2].trim() : rect; const id text.replace(/\s/g, _); return { id, text, shape, attributes }; }A - B - C会被展开成A-B、B-C两条边中间节点会自动生成不需要用户单独声明。这个设计的好处是写起来顺手缺点是有重复节点时要去重合并我通过一个 Map 以节点文本为 key 做合并保证同一个节点只出现一次。2.3 布局算法树形结构的整齐编排布局是整个项目里我花时间最多的地方。图要好看关键在于“层次对不对、同层对齐不准、父节点居中不居中、链路不交叉”。我采用的方案分两步。第一步用拓扑排序给所有节点分层级保证箭头方向和层级递增方向一致第二步通过递归后序遍历计算每一棵子树的宽度然后让父节点居中于它所有子节点的中间位置。核心伪代码function layoutNode(node) { const children childrenMap.get(node.id) || []; if (children.length 0) { // 叶子节点直接分配横坐标 node.width estimateTextWidth(node.text); node.x cursorX; cursorX node.width H_GAP; return; } // 先布局所有子节点 for (const child of children) { layoutNode(child); } const minX Math.min(...children.map((c) c.x)); const maxX Math.max(...children.map((c) c.x c.width)); node.width estimateTextWidth(node.text); // 父节点放在子节点范围的中间 node.x (minX maxX - node.width) / 2; }递归调用时需要注意的是如果一个节点有多个父节点它会被多次访问造成横坐标重复分配。第一版我做了限制假设输入是树结构即每个节点最多只有一个父节点。有汇聚节点的场景我会在第四章专门讲怎么判断和规避。深度用拓扑排序时顺手算出来y depth * (NODE_HEIGHT V_GAP)层与层之间留固定间距这样像数楼梯一样一层一层往下铺很整齐。2.4 渲染SVG 坐标系里的矩形、文字和曲线SVG 本身是个 XML 文本用字符串拼接的渲染器逻辑很直接。每个节点渲染成rect加text每条边渲染成path。function renderNode(node) { const x node.x; const y node.y; return ( g rect x${x} y${y} width${node.width} height${NODE_HEIGHT} rx6 fill#f8fafc stroke#475569/ text x${x node.width / 2} y${y NODE_HEIGHT / 2} text-anchormiddle dominant-baselinecentral font-familysans-serif font-size13${escapeXml(node.text)}/text /g ); }dominant-baselinecentral能让文字在矩形里垂直居中比手工调 y 坐标省心。这根属性在部分渲染器里兼容性一般如果需要更稳的写法可以用dy.35em代替两者达到的效果接近。边的部分我用的不是直线而是三次贝塞尔曲线。起点从父节点右侧中心出发终点落在子节点左侧中心控制点取两点水平距离的一半这样曲线会很自然地弯过去比直线干巴巴地穿过层级要顺眼。3. 实操记录跑通一张“系统架构图”工作流3.1 初始化环境与文件入口我先给项目一个最小可运行的骨架。因为是零依赖项目package.json只需要声明入口和命令{ name: diagram-design, version: 0.1.0, private: true, bin: { diagram-design: ./index.js }, scripts: { build: node index.js example.txt } }index.js里的流程很简单读文件、解析、布局、渲染、写出 SVG。命令行参数我也没用第三方库直接用process.argv.splice(2)拿参数第一个参数是输入文件路径第二个可选参数是输出文件路径。const fs require(fs); const { parseText } require(./parser); const { layout } require(./layout); const { render } require(./renderer); const input process.argv[2]; const output process.argv[3] || output.svg; const text fs.readFileSync(input, utf-8); const { nodes, edges } parseText(text); const settled layout(nodes, edges); const svg render(settled, edges); fs.writeFileSync(output, svg, utf-8); console.log(已生成, output);3.2 用 DSL 描述一张架构图我在example.txt里写了一个经典的多服务架构图用户端 - API 网关 业务后台 - API 网关 API 网关 - 用户服务 API 网关 - 订单服务 API 网关 - 消息服务 用户服务 - 数据库 订单服务 - 数据库 消息服务 - 消息队列这段文本从用户视角看完全是一份纯文档但从另一个角度看它就是一张带方向依赖的图。解析器跑完后会得到 8 个节点和 8 条边。注意“数据库”和“消息队列”都有多个入边严格来说已经不是树结构了。我们第三章先假设它能跑布局算法对多入边节点的处理会在 4.3 节说明这里不展开。3.3 跑通布局计算与坐标输出调用layout()后每个节点会带上坐标。我加了一个调试参数运行后直接打印每个节点的坐标表格节点层级xy用户端04060业务后台025060API 网关1145200用户服务230340订单服务2170340消息服务2310340数据库3100480消息队列3310480可以看到同一层级的 y 坐标相同横向分布按照叶子节点宽度从左往右排。API 网关位于第二层所有子节点的中间位置看起来非常规整。3.4 渲染导出 SVG 和验收标准渲染器把带坐标的节点和边拼成一个完整的 SVG 文件。验收时我用三个标准来检查节点不重叠、同一层级在一条水平线上、所有连线能看出清晰的方向。只要这三条满足图就可以拿去做项目文档配图。实际跑完的效果四周留白 40px节点间距 30px层间距 140px每条边都带箭头标记方向明确。打开文件后我第一反应是这图虽然朴素但完全能直接放进方案里用比手动画节省了至少一半时间。4. 踩坑记录与排查速查手册4.1 中文文字导出乱码第一次跑出 SVG 后我用浏览器打开中文全部显示成方块。排查发现是 SVG 里没有声明合适的字体系统默认渲染找不到 CJK 字形。解决办法是给所有text加上一个可用的字体栈比如font-family-apple-system, PingFang SC, Microsoft YaHei, sans-serif这样在 macOS 和 Windows 上都能正常显示中文。如果是部署到 CI 环境再转 PDF还要确认服务端装了对应中文字体这是另一个经常会坑到人的点。4.2 文本宽度估算偏差一开始我按字符数乘单个字符宽度来估算节点宽度结果中英文混排时节点大小忽宽忽窄有的文字被截断。后来我改成按字符类型估算英文和数字占 0.6 个单位中文占 1 个单位标点符号按实际类型取 0.3 到 1 不等。同时给节点宽度设置一个最小值避免只有一个字的节点窄得难看。这种估算方案虽然不如measureText精确但在零依赖的前提下已经够用。4.3 汇聚节点导致的布局重叠当多个父节点指向同一个子节点时这个子节点在递归里会被重复访问布局宽度来回叠加节点位置就跑飞了。这是图布局里最典型的“DAG 汇聚问题”。我采用的方案是第一版识别多入边的节点把它和普通树节点区别对待这类节点不参与父节点居中计算统一靠左排列并在注释里提示“当前版本不完全支持宽幅汇聚图”。如果你想处理得更完善可以按族谱布局去处理把汇聚节点归属于宽度最大的父分支再通过增加连线的绕行路径来避免遮挡。4.4 边线穿节点与箭头方向不明确图简单的时候曲线全部从节点右侧中心出发问题不大。节点多了以后曲线会穿过无关节点视觉上很乱。我加了一个“障碍检测”的简化策略先渲染所有节点再渲染所有边同时把每条边的路径控制点向上偏移让曲线尽量先向右侧画出再拐向目标节点。这样一来主要链路曲线都在节点上方滑过不会再硬穿节点主体。虽然这种策略不保证全局无交叉但日常架构图场景完全够用。4.5 问题速查表现象可能原因处理方法中文显示方块SVG 缺少 CJK 字体栈增加 PingFang/微软雅黑等字体声明节点重叠多入边节点重复布局检查输入是否含汇聚节点手动调整箭头方向乱布局方向与连接方向不一致确认 y 递增方向与边的流向一致输出空白输入文件读的是空内容检查入口参数和编码是否为 UTF-8节点文字溢出宽度估算过小改用中英文混合宽度估算方式5. 后续可以怎么扩展5.1 支持更多图类型当前实现只面向流程/架构图。其实 parser 的层可以很容易再加一种关键字比如mindmap开头表示思维导图。思维导图不需要横向箭头而是用圆角框和层级缩进表示隶属关系布局算法只需把“父节点居中于子节点”改成“父节点在左上角子节点向下逐一排列”就行。5.2 导出 PNG 和 PDFSVG 是矢量格式大多数文档可以直接用。如果要发布到只支持位图的平台需要转 PNG。最稳妥的思路是引入一个轻量级转换工具但这就打破零依赖了。我的建议是做成可选特性按需安装不污染主项目。5.3 热更新预览模式文本改一行就重新生成一次 SVG。可以监听文件变化每次保存自动换出新的图浏览端只要用浏览器刷新就能看见效果。对写技术文档来说这个体验比手动点生成按钮舒服得多。5.4 设计规范与主题模板图好不好看七分靠配色三分靠布局。我准备把常用主题抽成配置文件比如“默认蓝灰”“护眼绿色”“演示深色”用户通过--theme dark指定即可。主题的本质就是一组颜色和节点样式的映射渲染器读取主题文件后把颜色替换进去实现成本不高但对成品质量提升很明显。把整个项目从头到尾写完我最大的感触是画图工具的难点并不是“画”而是“安排位置”。只要把解析、分层、居中对齐这套逻辑想清楚哪怕渲染部分只用字符串拼 SVG也能做出让人愿意放进正式文档里的图。diagram-design 这个名字里的 design重点从来不在像素级美化而在于设计了一套从文本到图形的规则并让这套规则稳定、可预期、能维护。如果你也想做一个类似工具我建议先从树形结构入手别一上来就啃复杂网络的交叉最小化。等树的布局稳定了再慢慢去拓宽节点类型、边缘属性和布局策略。这套思路不只在画图上通用任何从“描述”到“呈现”的系统底层逻辑都是相通的。