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