Spaces:
Running
Running
| /** | |
| * 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,'<').replace(/>/g,'>'); } | |
| // Shiki-style colored code token | |
| function shikiWrap(token, color) { | |
| return `<span style="color:${color}">${E(token)}</span>`; | |
| } | |
| // 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 || '<p>This is a typical AI response explaining a concept with some details and examples.</p>'; | |
| const shikiCode = generateShikiCode(CODE_LINES); | |
| const codeBlock = `<pre data-language="javascript"><code class="language-javascript">${shikiCode}</code></pre>`; | |
| 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 = `<!DOCTYPE html><html><head><meta charset="UTF-8"><style>${CSS}</style></head><body><div class="chat-container">`; | |
| // 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 += `<div class="message-row"><div class="avatar">🤖</div><div class="message-bubble ai-bubble">${codeBlock}</div></div>`; | |
| i++; | |
| } | |
| if (j < textIterations) { | |
| html += `<div class="message-row"><div class="avatar">👤</div><div class="message-bubble user-bubble">${textContent}</div></div>`; | |
| j++; | |
| } | |
| } | |
| html += '</div></body></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(); | |