101 lines
6.1 KiB
JavaScript
101 lines
6.1 KiB
JavaScript
#!/usr/bin/env node
|
||
// 文档 UML 批量渲染入口:读取仓库内 .puml,在 rendered 中写入 SVG/PNG。
|
||
// 固定版本的工具包安装到独立临时目录,由 UML_RENDER_TOOLS 指定;不会修改应用依赖。
|
||
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 进程可执行原本面向浏览器的布局过程。
|
||
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;
|
||
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');
|
||
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);
|
||
const svg = new XMLSerializer().serializeToString(xml);
|
||
// 同时保存可缩放 SVG 和通用 PNG;写完读取 PNG 元数据确认生成尺寸。
|
||
const stem = filename.slice(0, -5);
|
||
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})`);
|
||
}
|