在终端环境中渲染 Mermaid 图表是一项独特的工程挑战。与浏览器中基于 SVG 的无限画布不同,终端渲染必须在固定宽度的字符网格内完成布局计算、节点定位与连线路由。这类需求出现在 TUI 应用开发、CI 日志输出、SSH 远程会话等场景,传统的 Puppeteer + SVG 方案在这些环境下完全不可行。
终端渲染的核心约束
终端渲染引擎面临三个根本性限制。首先是离散网格约束:每个字符占据固定的 1×1 位置,图的所有元素必须映射到整数坐标 (row, col),任何小数坐标都需要向上取整或四舍五入。其次是有限字符集:可用的绘图字符仅限于 Unicode 框线符(─│┌┐└┘)及其 ASCII 等价符(- | +),无法使用曲线或渐变。第三是确定性要求:相同输入必须产生完全相同的输出,这对于 CI 复现和调试至关重要。
传统图布局算法如 Graphviz 的 dot、Dagre 或 ELK 输出的浮点坐标 (x, y) 不能直接用于终端渲染。以 Dagre 为例,其 rankdir: TB 模式下返回的节点坐标可能是 {x: 45.5, y: 120.3},渲染引擎需要将这些浮点值映射到字符网格,同时保持相对位置关系不发生交叉或重叠。
网格布局的工程化实现
一种常见的实现策略是拓扑分层 + 网格离散化。引擎首先解析 Mermaid 语法,构建节点与边的有向图;然后执行拓扑排序,将节点分配到不同的层(rank);接着在同一层内按依赖关系排序节点位置;最后将每层的节点展开到网格行。
以 mermaidtui 引擎为例,其渲染流程包含以下关键步骤:
// 简化的拓扑分层伪代码
interface Node { id: string; label: string; layer: number; col: number; }
interface Edge { source: string; target: string; }
// 1. 计算节点层级(最长路径算法)
function computeLayers(nodes: Node[], edges: Edge[]): void {
const inDegree = new Map<string, number>();
const adj = new Map<string, string[]>();
// 初始化入度和邻接表
for (const node of nodes) inDegree.set(node.id, 0);
for (const edge of edges) {
adj.set(edge.source, [...(adj.get(edge.source) || []), edge.target]);
inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1);
}
// Kahn 算法拓扑排序
const queue = nodes.filter(n => inDegree.get(n.id) === 0);
while (queue.length > 0) {
const current = queue.shift()!;
for (const neighbor of adj.get(current.id) || []) {
const newDepth = current.layer + 1;
if (newDepth > (nodes.find(n => n.id === neighbor)?.layer || 0)) {
nodes.find(n => n.id === neighbor)!.layer = newDepth;
}
inDegree.set(neighbor, inDegree.get(neighbor)! - 1);
if (inDegree.get(neighbor) === 0) queue.push(nodes.find(n => n.id === neighbor)!);
}
}
}
// 2. 网格离散化(层内节点位置分配)
function assignGridPositions(nodes: Node[], rankSpacing: number = 4): void {
const layers = new Map<number, Node[]>();
for (const node of nodes) {
if (!layers.has(node.layer)) layers.set(node.layer, []);
layers.get(node.layer)!.push(node);
}
for (const [layer, layerNodes] of layers) {
layerNodes.forEach((node, index) => {
node.col = index * rankSpacing;
});
}
}
上述代码展示了拓扑分层与网格位置分配的核心逻辑。关键参数 rankSpacing(层间距)通常设为 4-6 个字符宽度,用于在同一层内区分相邻节点。实际实现中还需要考虑节点自身的宽度(标签文字长度 + 2-3 字符内边距),以及层间的最小行距(通常为 2-3 行)。
正交边路由的字符选择
边路由是终端渲染中最复杂的部分。由于只能使用水平(─)和垂直(│)线段,所有边必须是正交折线 —— 水平段与垂直段交替,拐点使用角点字符连接。路由算法的目标是找到一条从源节点边界到目标节点边界的最短路径,同时避免与已有节点或边交叉。
标准 Unicode 框线字符集提供了完整的正交路由能力。水平连接使用 ─(U+2500),垂直连接使用 │(U+2502),四个方向的拐点分别对应 ┌(U+250C,右上角)、┐(U+2510,左上角)、└(U+2514,右下角)、┘(U+2518,左下角)。当需要 ASCII 兼容模式时,这些字符分别替换为 -、|、+。
// 正交路由字符选择逻辑
function getCornerChar(fromDir: 'up'|'down'|'left'|'right',
toDir: 'up'|'down'|'left'|'right'): string {
const corners: Record<string, string> = {
'right-down': '┌', // 进入从右,出去向下
'right-up': '┐', // 进入从右,出去向上
'left-down': '┐', // 进入从左,出去向下(镜像)
'left-up': '┌', // 进入从左,出去向上
'down-right': '└', // 进入从下,出去向右
'down-left': '┘', // 进入从下,出去向左
'up-right': '┌', // 进入从上,出去向右
'up-left': '┘', // 进入从上,出去向左
};
return corners[`${fromDir}-${toDir}`] || '─';
}
对于 LR(从左到右)方向的流程图,边路由的典型策略是:源节点右侧发射水平线,遇到第一个垂直障碍时向下折转,水平延伸至目标节点所在列,再向上折转连接目标节点左侧。这种策略产生的路径通常不会超过两段折线,在大多数简单流程图中能够保持可读性。
关键工程参数汇总
在实际项目中采用终端 ASCII 渲染方案时,以下参数需要根据具体场景调整:
网格尺寸参数:节点内边距建议 2-3 字符,确保文字与边框之间有足够空白;层间距(rankSpacing)建议 4-6 字符,当流程图包含大量并行分支时可增大至 8-10;单行最大宽度建议 80-120 字符,超出后应考虑横向滚动或分页。
方向支持参数:主流实现支持四种流向配置。LR(Left-Right)适合水平流程,如数据处理管道;RL(Right-Left)用于从右向左的特殊流程;TB(Top-Bottom)是传统的从上到下布局;BT(Bottom-Tool)较少使用,适合逆向流程图。
渲染模式切换:Unicode 模式使用完整框线字符,视觉效果最佳但依赖终端字体支持;ASCII 模式使用 -、|、+ 组合,兼容性最好但可读性略低。生产环境建议默认 Unicode,根据终端检测结果自动降级。
应用场景与局限性
终端 ASCII 渲染方案最适合三类场景。CI/CD 日志输出:在构建日志中直接嵌入流程图,无需生成图片附件。TUI 工具内嵌:监控面板、调试工具等需要文本界面的应用。远程 SSH 会话:在无法打开浏览器的环境中查看架构图或数据流。
该方案的局限也很明显。不支持复杂节点形状(圆形、菱形、数据库图标等),不支持样式注入(颜色、线宽、字体),不支持子图嵌套(部分实现支持但效果有限)。对于需要高保真视觉效果的场景,仍应使用传统的 SVG 渲染方案。
资料来源:本文技术细节参考了 mermaidtui 开源实现(Apache-2.0 许可)与 Mermaid 官方布局算法文档。