@patricksurry/g3
Version:
A flexible Javascript framework for building steam gauge instrument panels that display live external metrics from flight (or other) simulators like XPlane or MS FS2020
162 lines (134 loc) • 5.22 kB
JavaScript
import puppeteer from 'puppeteer';
import path from 'path';
import fs from 'fs';
import { parseArgs } from 'node:util';
import sharp from 'sharp';
async function flood_alpha(img) {
const { data, info } = await sharp(img)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const color = (p) => (p[0] << 16) | (p[1]) << 8 | p[2];
const target = color(data),
directions = [ -info.width*4, -1*4, 1*4, info.width*4 ];
let stack = [0];
while (stack.length > 0) {
let i = stack.pop();
directions.map(d => {
let j = i + d;
if (j >= 0 && j < data.length && data[j+3] && color(data.subarray(j)) == target) {
data[j+3] = 0;
stack.push(j);
}
});
}
return sharp(data, {
raw: {
width: info.width,
height: info.height,
channels: 4
}
}).trim().png();
}
/**
* Renders the first SVG element of an HTML file to a PNG image.
* @param {string} htmlPath - The path to the input HTML file.
* @param {string} pngPath - The path to the output PNG file.
* @param {object} options - Options for rendering.
*/
async function renderSvgToPng(htmlPath, pngPath, options = {}) {
// gaussian blur shadow still adds some uncontrolled randomness?
const { compare, force, threshold = 0.0001 } = options;
if (!htmlPath || !pngPath) {
console.error('Usage: node render-png.js <input.html> <output.png> [--compare] [--force]');
process.exit(2);
}
const absoluteHtmlPath = path.resolve(htmlPath);
if (!fs.existsSync(absoluteHtmlPath)) {
console.error(`Error: Input file not found at ${absoluteHtmlPath}`);
process.exit(3);
}
if (compare) {
if (!fs.existsSync(pngPath)) {
console.error(`Error: Comparison file not found at ${pngPath}`);
process.exit(4);
}
} else if (fs.existsSync(pngPath) && !force) {
console.error(`Error: Output file exists at ${pngPath}. Use --force to overwrite.`);
process.exit(5);
}
console.log(`Rendering ${absoluteHtmlPath}...`);
const browser = await puppeteer.launch({ headless: 'new' });
try {
const page = await browser.newPage();
// Replace math.random with a predictable version
await page.evaluateOnNewDocument(() => {
function mulberry32(seed) {
return function() {
let t = seed += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
}
}
Math.random = mulberry32(12345);
// one-shot update for screenshot so browser can exit
setInterval = (fn, interval) => { fn(); return 0; };
// Mock Date to a fixed point for deterministic rendering
const MOCK_DATE_STRING = '2025-12-28T12:34:56-05:00';
const MOCK_TIMESTAMP = new Date(MOCK_DATE_STRING).getTime();
const OriginalDate = Date;
// eslint-disable-next-line no-global-assign
Date = class extends OriginalDate {
constructor(...args) {
// If called with no arguments, return the mock date.
// Otherwise, pass arguments to the original Date constructor.
super(...(args.length === 0 ? [MOCK_DATE_STRING] : args));
}
static now() { return MOCK_TIMESTAMP; }
};
});
await page.goto(`file://${absoluteHtmlPath}`, { waitUntil: 'networkidle0' });
const svgElement = await page.waitForSelector('svg');
// Take screenshot to a buffer first
const buffer = await svgElement.screenshot();
let img = await flood_alpha(buffer);
if (compare) {
const { data: actualData, info: actualInfo } = await img.raw().toBuffer({ resolveWithObject: true });
const { data: expectData, info: expectInfo } = await sharp(pngPath).raw().toBuffer({ resolveWithObject: true });
if (expectData.length !== actualData.length) {
console.error(`Image size mismatch: got ${actualInfo.width} x ${actualInfo.height} x ${actualInfo.channels}, expected ${expectInfo.width} x ${expectInfo.height} x ${expectInfo.channels}`);
process.exitCode = 1;
} else {
let diff = 0;
for (let i = 0; i < actualData.length; i+=4) {
diff += [0,1,2,3].map((j) => Math.abs(actualData[i+j] - expectData[i+j])).reduce((a, b) => a + b);
}
const err = (100 * diff/128/4/actualData.length).toPrecision(2);
if (err > 100*threshold) {
console.error(`Image data mismatch: error ${err}% exceeds threshold.`);
process.exitCode = 1;
} else {
console.log(`Images match with error ${err}%.`)
}
}
} else {
await img.toFile(pngPath);
console.log(`Wrote PNG to ${pngPath}`);
}
} catch (error) {
console.error('An error occurred during rendering:', error);
process.exitCode = 5;
} finally {
await browser.close();
}
}
const { values, positionals } = parseArgs({
options: {
compare: { type: 'boolean' },
force: { type: 'boolean' },
},
allowPositionals: true,
});
const [htmlPath, pngPath] = positionals;
renderSvgToPng(htmlPath, pngPath, values);