svg-bbox
Version:
A set of tools to compute and to use a SVG bounding box you can trust (as opposed to the unreliable .getBBox() scourge)
428 lines (378 loc) • 13.5 kB
JavaScript
#!/usr/bin/env node
/**
* sbb-test.cjs
*
* Usage:
* node sbb-test.cjs path/to/file.svg
*
* What it does:
* - launches Chrome/Chromium via Puppeteer (headless).
* - falls back to system Chrome via chrome-launcher if bundled Chromium is missing.
* - creates an empty HTML page and injects ONLY the SVG.
* - loads SvgVisualBBox.js into the page.
* - runs all exported functions:
* - getSvgElementVisualBBoxTwoPassAggressive
* - getSvgElementsUnionVisualBBox
* - getSvgElementVisibleAndFullBBoxes
* - getSvgRootViewBoxExpansionForFullDrawing
* - writes:
* - <svgbasename>-bbox-results.json (data)
* - <svgbasename>-bbox-errors.log (errors & diagnostics)
*
* Works on Linux and macOS (and should work on Windows as well).
*/
const fs = require('fs');
const path = require('path');
const puppeteer = require('puppeteer');
const chromeLauncher = require('chrome-launcher');
const {
getVersion,
printVersion: _printVersion,
hasVersionFlag: _hasVersionFlag
} = require('./version.cjs');
// SECURITY: Import security utilities
const {
validateFilePath,
validateOutputPath,
readSVGFileSafe,
sanitizeSVGContent,
writeFileSafe,
SVGBBoxError: _SVGBBoxError,
ValidationError,
FileSystemError: _FileSystemError
} = require('./lib/security-utils.cjs');
const {
runCLI,
printSuccess,
printError: _printError,
printInfo,
printWarning: _printWarning
} = require('./lib/cli-utils.cjs');
// Centralized timeout configuration
const { BROWSER_TIMEOUT_MS } = require('./config/timeouts.cjs');
/**
* Launch Puppeteer with the best available browser:
* 1. Try bundled Chromium (default Puppeteer behavior).
* 2. If that fails, try to find a system Chrome/Chromium via chrome-launcher
* and launch Puppeteer using its executablePath.
*/
/**
* Launch browser with fallback to system Chrome
* @param {string[]} errorLogMessages - Array to collect error messages
* @returns {Promise<import('puppeteer').Browser>}
*/
async function launchBrowserWithFallback(errorLogMessages) {
try {
const browser = await puppeteer.launch({
// @ts-ignore - 'new' is valid for newer puppeteer versions
headless: 'new' // new headless mode in recent Chrome versions
});
return browser;
} catch (err) {
errorLogMessages.push(
'[launch] Failed to launch bundled Chromium with Puppeteer: ' +
/** @type {Error} */ (err).message
);
}
// Fallback: use chrome-launcher to find a system Chrome/Chromium
let chromePaths;
try {
chromePaths = chromeLauncher.Launcher.getInstallations();
} catch (err) {
errorLogMessages.push(
'[launch] chrome-launcher.getInstallations failed: ' + /** @type {Error} */ (err).message
);
throw new Error(
'Could not launch any browser (no bundled Chromium and chrome-launcher failed).'
);
}
if (!chromePaths || chromePaths.length === 0) {
throw new Error(
'No Chrome/Chromium installations found by chrome-launcher; cannot launch browser.'
);
}
const chosen = chromePaths[0];
errorLogMessages.push('[launch] Using system Chrome/Chromium at: ' + chosen);
try {
const browser = await puppeteer.launch({
// @ts-ignore - 'new' is valid for newer puppeteer versions
headless: 'new',
executablePath: chosen
});
return browser;
} catch (err) {
errorLogMessages.push(
'[launch] Failed to launch Puppeteer with system Chrome: ' +
/** @type {Error} */ (err).message
);
throw new Error('Could not launch system Chrome/Chromium with Puppeteer.');
}
}
/**
* Generate a very simple HTML shell. SVG is injected later via page.evaluate()
* using DOMParser, so this page starts empty on purpose.
*/
function makeHtmlShell() {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>SvgVisualBBox Test</title>
<style>
html, body {
margin: 0;
padding: 0;
background: #ffffff;
}
</style>
</head>
<body>
<!-- SVG will be injected here -->
</body>
</html>`;
}
/**
* Main test runner.
*/
async function runTest() {
// Display version
printInfo(`sbb-test v${getVersion()} | svg-bbox toolkit\n`);
const args = process.argv.slice(2);
if (args.length < 1) {
throw new ValidationError('Usage: node sbb-test.cjs path/to/file.svg');
}
// SECURITY: Validate and sanitize input path
const safePath = validateFilePath(args[0], {
requiredExtensions: ['.svg'],
mustExist: true
});
// SECURITY: Read SVG with size limit and validation
const svgContent = readSVGFileSafe(safePath);
// SECURITY: Sanitize SVG content (remove scripts, event handlers)
const sanitizedSvg = sanitizeSVGContent(svgContent);
const baseName = path.basename(safePath, path.extname(safePath));
const outJsonPath = path.resolve(process.cwd(), `${baseName}-bbox-results.json`);
const errLogPath = path.resolve(process.cwd(), `${baseName}-bbox-errors.log`);
const errorLogMessages = [];
let browser = null;
try {
browser = await launchBrowserWithFallback(errorLogMessages);
} catch (err) {
errorLogMessages.push('[fatal] ' + err.stack);
// SECURITY: Use writeFileSafe for error log
const safeErrPath = validateOutputPath(errLogPath);
writeFileSafe(safeErrPath, errorLogMessages.join('\n'), 'utf8');
throw new Error(`Failed to launch browser; see error log: ${safeErrPath}`);
}
const page = await browser.newPage();
// SECURITY: Set page timeout
page.setDefaultTimeout(BROWSER_TIMEOUT_MS);
page.setDefaultNavigationTimeout(BROWSER_TIMEOUT_MS);
// Collect page console + errors into error log
page.on('console', (msg) => {
const type = msg.type();
const text = msg.text();
errorLogMessages.push(`[page console ${type}] ${text}`);
});
page.on('pageerror', (err) => {
errorLogMessages.push('[page error] ' + /** @type {Error} */ (err).stack);
});
try {
// 1. Load a minimal HTML shell
await page.setContent(makeHtmlShell(), { waitUntil: 'load' });
// 2. Inject SvgVisualBBox.js (UMD) from local file
const libPath = path.resolve(__dirname, 'SvgVisualBBox.js');
if (!fs.existsSync(libPath)) {
throw new Error('SvgVisualBBox.js not found at: ' + libPath);
}
await page.addScriptTag({ path: libPath });
// 3. Now run tests in the browser context
const results = await page.evaluate(async (svgString) => {
/* eslint-disable no-undef */
const res = {
summary: {},
rootVisibleAndFull: null,
randomElementInfo: null,
randomVisibleAndFull: null,
randomAggressive: null,
unionRootAndRandom: null,
unionAll: null,
viewBoxExpansion: null,
errors: []
};
try {
if (!window.SvgVisualBBox) {
throw new Error('SvgVisualBBox not found on window; library did not load.');
}
const SvgVisualBBox = window.SvgVisualBBox;
// Parse and import SVG safely with DOMParser
let parser;
try {
parser = new DOMParser();
} catch (e) {
throw new Error('DOMParser not available: ' + e.message);
}
const svgDoc = parser.parseFromString(svgString, 'image/svg+xml');
const originalSvg = svgDoc.documentElement;
if (!originalSvg || originalSvg.nodeName.toLowerCase() !== 'svg') {
throw new Error('Provided file does not appear to be a valid <svg> root.');
}
const importedSvg = document.importNode(originalSvg, true);
// Ensure it has an id for easier debugging
if (!importedSvg.id) {
importedSvg.id = 'rootSvg';
}
document.body.appendChild(importedSvg);
res.summary.rootSvgId = importedSvg.id || null;
// --- 1) root: visible + full bboxes -----------------------
try {
res.rootVisibleAndFull = await SvgVisualBBox.getSvgElementVisibleAndFullBBoxes(
importedSvg,
{
coarseFactor: 3,
fineFactor: 24,
useLayoutScale: true
}
);
} catch (e) {
res.errors.push('[rootVisibleAndFull] ' + e.message);
}
// --- 2) pick a random element (excluding defs/metadata/etc.) -----
const allCandidates = Array.from(importedSvg.querySelectorAll('*')).filter((el) => {
const tag = el.tagName.toLowerCase();
// skip non-rendering / meta elements
if (['defs', 'title', 'desc', 'metadata', 'script', 'style'].includes(tag)) {
return false;
}
// we also skip the root in this pool; we'll include it explicitly
if (el === importedSvg) {
return false;
}
return true;
});
let randomElement = null;
if (allCandidates.length > 0) {
const index = Math.floor(Math.random() * allCandidates.length);
randomElement = allCandidates[index];
res.randomElementInfo = {
tagName: randomElement.tagName,
id: randomElement.id || null,
index
};
// random element visible+full
try {
res.randomVisibleAndFull = await SvgVisualBBox.getSvgElementVisibleAndFullBBoxes(
randomElement,
{
coarseFactor: 3,
fineFactor: 24,
useLayoutScale: true
}
);
} catch (e) {
res.errors.push('[randomVisibleAndFull] ' + e.message);
}
// random element aggressive direct bbox
try {
res.randomAggressive = await SvgVisualBBox.getSvgElementVisualBBoxTwoPassAggressive(
randomElement,
{
mode: 'clipped', // test default mode
coarseFactor: 3,
fineFactor: 24,
useLayoutScale: true
}
);
} catch (e) {
res.errors.push('[randomAggressive] ' + e.message);
}
// --- 3) union of root + random element -------------------------
try {
res.unionRootAndRandom = await SvgVisualBBox.getSvgElementsUnionVisualBBox(
[importedSvg, randomElement],
{
mode: 'clipped',
coarseFactor: 3,
fineFactor: 24,
useLayoutScale: true
}
);
} catch (e) {
res.errors.push('[unionRootAndRandom] ' + e.message);
}
} else {
res.summary.note = 'No suitable random elements found (only defs/metadata).';
}
// --- 4) union of *all* drawable elements (if any) ---------------
if (allCandidates.length > 0) {
// @ts-ignore - concat types work correctly at runtime
const unionTargets = [importedSvg].concat(allCandidates.slice(0, 20)); // limit for sanity
try {
res.unionAll = await SvgVisualBBox.getSvgElementsUnionVisualBBox(unionTargets, {
mode: 'clipped',
coarseFactor: 3,
fineFactor: 24,
useLayoutScale: true
});
} catch (e) {
res.errors.push('[unionAll] ' + e.message);
}
}
// --- 5) root: viewBox expansion for full drawing ----------------
try {
// @ts-ignore - getSvgRootViewBoxExpansionForFullDrawing exists at runtime
res.viewBoxExpansion = await SvgVisualBBox.getSvgRootViewBoxExpansionForFullDrawing(
importedSvg,
{
coarseFactor: 3,
fineFactor: 24,
useLayoutScale: true
}
);
} catch (e) {
res.errors.push('[viewBoxExpansion] ' + e.message);
}
} catch (e) {
res.errors.push('[top-level evaluate] ' + (e.stack || e.message));
}
return res;
/* eslint-enable no-undef */
}, sanitizedSvg);
// SECURITY: Write output JSON with path validation
const safeJsonPath = validateOutputPath(outJsonPath, {
requiredExtensions: ['.json']
});
writeFileSafe(safeJsonPath, JSON.stringify(results, null, 2), 'utf8');
// Append any page-accumulated errors to error log
if (results && Array.isArray(results.errors) && results.errors.length > 0) {
errorLogMessages.push('--- errors from browser context ---');
for (const msg of results.errors) {
errorLogMessages.push(msg);
}
}
// SECURITY: Write error log with path validation
const safeErrPath = validateOutputPath(errLogPath);
writeFileSafe(safeErrPath, errorLogMessages.join('\n'), 'utf8');
printSuccess(`Results written to: ${safeJsonPath}`);
printInfo(`Errors written to: ${safeErrPath}`);
} catch (err) {
errorLogMessages.push('[fatal in runTest] ' + err.stack);
// SECURITY: Write error log with path validation
const safeErrPath = validateOutputPath(errLogPath);
writeFileSafe(safeErrPath, errorLogMessages.join('\n'), 'utf8');
throw new Error(`Fatal error; see error log: ${safeErrPath}`);
} finally {
// SECURITY: Ensure browser is always closed
if (browser) {
try {
await browser.close();
} catch {
// Force kill if close fails
if (browser.process()) {
browser.process().kill('SIGKILL');
}
}
}
}
}
runCLI(runTest);