Files
endorphin-tessera-gui/scripts/render-uml.mjs

109 lines
7.1 KiB
JavaScript
Raw Permalink 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 node
// 文档 UML 批量渲染入口:读取仓库内 .puml,在 rendered 中写入 SVG/PNG。
// UML_RENDER_TOOLS 指向已安装依赖的工具目录;README 给出固定版本安装示例,脚本自身不安装或校验版本。
import { createRequire } from 'node:module';
import { readFile, readdir, mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
// 文档维护者从命令行执行本脚本;运行时入口不导入它,依赖应事先安装,本脚本不执行包管理器。
const tools = process.env.UML_RENDER_TOOLS;
// 工具依赖放在独立目录,避免给应用运行依赖增加文档渲染包。
if (!tools) throw new Error('Set UML_RENDER_TOOLS to the documentation tool directory; see docs/architecture/uml/README.md');
// 从工具目录的 package.json 创建专用 require,所有渲染依赖均从该目录解析,不依赖运行时 cwd。
const require = createRequire(path.join(path.resolve(tools), 'package.json'));
const { createCanvas } = require('@napi-rs/canvas');
const { DOMImplementation, XMLSerializer, DOMParser } = require('@xmldom/xmldom');
const sharp = require('sharp');
// 官方 PlantUML JS 引擎依赖 DOM、Graphviz 和 Canvas 文本度量。
// 在导入引擎前补齐全局环境,让 Node 进程可执行原本面向浏览器的布局过程。
// 这些赋值修改当前 Node 进程 globalThis,脚本结束前不恢复;按独立命令行工具使用,不是无全局副作用的库。
globalThis.PLANTUML_VIZ = require('@viz-js/viz');
globalThis.window = globalThis;
globalThis.XMLSerializer = XMLSerializer;
globalThis.DOMParser = DOMParser;
const document = new DOMImplementation().createDocument(null, 'root', null);
const element = document.createElement.bind(document);
const elementNS = document.createElementNS.bind(document);
// 创建只用于文本度量的 1x1 Canvas。此对象不承载最终图片像素,字体实际尺寸由原生引擎返回。
function measurementCanvas() {
// PlantUML 的文本布局需要真实字体宽高,统一用本机 Canvas 测量而非固定字符宽度。
const canvas = createCanvas(1, 1);
const context = canvas.getContext('2d');
const measure = context.measureText.bind(context);
// 包装测量结果,修正 PlantUML 用于行距的 ascent/descent;保留其他真实 metrics 字段。
context.measureText = text => {
const metrics = measure(text);
// 优先使用整套字体的上下界,而非当前字符串字形界;
// 否则不带下伸部的文字可能使相邻类字段的行距过小而重叠。
return {
...metrics,
width: metrics.width,
actualBoundingBoxAscent: metrics.fontBoundingBoxAscent || metrics.actualBoundingBoxAscent,
actualBoundingBoxDescent: metrics.fontBoundingBoxDescent || metrics.actualBoundingBoxDescent,
};
};
return canvas;
}
// 普通标签交给 XML DOM,只有 canvas 替换为可实际 measureText 的原生对象。
document.createElement = tag => tag === 'canvas' ? measurementCanvas() : element(tag);
document.createElementNS = (namespace, tag) => {
// 为无浏览器 DOM 补齐样式与 getBBox,满足官方渲染引擎的 SVG 文本布局需求。
const node = elementNS(namespace, tag);
node.style = {};
// 无浏览器布局树时只模拟当前文字节点的包围盒:字体来自 SVG 属性,y 从基线向上偏移。
node.getBBox = () => {
const context = measurementCanvas().getContext('2d');
context.font = `${node.getAttribute('font-size') || 14}px ${node.getAttribute('font-family') || 'Arial'}`;
const metrics = context.measureText(node.textContent || '');
return {
width: metrics.width,
height: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent,
x: 0,
y: -metrics.actualBoundingBoxAscent,
};
};
return node;
};
document.body = element('body');
document.documentElement.appendChild(document.body);
globalThis.document = document;
// 全局 DOM/Canvas 适配已设置完毕才加载引擎,避免模块初始化读取到缺失的浏览器接口。
const { renderToString } = await import(pathToFileURL(require.resolve('@plantuml/core')));
// 仓库位置由脚本 URL 确定,与调用者工作目录无关;输出目录不存在时创建。
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const source = path.join(root, 'docs/architecture/uml');
const output = path.join(source, 'rendered');
await mkdir(output, { recursive: true });
// 按文件名排序使处理顺序/日志稳定,只处理本目录的 .puml,不递归扫描产物目录。
for (const filename of (await readdir(source)).filter(name => name.endsWith('.puml')).sort()) {
// 每张图独立渲染并检查错误文本;错误图不能被当作正常产物覆盖已有预览。
const text = await readFile(path.join(source, filename), 'utf8');
// 将引擎 resolve/reject 回调适配为 Promise,逐图等待;渲染失败直接终止,不覆盖该图产物。
const rendered = await new Promise((resolve, reject) => renderToString(text.split('\n'), resolve, reject));
const xml = new DOMParser().parseFromString(rendered, 'image/svg+xml');
// 引擎有时以正常 SVG 输出错误页,所以不能只判断 Promise resolve;还要检查文字层的错误标签。
// 检查根标签和几类错误文本,不是完整 SVG/XML 语义验证,也不保证识别引擎所有错误页格式。
const labels = Array.from(xml.getElementsByTagName('text'), node => node.textContent).join('\n');
if (xml.documentElement.tagName !== 'svg' || /syntax error|error line|assumed diagram type|an error has occurr/i.test(labels)) {
throw new Error(`${filename}: renderer returned an error diagram\n${labels}`);
}
// 在最前方加入不透明白纸背景,保证深色 Markdown 查看器中的箭头与文字仍可辨认。
const paper = xml.createElementNS('http://www.w3.org/2000/svg', 'rect');
for (const [key, value] of Object.entries({ x: '0', y: '0', width: '100%', height: '100%', fill: '#FFFFFF' })) paper.setAttribute(key, value);
xml.documentElement.insertBefore(paper, xml.documentElement.firstChild);
// 序列化修改后的 DOM,而不是直接写引擎原字符串,确保白底矩形进入两个导出格式。
const svg = new XMLSerializer().serializeToString(xml);
// 同时保存可缩放 SVG 和通用 PNG;写完读取 PNG 元数据确认生成尺寸。
const stem = filename.slice(0, -5);
// 两种产物顺序写入且不组成事务;SVG 成功后若 PNG 失败,前面已写出的文件不会自动回滚。
// 同名文件直接覆盖;本轮没有对应 .puml 的旧产物不会被自动删除,较早已完成的图也不会因后图失败撤回。
await writeFile(path.join(output, `${stem}.svg`), svg);
// 以 192 DPI 光栅化为 PNG 供通用预览;SVG 同时保留矢量文字和缩放能力。
const png = sharp(Buffer.from(svg), { density: 192 });
await png.png().toFile(path.join(output, `${stem}.png`));
const metadata = await sharp(path.join(output, `${stem}.png`)).metadata();
console.log(`${filename} -> SVG + PNG (${metadata.width}×${metadata.height})`);
}