/** * PDF Generation Server for XWX AI Chat Exporter * * Key Optimizations (2026-02-13): * * 1. headless: 'shell' mode (CRITICAL) * - Issue: Puppeteer's new headless mode ('new') causes severe PDF file size inflation * (e.g., 1.27MB images → 9.83MB PDF, nearly 8x larger) * - Solution: Use 'shell' mode instead of 'new' mode * - Result: PDF size reduced to normal (1.27MB images → ~1.5MB PDF) * - Reference: https://github.com/puppeteer/puppeteer/issues/458 * * 2. Extended image loading timeout (8 seconds) * - Issue: Base64 images need time to decode and render in Chromium * 2-second timeout caused 30% of images to fail loading * - Solution: Increased timeout from 2s to 8s for reliable base64 image rendering * - Result: 100% image loading success rate * * 3. waitForNetworkIdle after setContent * - Issue: page.setContent() doesn't wait for all resources to stabilize * - Solution: Added waitForNetworkIdle({ idleTime: 500 }) after setContent * - Result: Ensures all base64 images are fully decoded before PDF generation */ const express = require('express'); const puppeteer = require('puppeteer'); const cors = require('cors'); const { getHighlighter } = require('shiki'); const fs = require('fs'); const path = require('path'); const os = require('os'); const { BrowserPool } = require('./browser-pool'); let ChartJSNodeCanvas = null; try { ChartJSNodeCanvas = require('chartjs-node-canvas').ChartJSNodeCanvas; } catch (e) { console.log('[WIDGET] chartjs-node-canvas not yet installed, will retry on demand'); } // ─── Shiki Highlighter Initialization ───────────────── // Maps frontend codeTheme settings to Shiki theme names const THEME_MAP = { 'github': 'github-light', 'monokai': 'monokai', 'oneDark': 'one-dark-pro', }; let shikiHighlighter = null; async function initShiki() { try { console.log('[Shiki] Initializing highlighter with bundled languages...'); shikiHighlighter = await getHighlighter({ themes: ['github-light', 'monokai', 'one-dark-pro'], langs: [ // Core languages (all included in shiki default bundle) 'javascript', 'typescript', 'python', 'java', 'c', 'cpp', 'csharp', 'go', 'rust', 'ruby', 'php', 'swift', 'kotlin', 'sql', 'bash', 'shell', 'yaml', 'json', 'html', 'xml', 'css', 'scss', 'less', 'markdown', 'diff', 'dockerfile', 'lua', 'r', 'dart', 'scala', // Additional languages 'perl', 'haskell', 'erlang', 'elixir', 'clojure', 'groovy', 'objective-c', 'asm', 'powershell', 'makefile', 'cmake', 'protobuf', 'graphql', 'toml', 'ini', 'git-rebase', // Rare languages (verified in shiki default bundle) 'abap', 'cobol', 'pascal', 'racket', 'latex', 'tex', 'viml', 'nginx', 'apache', 'vue', 'svelte', 'zig', 'matlab', 'julia', 'astro', ], }); const langs = shikiHighlighter.getLoadedLanguages(); console.log(`[Shiki] Highlighter ready with ${langs.length} languages: ${langs.slice(0, 20).join(', ')}...`); } catch (e) { console.error('[Shiki] Failed to initialize:', e.message); shikiHighlighter = null; } } // Pre-highlight code blocks in HTML using Shiki (inline styles) function highlightHtmlWithShiki(html, themeName = 'github-light') { if (!shikiHighlighter) return html; const shikiTheme = THEME_MAP[themeName] || 'github-light'; return html.replace(/
]*)>]*>([\s\S]*?)<\/code><\/pre>/gi, (match, preAttrs, codeText) => {
// Skip if already has syntax spans (Shiki-style or hljs-style)
if (/').replace(/&/g, '&').replace(/"/g, '"'),
{ lang, theme: shikiTheme }
);
// Restore data-language attribute for CSS language label display
highlighted = highlighted.replace(/ {
console.log('[Shiki] Startup initialization complete.');
}).catch(() => {});
const app = express();
const port = process.env.PORT || 7860;
const isTest = process.env.NODE_ENV === 'test';
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// ─── Performance configuration (env overridable) ────────────────────
// PDF_POOL_SIZE: how many Chromium browsers to keep warm. Each browser
// renders ONE PDF at a time (page.pdf() is CPU-bound), so this is also the
// max concurrent PDF jobs. Default 4 works well on multi-core; set to 2 for
// Hugging Face free tier (2 vCPU / 16GB).
// PDF_RECYCLE_AFTER: recycle a browser after N jobs to bound memory growth.
// WIDGET_MAX_CONCURRENT: concurrent widget renders inside the shared widget
// browser (each widget gets its own page).
const PDF_POOL_SIZE = parseInt(process.env.PDF_POOL_SIZE || '4', 10);
const PDF_RECYCLE_AFTER = parseInt(process.env.PDF_RECYCLE_AFTER || '30', 10);
const PDF_ACQUIRE_TIMEOUT_MS = parseInt(process.env.PDF_ACQUIRE_TIMEOUT_MS || '120000', 10);
const WIDGET_MAX_CONCURRENT = parseInt(process.env.WIDGET_MAX_CONCURRENT || '3', 10);
// 单次 CDP 调用(page.evaluate / screenshot / setContent 等)的最大等待时间。
// 必须小于 render_charts 的 30s 应用级超时,确保挂起操作能及时释放并发槽位。
const WIDGET_CDP_TIMEOUT_MS = parseInt(process.env.WIDGET_CDP_TIMEOUT_MS || '20000', 10);
// 单个 widget 整个渲染流程的硬超时(无论内部卡在哪一步),保证渲染必然在
// 应用级 30s 超时之前结束,并发槽位不会被无限期占用。
const WIDGET_HARD_TIMEOUT_MS = parseInt(process.env.WIDGET_HARD_TIMEOUT_MS || '20000', 10);
// 从 widget 浏览器池获取浏览器的最长等待时间(池满时排队)
const WIDGET_ACQUIRE_TIMEOUT_MS = parseInt(process.env.WIDGET_ACQUIRE_TIMEOUT_MS || '15000', 10);
const PDF_LAUNCH_ARGS = [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--font-render-hinting=none',
'--disable-gpu',
'--disable-software-rasterizer',
'--memory-pressure-off'
];
const PDF_LAUNCH_OPTIONS = {
executablePath: '/usr/bin/chromium',
protocolTimeout: 0,
// Load images served with expired/invalid SSL certificates.
// Third-party image CDNs (e.g. imgs.sbkko.com) can have certificate issues;
// the PDF must still render those images the user saw in the conversation.
acceptInsecureCerts: true,
args: PDF_LAUNCH_ARGS,
headless: 'shell'
};
const pdfPool = new BrowserPool({
name: 'pdf',
size: PDF_POOL_SIZE,
launchOptions: PDF_LAUNCH_OPTIONS,
recycleAfter: PDF_RECYCLE_AFTER,
acquireTimeoutMs: PDF_ACQUIRE_TIMEOUT_MS,
log: (msg) => console.log(`[PERF] ${msg}`)
});
// ─── Widget browser pool ───────────────────────────────────────
// 为什么用「池」而不是单例浏览器(v2.1.7 曾改为单例 + 高并发 page):
// 单例浏览器下多个 widget 同时渲染会争抢同一个浏览器进程的页面主线程
// (Runtime.evaluate / screenshot 实测可挂起 20s+,最严重 231s),单个
// widget 拖慢后占满全局并发队列,导致后续 widget 排队直至 30s 超时级联,
// DOCX 导出因此把图表 fallback 成数据表格。池化后每个 widget 独占一个
// 浏览器(进程级隔离,等同稳定版 v2.0.8 的每 widget 一浏览器),同时保留
// 浏览器复用(避免每次启动 Chromium 的开销)。池大小 = WIDGET_MAX_CONCURRENT。
const WIDGET_LAUNCH_OPTIONS = {
executablePath: '/usr/bin/chromium',
// 有限 CDP 超时(而非 PDF 的 0):池内浏览器被 widget 共享复用,单次 CDP
// 调用挂起必须在应用级 30s 超时之前抛错并释放浏览器,否则会拖垮整个池。
protocolTimeout: WIDGET_CDP_TIMEOUT_MS,
// Same rationale as PDF_LAUNCH_OPTIONS: widget HTML may embed images
// from third-party CDNs with expired/invalid certificates.
acceptInsecureCerts: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--font-render-hinting=none',
'--disable-gpu',
'--disable-software-rasterizer',
'--enable-webgl',
'--use-gl=angle',
'--use-angle=swiftshader',
'--memory-pressure-off'
],
headless: 'shell'
};
const widgetPool = new BrowserPool({
name: 'widget',
size: WIDGET_MAX_CONCURRENT,
launchOptions: WIDGET_LAUNCH_OPTIONS,
recycleAfter: 100,
acquireTimeoutMs: WIDGET_ACQUIRE_TIMEOUT_MS,
log: (msg) => console.log(`[PERF] ${msg}`)
});
console.log(`[PERF] PDF browser pool: size=${PDF_POOL_SIZE}, recycleAfter=${PDF_RECYCLE_AFTER}, widgetConcurrent=${WIDGET_MAX_CONCURRENT}, widgetPoolSize=${WIDGET_MAX_CONCURRENT}`);
app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.get('/', (req, res) => {
res.send(`Puppeteer PDF Server Running (${isTest ? '测试环境' : '生产环境'})`);
});
app.post('/api/generate_pdf', async (req, res) => {
const startTime = Date.now();
// ── 功能性字段(PDF渲染业务逻辑依赖,保持顶层) ──
const { html, textOnlySizeMB, codeTheme, showWatermark,
imageCount, totalImageSizeMB, messageCount } = req.body;
// ── 遥测数据:通用 metadata 对象,后端不做任何解析/格式化,原样透传 ──
// 新插件:logs 数组预格式化,forEach 透传(后台只追加环境标签)
// 旧插件:降级输出原始 JSON
const metadata = req.body.metadata;
const envText = isTest ? '【测试环境】' : '【生产环境】';
if (metadata?.logs?.length) {
console.log(`---------------[PDF-GEN] ${envText} ${metadata.logs[0]}`);
for (let i = 1; i < metadata.logs.length; i++) console.log(metadata.logs[i]);
} else {
console.log(`[PDF-GEN] ${envText} metadata: ${JSON.stringify(metadata)}`);
}
const getElapsed = () => ((Date.now() - startTime) / 1000).toFixed(2) + 's';
let browser = null;
let acquired = null; // pooled browser slot (release in finally)
let page = null;
let tempFilePath = null;
try {
if (!html) {
return res.status(400).json({ error: 'Missing html content' });
}
// ─── Syntax Highlighting Path Selection ───
// Priority: codeTheme param > HTML detection > Shiki default
// 1. If codeTheme is sent (new plugin) → always use Shiki
// 2. If no codeTheme → check for highlight.js script in HTML
// - Found → legacy highlight.js path (old plugin)
// - Not found → Shiki default (no highlighting in HTML)
const hasHighlightJsScript = html.includes('highlight.min.js') || html.includes('highlight.full.min.js') || html.includes('hljs.highlightAll');
let htmlToProcess = html;
if (codeTheme) {
console.log(`[PDF-GEN] [${getElapsed()}] codeTheme provided (${codeTheme}), using Shiki`);
htmlToProcess = highlightHtmlWithShiki(html, codeTheme);
} else if (hasHighlightJsScript && shikiHighlighter) {
console.log(`[PDF-GEN] [${getElapsed()}] No codeTheme, detected highlight.js in HTML, using legacy path`);
} else if (shikiHighlighter) {
console.log(`[PDF-GEN] [${getElapsed()}] No codeTheme, no highlight.js, using Shiki default`);
htmlToProcess = highlightHtmlWithShiki(html, 'github');
}
const brandText = showWatermark !== false ? 'Powered by XWX AI Chat Exporter' : '';
const htmlSizeMBNum = Buffer.byteLength(html, 'utf8') / (1024 * 1024);
const htmlSizeMB = htmlSizeMBNum.toFixed(2);
const imgCount = imageCount || 0;
const imgSizeMB = totalImageSizeMB || 0;
// 使用前端传来的纯文本 HTML 大小(扣除 base64 图片)做时间预估
// base64 图片不增加 Chromium PDF 引擎的渲染复杂度,benchmark 公式也是基于纯文本校准的
// 如果前端未提供(旧版本插件),回退到总大小(向后兼容)
const effectiveSizeMB = textOnlySizeMB != null ? textOnlySizeMB : htmlSizeMBNum;
console.log(`[PDF-GEN] [${getElapsed()}] 解析请求完成: HTML ${htmlSizeMB} MB (纯文本=${effectiveSizeMB.toFixed(2)} MB), 消息 ${messageCount || 0} 条, 图片 ${imgCount} 张 (${imgSizeMB} MB)`);
// DEBUG: Count actual images in HTML received
const htmlImgTagRegex = /
]+src=["']data:image\/[^"']+/gi;
const htmlImgTags = htmlToProcess.match(htmlImgTagRegex);
const htmlImageCount = htmlImgTags ? htmlImgTags.length : 0;
console.log(`[PDF-IMAGE] HTML contains ${htmlImageCount}
tags with data: URLs (frontend reported ${imgCount})`);
if (htmlImgTags && htmlImgTags.length > 0) {
// Log src type distribution
const pngCount = htmlImgTags.filter(t => t.includes('data:image/png')).length;
const jpegCount = htmlImgTags.filter(t => t.includes('data:image/jpeg') || t.includes('data:image/jpg')).length;
const gifCount = htmlImgTags.filter(t => t.includes('data:image/gif')).length;
const svgCount = htmlImgTags.filter(t => t.includes('data:image/svg')).length;
const webpCount = htmlImgTags.filter(t => t.includes('data:image/webp')).length;
console.log(`[PDF-IMAGE] Format breakdown: PNG=${pngCount}, JPEG=${jpegCount}, GIF=${gifCount}, SVG=${svgCount}, WebP=${webpCount}`);
}
// HTML 大小预警:超过 10 MB 时 Chromium PDF 引擎可能崩溃,但不阻止处理
// 前端已显示警告提示用户,这里仅记录日志
// Benchmark 验证:7.01 MB 需要 31 分钟,大文件可能超时
const MAX_RECOMMENDED_TEXT_HTML_SIZE_MB = 10;
if (effectiveSizeMB > MAX_RECOMMENDED_TEXT_HTML_SIZE_MB) {
console.log(`[PDF-GEN] [${getElapsed()}] ⚠️ 大文件预警: 纯文本=${effectiveSizeMB.toFixed(2)} MB,超过推荐上限 ${MAX_RECOMMENDED_TEXT_HTML_SIZE_MB} MB,Chromium PDF 引擎可能超时或崩溃,继续尝试处理`);
}
// 移除