File size: 10,552 Bytes
b89c431
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
/**
 * 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }

// 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">&#129302;</div><div class="message-bubble ai-bubble">${codeBlock}</div></div>`;
      i++;
    }
    if (j < textIterations) {
      html += `<div class="message-row"><div class="avatar">&#128100;</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();