/**
* Benchmark: 测量不同 HTML 大小下 page.pdf() 的实际渲染时间
*/
const http = require('http');
const fs = require('fs');
const BACKEND_PORT = process.env.PORT || 7861;
const BACKEND_URL = `http://localhost:${BACKEND_PORT}`;
// CSS from buildDocumentCss
const CSS = `@media print { @page { size: A4; margin: 15mm 10mm; } body { -webkit-print-color-adjust: exact; } }
body { font-family: -apple-system, sans-serif; font-size: 14px; line-height: 1.6; max-width: 746px; margin: 0 auto; padding: 20px; }
h1,h2,h3 { font-weight: 600; margin: 16px 0 8px; }
pre { background: #f6f8fa; padding: 16px; border-radius: 6px; overflow-x: auto; border: 1px solid #e1e4e8; }
code { font-family: monospace; font-size: 13px; }
p { margin: 8px 0; } table { border-collapse: collapse; width: 100%; margin: 12px 0; }
th,td { border: 1px solid #dfe2e5; padding: 8px 12px; } th { background: #f1f3f4; }
.chat-container { display: flex; flex-direction: column; gap: 16px; }
.message-row { display: flex; gap: 10px; } .message-bubble { max-width: 85%; padding: 12px 16px; border-radius: 12px; }
.ai-bubble { background: #fff; border: 1px solid #eee; } .user-bubble { background: #e8f0fe; }
.avatar { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; }`;
function E(s) { return s.replace(/&/g,'&').replace(//g,'>'); }
// Shiki-style colored code token
function shikiWrap(token, color) {
return `${E(token)}`;
}
// Generate code with Shiki-style spans (simulating real output)
function generateShikiCode(lines) {
return lines.map(line => {
const parts = [];
const tokens = line.split(/(\s+|[^a-zA-Z0-9_\s]+)/g);
for (const tok of tokens) {
if (!tok) continue;
if (/^\s+$/.test(tok)) { parts.push(tok); continue; }
let color = '#c9d1d9'; // plain
if (/^(const|let|var|function|return|if|else|for|async|await|import|from|export|class|extends|new|try|catch|throw|typeof|this|switch|case|break|continue|while|do|of|in|static|get|set|super|interface|type|void|null|undefined|true|false)$/.test(tok)) color = '#ff7b72';
else if (/^(console|log|error|fetch|Promise|Math|Date|JSON|Map|Set|Array|Object|String|Number|Error|setTimeout|clearTimeout|AbortController|AbortSignal|require|module|exports|process)$/.test(tok)) color = '#d2a8ff';
else if (/^\d+$/.test(tok)) color = '#79c0ff';
else if (/^[{}()\[\];,\.:=+\-*/<>!&|?%@~^'"`]+$/.test(tok)) color = '#c9d1d9';
else if (/^[A-Z]/.test(tok) && tok.length > 1) color = '#ffa657';
parts.push(shikiWrap(tok, color));
}
return parts.join('');
}).join('\n');
}
const CODE_LINES = [
'async function fetchData(url, options = {}) {',
' const controller = new AbortController();',
' const timeout = setTimeout(() => controller.abort(), 30000);',
' try {',
' const response = await fetch(url, {',
' ...options,',
' signal: controller.signal,',
" headers: { 'Content-Type': 'application/json' },",
' });',
' if (!response.ok) {',
' throw new Error(`HTTP ${response.status}: ${response.statusText}`);',
' }',
' const data = await response.json();',
" console.log('Data received:', data);",
' return data;',
' } catch (error) {',
" console.error('Fetch failed:', error.message);",
' throw error;',
' } finally {',
' clearTimeout(timeout);',
' }',
'}',
];
function buildHtml(targetSizeMB, opts = {}) {
const shikiRatio = opts.shikiRatio || 0.5; // fraction of content that is Shiki code
const textContent = opts.textContent || '
This is a typical AI response explaining a concept with some details and examples.
';
const shikiCode = generateShikiCode(CODE_LINES);
const codeBlock = `${shikiCode}
`;
const codeBlockSize = Buffer.byteLength(codeBlock, 'utf8');
const textBlockSize = Buffer.byteLength(textContent, 'utf8');
const overhead = 2048; // HTML wrapper + CSS
const targetBytes = targetSizeMB * 1024 * 1024 - overhead;
let html = ``;
// Build a mix of code blocks and text
const codeWeight = shikiRatio;
const textWeight = 1 - shikiRatio;
const codeBytes = targetBytes * codeWeight;
const textBytes = targetBytes * textWeight;
const codeIterations = Math.ceil(codeBytes / codeBlockSize);
const textIterations = Math.ceil(textBytes / textBlockSize);
let i = 0, j = 0;
const totalIterations = codeIterations + textIterations;
while (i < codeIterations || j < textIterations) {
// Alternate between code and text
if (i < codeIterations) {
html += `
`;
i++;
}
if (j < textIterations) {
html += `
`;
j++;
}
}
html += '
';
const actualSize = (Buffer.byteLength(html, 'utf8') / 1024 / 1024).toFixed(2);
return html;
}
async function sendPdfRequest(html, label) {
const payload = JSON.stringify({
html,
codeTheme: 'github',
showWatermark: false,
imageCount: 0,
totalImageSizeMB: 0,
platform: 'Benchmark',
language: 'en-US',
extensionVersion: '2.0.2',
exportCount: 0, exportPdf: 0, exportMd: 0,
exportTxt: 0, exportDocx: 0, exportJson: 0,
exportClipboard: 0, exportNotion: 0,
});
return new Promise((resolve, reject) => {
const startTime = Date.now();
const options = {
hostname: 'localhost',
port: BACKEND_PORT,
path: '/api/generate_pdf',
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
timeout: 600000,
};
const req = http.request(options, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (res.statusCode === 200) {
const pdfSizeMB = (Buffer.concat(chunks).length / 1024 / 1024).toFixed(2);
resolve({ ok: true, elapsed: parseFloat(elapsed), pdfSizeMB });
} else {
const body = Buffer.concat(chunks).toString();
const errorMatch = body.match(/details":"([^"]+)"/);
resolve({ ok: false, elapsed: parseFloat(elapsed), error: errorMatch ? errorMatch[1] : body, status: res.statusCode });
}
});
});
req.on('error', (e) => reject(e));
req.on('timeout', () => { req.destroy(); reject(new Error('HTTP timeout')); });
req.write(payload);
req.end();
});
}
async function runBenchmark() {
console.log('=== PDF Rendering Benchmark ===\n');
console.log(`Backend: ${BACKEND_URL}\n`);
const testCases = [
{ size: 0.5, shikiRatio: 0, label: '0.5 MB, 纯文本' },
{ size: 0.5, shikiRatio: 0.5, label: '0.5 MB, 50% Shiki' },
{ size: 1, shikiRatio: 0, label: '1.0 MB, 纯文本' },
{ size: 1, shikiRatio: 0.5, label: '1.0 MB, 50% Shiki' },
{ size: 2, shikiRatio: 0, label: '2.0 MB, 纯文本' },
{ size: 2, shikiRatio: 0.5, label: '2.0 MB, 50% Shiki' },
{ size: 3, shikiRatio: 0, label: '3.0 MB, 纯文本' },
{ size: 3, shikiRatio: 0.5, label: '3.0 MB, 50% Shiki' },
{ size: 5, shikiRatio: 0, label: '5.0 MB, 纯文本' },
{ size: 5, shikiRatio: 0.5, label: '5.0 MB, 50% Shiki' },
];
const results = [];
for (const tc of testCases) {
const html = buildHtml(tc.size, { shikiRatio: tc.shikiRatio });
const actualSizeMB = (Buffer.byteLength(html, 'utf8') / 1024 / 1024).toFixed(2);
process.stdout.write(`\n[${tc.label}] HTML=${actualSizeMB} MB ... `);
try {
const result = await sendPdfRequest(html, tc.label);
results.push({
label: tc.label,
htmlSizeMB: parseFloat(actualSizeMB),
success: result.ok,
time: result.elapsed,
pdfSizeMB: result.pdfSizeMB || 'N/A',
error: result.error || '',
});
if (result.ok) {
process.stdout.write(`OK ${result.elapsed}s (PDF: ${result.pdfSizeMB} MB)\n`);
} else {
process.stdout.write(`FAIL ${result.elapsed}s - ${result.error}\n`);
}
} catch (err) {
process.stdout.write(`ERROR: ${err.message}\n`);
results.push({ label: tc.label, htmlSizeMB: parseFloat(actualSizeMB), success: false, time: 0, error: err.message });
}
}
// Summary table
console.log('\n\n=== Results Summary ===\n');
console.log('| HTML Size | Content Type | Time (s) | PDF Size | Status |');
console.log('|-----------|-------------|----------|----------|--------|');
for (const r of results) {
const status = r.success ? 'OK' : `FAIL (${r.error})`;
console.log(`| ${r.htmlSizeMB} MB | ${r.label.split(', ')[1]} | ${r.time.toFixed(1)}s | ${r.pdfSizeMB || '-'} MB | ${status} |`);
}
// Calculate per-MB render time
console.log('\n=== Per-MB Analysis ===\n');
const successResults = results.filter(r => r.success);
if (successResults.length >= 2) {
// Linear regression: time = base + rate * size
const n = successResults.length;
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
for (const r of successResults) {
sumX += r.htmlSizeMB;
sumY += r.time;
sumXY += r.htmlSizeMB * r.time;
sumX2 += r.htmlSizeMB * r.htmlSizeMB;
}
const rate = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
const base = (sumY - rate * sumX) / n;
console.log(`Base overhead (browser launch etc.): ${base.toFixed(1)}s`);
console.log(`Per-MB PDF render time: ${rate.toFixed(2)}s/MB`);
console.log('');
console.log('Recommended timeout formula:');
console.log(` pdfTimeout = ${Math.ceil(base)}s + sizeMB * ${Math.ceil(rate * 3)}s (3x safety margin)`);
console.log('');
// Predicted timeouts at various sizes
console.log('Predicted timeouts (with 3x safety margin):');
for (const size of [1, 2, 3, 5, 7, 10]) {
const predicted = base + rate * size;
const withMargin = Math.ceil(base + rate * size * 3);
console.log(` ${size} MB: actual ~${predicted.toFixed(0)}s, recommended timeout = ${withMargin}s`);
}
}
}
runBenchmark();