#!/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})`); }