/**
* End-to-end render benchmark: html2canvas-pro vs upstream html2canvas 1.4.1.
*
* Renders real fixtures in headless Chromium via Playwright, timing the full
* clone → parse → rasterise pipeline of both libraries inside the same page,
* with runs interleaved to cancel ordering / thermal bias.
*
* Prerequisites:
* corepack pnpm build # dist/ must match the current sources
* npx playwright install chromium
*
* Run with:
* corepack pnpm bench:render
*/
import http from 'node:http';
import { readFile, writeFile, stat } from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const PRO_UMD = path.join(ROOT, 'dist', 'html2canvas-pro.js');
const UPSTREAM_URL = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
const UPSTREAM_CACHE = path.join(os.tmpdir(), 'html2canvas-1.4.1.min.js');
const RUNS = 10;
const WARMUP = 2;
const RUN_TIMEOUT_MS = 60000;
// The page context cannot always reach the CDN, so the upstream bundle is
// fetched once up front and injected from disk (also makes runs offline-safe).
const ensureUpstreamBundle = async () => {
try {
const cached = await stat(UPSTREAM_CACHE);
if (cached.isFile() && cached.size > 100000) return UPSTREAM_CACHE;
} catch {
// not cached yet
}
const res = await fetch(UPSTREAM_URL);
if (!res.ok) throw new Error(`Failed to download upstream html2canvas: HTTP ${res.status}`);
const body = Buffer.from(await res.arrayBuffer());
await writeFile(UPSTREAM_CACHE, body);
return UPSTREAM_CACHE;
};
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript',
'.css': 'text/css',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.woff': 'font/woff',
'.woff2': 'font/woff2'
};
const SIMPLE_FIXTURE = `
Quarterly report
Plain document benchmark fixture
${Array.from(
{ length: 40 },
(_, i) =>
`Section ${i + 1}
Lorem ipsum dolor sit amet, ` +
'consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore ' +
'magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
'
).join('\n ')}
`;
// Legacy CSS syntax only (hex/rgb colours, linear/radial gradients) so that the
// upstream library can complete it too — isolates pipeline speed from feature coverage.
const LARGE_FIXTURE = `
${Array.from({ length: 300 }, (_, i) => {
const t = i * 37;
const pastel = `rgb(${140 + (t % 100)}, ${190 + (t % 60)}, 255)`;
return (
`` +
`
Item ${i + 1} active
` +
'
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor ' +
'incididunt ut labore et dolore magna aliqua ut enim ad minim veniam.
'
);
}).join('\n ')}
`;
const startServer = () =>
new Promise((resolve) => {
const server = http.createServer((req, res) => {
const pathname = decodeURIComponent(new URL(req.url, 'http://127.0.0.1').pathname);
const file = path.normalize(path.join(ROOT, pathname));
if (!file.startsWith(ROOT)) {
res.writeHead(403).end();
return;
}
readFile(file)
.then((data) => {
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] ?? 'application/octet-stream' });
res.end(data);
})
.catch(() => res.writeHead(404).end());
});
server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }));
});
// Both bundles expose the global `html2canvas`, so the pro build is loaded first
// and aliased before the upstream build overwrites the global.
const installBench = () => {
window.html2canvasPro = window.html2canvas;
window.__run = async (globalName) => {
const start = performance.now();
try {
const canvas = await window[globalName](document.body, { logging: false, scale: 1 });
return { ms: performance.now() - start, width: canvas.width, height: canvas.height };
} catch (err) {
return { error: String(err && err.message ? err.message : err) };
}
};
};
const timedRun = (page, globalName) =>
Promise.race([
page.evaluate((g) => window.__run(g), globalName),
new Promise((_, reject) => setTimeout(() => reject(new Error('run timed out')), RUN_TIMEOUT_MS))
]).catch(() => null);
const stats = (values) => {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
return {
median: sorted[Math.floor(sorted.length / 2)],
mean,
min: sorted[0],
max: sorted[sorted.length - 1]
};
};
const fmt = (ms) => (ms == null ? ' n/a ' : `${ms.toFixed(0)} ms`.padStart(9));
const benchFixture = async (page, label, load, pageErrors, upstreamPath) => {
pageErrors.length = 0;
await load();
await page.addScriptTag({ path: PRO_UMD });
await page.evaluate(installBench);
await page.addScriptTag({ path: upstreamPath });
for (let i = 0; i < WARMUP; i++) {
await timedRun(page, 'html2canvasPro').catch(() => undefined);
await timedRun(page, 'html2canvas').catch(() => undefined);
}
const proTimes = [];
const upstreamTimes = [];
let upstreamFailures = 0;
let firstUpstreamError = null;
for (let i = 0; i < RUNS; i++) {
// Sequential runs; alternate which library goes first to cancel ordering bias.
const proFirst = i % 2 === 0;
const first = await timedRun(page, proFirst ? 'html2canvasPro' : 'html2canvas');
const second = await timedRun(page, proFirst ? 'html2canvas' : 'html2canvasPro');
const pro = proFirst ? first : second;
const upstream = proFirst ? second : first;
if (pro?.ms != null) proTimes.push(pro.ms);
if (upstream?.ms != null) upstreamTimes.push(upstream.ms);
else {
upstreamFailures++;
firstUpstreamError ??= upstream?.error ?? 'timeout';
}
if (pro?.ms != null && upstream?.ms != null && i === 0) {
console.log(
` canvas sizes: pro ${pro.width}x${pro.height}, upstream ${upstream.width}x${upstream.height}`
);
}
}
const pro = stats(proTimes);
const up = stats(upstreamTimes);
const speedup = up ? up.median / pro.median : null;
console.log(`\n[${label}] (n=${RUNS} measured runs each, after ${WARMUP} warmups)`);
console.log(
` html2canvas-pro median ${fmt(pro?.median)} mean ${fmt(pro?.mean)} min ${fmt(pro?.min)} max ${fmt(pro?.max)}`
);
console.log(
` html2canvas 1.4.1 median ${fmt(up?.median)} mean ${fmt(up?.mean)} min ${fmt(up?.min)} max ${fmt(up?.max)}`
);
console.log(` speedup (median): ${speedup ? `${speedup.toFixed(2)}x` : 'n/a'}`);
if (upstreamFailures > 0)
console.log(` ⚠ upstream failed on ${upstreamFailures}/${RUNS} measured runs: ${firstUpstreamError}`);
if (pageErrors.length > 0) console.log(` ⚠ page errors: ${pageErrors.slice(0, 3).join(' | ')}`);
return { label, pro, up, speedup };
};
const pkg = JSON.parse(await readFile(path.join(ROOT, 'package.json'), 'utf8'));
console.log(`\nRender benchmark — html2canvas-pro ${pkg.version} vs html2canvas 1.4.1`);
console.log('Chromium (Playwright, headless), scale: 1 pinned for both, runs interleaved\n');
const { server, port } = await startServer();
const browser = await chromium.launch();
const page = await browser.newPage();
const pageErrors = [];
page.on('pageerror', (err) => pageErrors.push(String(err)));
try {
const upstreamPath = await ensureUpstreamBundle();
await benchFixture(
page,
'simple document (41 cards of text)',
() => page.setContent(SIMPLE_FIXTURE, { waitUntil: 'load' }),
pageErrors,
upstreamPath
);
await benchFixture(
page,
'large document (300 cards, ~1200 elements, legacy CSS only)',
() => page.setContent(LARGE_FIXTURE, { waitUntil: 'load' }),
pageErrors,
upstreamPath
);
await benchFixture(
page,
'features-showcase (full CSS feature demo)',
() => page.goto(`http://127.0.0.1:${port}/dev/features-showcase.html`, { waitUntil: 'load' }),
pageErrors,
upstreamPath
);
} finally {
await browser.close();
server.close();
}
console.log('\nAbsolute times vary by machine — compare ratios, not milliseconds.\n');