Files
endorphin-tessera-gui/scripts/render-uml.mjs
T
codex-bot 21439d4c4d
Verify Tessera GUI / verify (push) Failing after 1m30s
docs: render UML diagrams and embed portable previews
2026-10-25 22:09:47 +08:00

88 lines
4.6 KiB
JavaScript
Raw 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
// 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() {
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) => {
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);
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})`);
}