93 lines
5.1 KiB
JavaScript
93 lines
5.1 KiB
JavaScript
#!/usr/bin/env node
|
||
// Documentation-only tool. Install pinned packages in a separate temporary directory;
|
||
// UML_RENDER_TOOLS points there. No app dependencies or remote rendering service.
|
||
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');
|
||
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's official JS engine uses DOM construction and Canvas text metrics.
|
||
// Supply a real XML DOM and native Canvas metrics, not approximate text widths.
|
||
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);
|
||
function measurementCanvas() {
|
||
// PlantUML 的文本布局需要真实字体宽高,统一用本机 Canvas 测量而非固定字符宽度。
|
||
const canvas = createCanvas(1, 1);
|
||
const context = canvas.getContext('2d');
|
||
const measure = context.measureText.bind(context);
|
||
context.measureText = text => {
|
||
const metrics = measure(text);
|
||
// PlantUML uses this height for line spacing. Font-wide ascent/descent
|
||
// keeps adjacent class fields from colliding when glyphs lack descenders.
|
||
return {
|
||
...metrics,
|
||
width: metrics.width,
|
||
actualBoundingBoxAscent: metrics.fontBoundingBoxAscent || metrics.actualBoundingBoxAscent,
|
||
actualBoundingBoxDescent: metrics.fontBoundingBoxDescent || metrics.actualBoundingBoxDescent,
|
||
};
|
||
};
|
||
return canvas;
|
||
}
|
||
document.createElement = tag => tag === 'canvas' ? measurementCanvas() : element(tag);
|
||
document.createElementNS = (namespace, tag) => {
|
||
// 为无浏览器 DOM 补齐样式与 getBBox,满足官方渲染引擎的 SVG 文本布局需求。
|
||
const node = elementNS(namespace, tag);
|
||
node.style = {};
|
||
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')));
|
||
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 });
|
||
for (const filename of (await readdir(source)).filter(name => name.endsWith('.puml')).sort()) {
|
||
// 每张图独立渲染并检查错误文本;错误图不能被当作正常产物覆盖已有预览。
|
||
const text = await readFile(path.join(source, filename), 'utf8');
|
||
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}`);
|
||
}
|
||
// Explicit opaque paper keeps arrows/labels legible in dark Markdown viewers.
|
||
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);
|
||
// High-resolution PNG is the universally visible Markdown preview; SVG keeps zoomable text.
|
||
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})`);
|
||
}
|