Spaces:
Running
Running
| /** | |
| * 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'); | |
| 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(/<pre([^>]*)><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (match, preAttrs, codeText) => { | |
| // Skip if already has syntax spans (Shiki-style or hljs-style) | |
| if (/<span/i.test(codeText)) return match; | |
| // Extract language from class or data attribute | |
| let lang = ''; | |
| const classMatch = preAttrs.match(/class="[^"]*language-(\w+)/i) | |
| || preAttrs.match(/data-language="(\w+)"/i); | |
| if (classMatch) lang = classMatch[1].toLowerCase(); | |
| // Try Shiki highlight | |
| if (lang && shikiHighlighter.getLoadedLanguages().includes(lang)) { | |
| try { | |
| let highlighted = shikiHighlighter.codeToHtml(codeText | |
| .replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"'), | |
| { lang, theme: shikiTheme } | |
| ); | |
| // Restore data-language attribute for CSS language label display | |
| highlighted = highlighted.replace(/<pre\s/, `<pre data-language="${lang}" `); | |
| return highlighted; | |
| } catch (e) { | |
| // Fallback to plain pre | |
| } | |
| } | |
| return match; | |
| }); | |
| } | |
| // Start Shiki initialization (non-blocking) | |
| initShiki().then(() => { | |
| 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)); | |
| 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(); | |
| const envText = isTest ? '【测试环境】' : '【生产环境】'; | |
| const { platform, exportCount, language, exportPdf, exportMd, exportTxt, exportDocx, exportJson, exportClipboard, exportNotion, extensionVersion, imageCount, totalImageSizeMB, messageCount, isPro } = req.body; | |
| // PRO status with text emphasis (no ANSI colors for Docker compatibility) | |
| const userTag = isPro ? '★★★PRO★★★' : '◆FREE◆'; | |
| const platformStr = platform || 'unknown'; | |
| const versionStr = extensionVersion || '-'; | |
| const langStr = language || '-'; | |
| // Format counts | |
| const fmtParts = []; | |
| if (exportPdf != null) fmtParts.push(`PDF:${exportPdf}`); | |
| if (exportMd != null) fmtParts.push(`MD:${exportMd}`); | |
| if (exportTxt != null) fmtParts.push(`TXT:${exportTxt}`); | |
| if (exportDocx != null) fmtParts.push(`DOCX:${exportDocx}`); | |
| if (exportJson != null) fmtParts.push(`JSON:${exportJson}`); | |
| if (exportClipboard != null) fmtParts.push(`CLIP:${exportClipboard}`); | |
| if (exportNotion != null) fmtParts.push(`NOTION:${exportNotion}`); | |
| const fmtStr = fmtParts.join(', ') || '-'; | |
| // Two-line log for readability | |
| console.log( | |
| `---------------[PDF-GEN] ${envText} ${userTag} 收到请求 | 平台: ${platformStr} | 版本: ${versionStr} | 语言: ${langStr}` | |
| ); | |
| console.log( | |
| ` 导出: ${exportCount ?? '-'}次 | 格式: ${fmtStr} | 消息: ${messageCount ?? '-'}条 | 图片: ${imageCount ?? '-'}张 (${totalImageSizeMB ?? '-'}MB)` | |
| ); | |
| console.log(`---------------`); | |
| const getElapsed = () => ((Date.now() - startTime) / 1000).toFixed(2) + 's'; | |
| let browser = null; | |
| try { | |
| const { html, showWatermark, imageCount, totalImageSizeMB, messageCount, codeTheme, textOnlySizeMB } = req.body; | |
| 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 = /<img[^>]+src=["']data:image\/[^"']+/gi; | |
| const htmlImgTags = htmlToProcess.match(htmlImgTagRegex); | |
| const htmlImageCount = htmlImgTags ? htmlImgTags.length : 0; | |
| console.log(`[PDF-IMAGE] HTML contains ${htmlImageCount} <img> 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 引擎可能超时或崩溃,继续尝试处理`); | |
| } | |
| // 移除 <script> 标签防止 Puppeteer 执行阻塞(如 alert() 弹窗会导致 setContent 内部 CDP 调用超时) | |
| htmlToProcess = htmlToProcess.replace(/<script[\s\S]*?<\/script>/gi, ''); | |
| console.log(`[PDF-GEN] [${getElapsed()}] 已移除 <script> 标签`); | |
| // 移除所有图片的 loading="lazy" 属性 | |
| // 问题:loading="lazy" 导致视口外的图片在 PDF 中不渲染 | |
| // 解决方案:https://stackoverflow.com/questions/79156691/puppeteersharp-fails-to-display-base64-encoded-images-in-pdf-output | |
| htmlToProcess = htmlToProcess.replace(/loading=["']lazy["']/gi, ''); | |
| console.log(`[PDF-GEN] [${getElapsed()}] 已移除 loading="lazy" 属性`); | |
| // 使用处理后的 HTML | |
| let htmlToUse = htmlToProcess; | |
| // 动态计算超时上限 | |
| // Benchmark 数据揭示 PDF 渲染时间呈三次方增长: | |
| // 0.82MB→8s, 1.64MB→16s, 3.28MB→85s, 4.92MB→>600s, 7.01MB→>1183s | |
| // 拟合公式: pdfRenderMs ≈ 10*size³ + 10*size² + 2*size (秒) | |
| // 使用纯文本大小(扣除 base64 图片),因为图片不增加渲染复杂度 | |
| // 超时 = 2× 安全系数, 上限 1800s (30 min) | |
| const baseTimeout = 60000; | |
| const s = effectiveSizeMB; | |
| const pdfRenderMs = (10000 * s * s * s + 10000 * s * s + 2000 * s); // 预估 PDF 实际渲染时间 (ms) | |
| const htmlExtraMs = Math.ceil(pdfRenderMs * 2); // 2x 安全系数 | |
| let extraMs = 0; | |
| if (imgCount <= 30) { | |
| extraMs = imgCount * 3000; | |
| } else if (imgCount <= 50) { | |
| extraMs = 90000 + (imgCount - 30) * 10000; | |
| } else if (imgCount <= 100) { | |
| extraMs = 290000 + (imgCount - 50) * 7000; | |
| } else { | |
| extraMs = 640000 + (imgCount - 100) * 5600; | |
| } | |
| const sizeExtraMs = (imgSizeMB || 0) * 3000; | |
| const networkTimeout = Math.min(baseTimeout + extraMs + sizeExtraMs + htmlExtraMs, 1200000); | |
| // 动态计算 setContent 超时上限(setContent 需要解析 HTML + 加载资源) | |
| const setContentTimeout = Math.min(60000 + extraMs + sizeExtraMs + htmlExtraMs, 3600000); | |
| // 动态计算截图超时上限 | |
| const screenshotTimeout = Math.min(30000 + (imgCount > 0 ? extraMs / 10 : 0) + htmlExtraMs / 10, 120000); | |
| // 动态计算 PDF 生成超时上限 | |
| const pdfTimeout = Math.min(120000 + extraMs / 5 + sizeExtraMs / 5 + htmlExtraMs, 3600000); | |
| console.log(`[PDF-GEN] [${getElapsed()}] 动态超时: setContent=${(setContentTimeout / 1000).toFixed(0)}s, waitForNetworkIdle=${(networkTimeout / 1000).toFixed(0)}s, pdf=${(pdfTimeout / 1000).toFixed(0)}s (HTML=${htmlSizeMB}MB, 纯文本=${effectiveSizeMB.toFixed(2)}MB, 图片=${imgCount}张)`); | |
| if (effectiveSizeMB > 4) { | |
| const estMinutes = (pdfTimeout / 60000).toFixed(0); | |
| console.log(`[PDF-GEN] [${getElapsed()}] ⚠️ 大文件预警: 纯文本=${effectiveSizeMB.toFixed(2)} MB HTML 预计需要 ${estMinutes} 分钟`); | |
| } | |
| console.log(`[PDF-GEN] [${getElapsed()}] 正在启动浏览器...`); | |
| // protocolTimeout: 0 = 禁用 CDP 协议层超时 | |
| // 参考: https://github.com/puppeteer/puppeteer/issues/9927 | |
| // PDF 超时时由应用层 Promise.race 控制,不依赖协议层超时 | |
| browser = await puppeteer.launch({ | |
| executablePath: '/usr/bin/chromium', | |
| protocolTimeout: 0, | |
| args: [ | |
| '--no-sandbox', | |
| '--disable-setuid-sandbox', | |
| '--disable-dev-shm-usage', | |
| '--font-render-hinting=none', | |
| '--disable-gpu', | |
| '--disable-software-rasterizer', | |
| '--memory-pressure-off' | |
| ], | |
| headless: 'shell' | |
| }); | |
| console.log(`[PDF-GEN] [${getElapsed()}] 浏览器启动成功`); | |
| const page = await browser.newPage(); | |
| // 设置 viewport 满足大部分页面渲染需求 | |
| await page.setViewport({ width: 1200, height: 800 }); | |
| console.log(`[PDF-GEN] [${getElapsed()}] Viewport: 1200x800`); | |
| // 大 HTML (> 5 MB) 使用临时文件法,避免 CDP WebSocket 传输限制 | |
| // 参考: https://danindu.medium.com/optimizing-puppeteer-for-pdf-generation-8b7777edbeca | |
| const isLargeHtml = htmlSizeMBNum > 5; | |
| let tempFilePath = null; | |
| console.log(`[PDF-GEN] [${getElapsed()}] 正在${isLargeHtml ? '通过临时文件加载' : '填充'}页面内容...`); | |
| if (isLargeHtml) { | |
| tempFilePath = path.join(os.tmpdir(), `xwx-pdf-${Date.now()}.html`); | |
| fs.writeFileSync(tempFilePath, htmlToUse, 'utf8'); | |
| await page.goto(`file://${tempFilePath}`, { | |
| waitUntil: ['load', 'networkidle0'], | |
| timeout: setContentTimeout | |
| }); | |
| } else { | |
| await page.setContent(htmlToUse, { | |
| waitUntil: ['load', 'networkidle0'], | |
| timeout: setContentTimeout | |
| }); | |
| } | |
| await page.waitForNetworkIdle({ idleTime: 500, timeout: networkTimeout }); | |
| console.log(`[PDF-GEN] [${getElapsed()}] 页面内容加载完成`); | |
| // 等待 base64 图片完全渲染(检测实际加载状态) | |
| let loadedImages = null; | |
| if (imgCount > 0) { | |
| console.log(`[PDF-GEN] [${getElapsed()}] 正在检测 ${imgCount} 张图片加载状态...`); | |
| // 只保留基本的打印颜色调整,不注入可能影响公式的CSS | |
| await page.addStyleTag({ | |
| content: `body { -webkit-print-color-adjust: exact; }` | |
| }); | |
| console.log(`[PDF-GEN] [${getElapsed()}] CSS 注入完成`); | |
| loadedImages = await page.evaluate(async () => { | |
| const images = document.querySelectorAll('img'); | |
| const results = []; | |
| for (const img of images) { | |
| const src = img.getAttribute('src') || ''; | |
| const srcPreview = src.substring(0, 80); | |
| const isBase64 = src.startsWith('data:image/'); | |
| // 对于base64图片,如果src存在且是base64格式,认为已加载(headless模式下complete可能不准确) | |
| const isLoaded = isBase64 && src.length > 100; | |
| const status = isLoaded ? (img.naturalWidth > 0 ? 'loaded' : 'loaded-base64') : (img.complete ? (img.naturalWidth > 0 ? 'loaded' : 'error') : 'pending'); | |
| results.push({ src: srcPreview, status, width: img.naturalWidth, isBase64 }); | |
| } | |
| // 对于base64图片,跳过等待onload(headless模式不准确) | |
| const base64Images = Array.from(images).filter(img => { | |
| const src = img.getAttribute('src') || ''; | |
| return src.startsWith('data:image/') && src.length > 100; | |
| }); | |
| if (base64Images.length > 0) { | |
| console.log(` 检测到 ${base64Images.length} 张base64图片,跳过onload等待(headless模式不准确)`); | |
| } else { | |
| await Promise.all(Array.from(images).map(img => { | |
| if (img.complete && img.naturalWidth > 0) { | |
| return Promise.resolve(); | |
| } | |
| return new Promise(resolve => { | |
| img.onload = () => { resolve(); }; | |
| img.onerror = () => { resolve(); }; | |
| setTimeout(() => { resolve(); }, 15000); | |
| }); | |
| })); | |
| } | |
| const finalResults = []; | |
| for (const img of images) { | |
| const src = img.getAttribute('src') || ''; | |
| const isBase64 = src.startsWith('data:image/') && src.length > 100; | |
| finalResults.push({ | |
| src: src.substring(0, 80), | |
| complete: isBase64 || img.complete, // base64图片认为已加载 | |
| width: img.naturalWidth, | |
| height: img.naturalHeight, | |
| isBase64 | |
| }); | |
| } | |
| return { | |
| initial: results, | |
| final: finalResults, | |
| total: images.length | |
| }; | |
| }); | |
| console.log(`[PDF-GEN] [${getElapsed()}] 图片加载结果:`); | |
| loadedImages.final.forEach((r, i) => { | |
| const sizeInfo = r.width > 0 ? `${r.width}x${r.height}` : 'pending'; | |
| const statusInfo = r.complete ? (r.width > 0 ? 'OK' : 'OK(base64)') : 'NOT_COMPLETE'; | |
| const srcType = r.isBase64 ? ' (base64)' : (r.src.startsWith('http') ? ' (http)' : ''); | |
| console.log(` Image ${i}: ${statusInfo}, ${sizeInfo}${srcType} src=${r.src.substring(0, 60)}...`); | |
| }); | |
| // 只对非base64图片检查失败 | |
| const failedImages = loadedImages.final.filter(r => !r.isBase64 && (r.width === 0 || !r.complete)); | |
| if (failedImages.length > 0) { | |
| console.log(`[PDF-GEN] [${getElapsed()}] ⚠️ 警告: ${failedImages.length} 张非base64图片加载失败`); | |
| } | |
| // 尝试使用 img.decode() 强制解码验证(根据 GitHub Issue #13726 方案) | |
| console.log(`[PDF-GEN] [${getElapsed()}] 尝试 img.decode() 强制解码验证...`); | |
| const decodeResults = await page.evaluate(async () => { | |
| const images = Array.from(document.querySelectorAll('img')); | |
| const results = []; | |
| for (const img of images) { | |
| const src = img.getAttribute('src') || ''; | |
| const isBase64 = src.startsWith('data:image/') && src.length > 100; | |
| if (isBase64 && img.naturalWidth > 0) { | |
| try { | |
| await img.decode(); | |
| results.push({ index: results.length, success: true, msg: 'decode OK' }); | |
| } catch (e) { | |
| results.push({ index: results.length, success: false, msg: 'decode failed: ' + e.message }); | |
| } | |
| } else { | |
| results.push({ index: results.length, success: isBase64, msg: isBase64 ? 'no naturalWidth' : 'not base64' }); | |
| } | |
| } | |
| return results; | |
| }); | |
| decodeResults.forEach((r, i) => { | |
| console.log(` Image ${i}: decode=${r.success ? 'OK' : 'FAIL'} (${r.msg})`); | |
| }); | |
| // 根据 GitHub Issue #10341:截图可以强制触发渲染流水线 | |
| console.log(`[PDF-GEN] [${getElapsed()}] 截图强制渲染(Issue #10341 workaround)...`); | |
| await page.screenshot({ type: 'png', timeout: screenshotTimeout }); | |
| console.log(`[PDF-GEN] [${getElapsed()}] 截图完成`); | |
| } else { | |
| console.log(`[PDF-GEN] [${getElapsed()}] 无图片,等待 DOM 稳定...`); | |
| await delay(200); | |
| } | |
| console.log(`[PDF-GEN] [${getElapsed()}] 正在生成 PDF 二进制流...`); | |
| // timeout: 0 = 禁用 page.pdf() 内部超时(默认 30s) | |
| // 参考: https://stackoverflow.com/questions/69436420 | |
| // 应用层用 Promise.race 做超时控制 | |
| const pdfPromise = page.pdf({ | |
| format: 'A4', | |
| printBackground: true, | |
| preferCSSPageSize: true, | |
| displayHeaderFooter: true, | |
| headerTemplate: '<div></div>', | |
| footerTemplate: ` | |
| <div style="font-size: 10px; font-family: Arial, sans-serif; color: #999; width: 100%; padding: 0 15mm; display: flex; justify-content: space-between; align-items: center;"> | |
| <div style="flex: 1; text-align: left;">${brandText}</div> | |
| <div style="flex: 1; text-align: right;"><span class="pageNumber"></span> / <span class="totalPages"></span></div> | |
| </div> | |
| `, | |
| margin: { | |
| top: '10mm', | |
| bottom: '20mm', | |
| left: '10mm', | |
| right: '10mm' | |
| }, | |
| timeout: 0 | |
| }); | |
| const timeoutPromise = new Promise((_, reject) => { | |
| setTimeout(() => reject(new Error(`PDF 生成超时 (${(pdfTimeout / 1000).toFixed(0)}s)`)), pdfTimeout); | |
| }); | |
| const pdfBuffer = await Promise.race([pdfPromise, timeoutPromise]); | |
| // 清理临时文件 | |
| if (tempFilePath) { | |
| try { fs.unlinkSync(tempFilePath); } catch {} | |
| console.log(`[PDF-GEN] [${getElapsed()}] 已清理临时文件`); | |
| } | |
| const pdfSizeMB = (pdfBuffer.length / 1024 / 1024).toFixed(2); | |
| console.log(`[PDF-GEN] [${getElapsed()}] PDF 生成成功 (${pdfSizeMB} MB),正在关闭浏览器...`); | |
| // Pass image loading summary as response header for frontend debugging | |
| if (loadedImages && loadedImages.final) { | |
| const imgSummary = loadedImages.final.map((r, i) => `${r.isBase64 ? 'base64' : 'http'}:${r.width}x${r.height}`).join('|'); | |
| res.setHeader('X-Image-Status', imgSummary.substring(0, 8000)); | |
| res.setHeader('X-Image-Count', String(loadedImages.final.length)); | |
| } | |
| await browser.close(); | |
| browser = null; | |
| console.log(`[PDF-GEN] [${getElapsed()}] >>> 任务全部完成 <<<`); | |
| res.setHeader('Content-Type', 'application/pdf'); | |
| res.setHeader('Content-Disposition', 'attachment; filename=export.pdf'); | |
| res.send(Buffer.from(pdfBuffer)); | |
| } catch (error) { | |
| console.error(`[PDF-GEN] [${getElapsed()}] 发生错误:`, error); | |
| if (browser) { | |
| try { await browser.close(); } catch (e) {} | |
| } | |
| // 清理临时文件 | |
| if (tempFilePath) { | |
| try { fs.unlinkSync(tempFilePath); } catch {} | |
| } | |
| res.status(500).json({ error: 'Internal Server Error', details: error.message }); | |
| } | |
| }); | |
| // ─── Widget Renderer Class ─────────────────────────────────── | |
| class WidgetRenderer { | |
| constructor() { | |
| this._chartInstances = new Map(); | |
| this._widgetBrowser = null; | |
| } | |
| async getWidgetBrowser() { | |
| if (this._widgetBrowser && this._widgetBrowser.isConnected()) { | |
| return this._widgetBrowser; | |
| } | |
| this._widgetBrowser = await puppeteer.launch({ | |
| executablePath: '/usr/bin/chromium', | |
| 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' | |
| }); | |
| return this._widgetBrowser; | |
| } | |
| async close() { | |
| if (this._widgetBrowser) { | |
| try { await this._widgetBrowser.close(); } catch (e) {} | |
| this._widgetBrowser = null; | |
| } | |
| } | |
| _getChartInstance(width, height) { | |
| const key = `${width}x${height}`; | |
| if (!this._chartInstances.has(key)) { | |
| let Ctor = ChartJSNodeCanvas; | |
| if (!Ctor) { | |
| ChartJSNodeCanvas = require('chartjs-node-canvas').ChartJSNodeCanvas; | |
| Ctor = ChartJSNodeCanvas; | |
| } | |
| this._chartInstances.set(key, new Ctor({ width, height, backgroundColour: '#ffffff' })); | |
| } | |
| return this._chartInstances.get(key); | |
| } | |
| async renderChart(widgetHtml, title) { | |
| const startTime = Date.now(); | |
| console.log(`[WIDGET] renderChart START: title=${title}`); | |
| const canvasCount = (widgetHtml.match(/<canvas/g) || []).length; | |
| const newChartCount = (widgetHtml.match(/new\s+Chart\s*\(/gi) || []).length + | |
| (widgetHtml.match(/new\s+[A-Z]\s*\(/g) || []).length + | |
| (widgetHtml.match(/new\s+C\s*\(/g) || []).length; | |
| const isMultiChart = canvasCount > 1 || newChartCount > 1; | |
| if (isMultiChart) { | |
| console.log(`[WIDGET] renderChart: multi-chart detected (canvas=${canvasCount}, newChart=${newChartCount}), using Puppeteer`); | |
| return this.renderWidgetPuppeteer(widgetHtml, 'chart', title, 5000); | |
| } | |
| const config = this.extractChartConfig(widgetHtml); | |
| if (!config) { | |
| console.log(`[WIDGET] renderChart FAIL: no chart config found, falling back`); | |
| return null; | |
| } | |
| const width = config.width || 800; | |
| const height = config.height || 300; | |
| const chartConfig = config.chartConfig; | |
| console.log(`[WIDGET] renderChart DEBUG: type=${chartConfig.type}, parsedWidth=${config.width}, default800=${config.width === undefined}, renderSize=${width}x${height}, labels=${chartConfig.data?.labels?.length}, datasets=${chartConfig.data?.datasets?.length}`); | |
| chartConfig.options = chartConfig.options || {}; | |
| chartConfig.options.animation = { duration: 0 }; | |
| const instance = this._getChartInstance(width, height); | |
| const buffer = await instance.renderToBuffer(chartConfig, 'image/png'); | |
| const dataUrl = `data:image/png;base64,${buffer.toString('base64')}`; | |
| console.log(`[WIDGET] renderChart OK: type=${chartConfig.type}, renderSize=${width}x${height}, size=${(buffer.length / 1024).toFixed(1)}KB`); | |
| return dataUrl; | |
| } | |
| extractChartConfig(widgetHtml) { | |
| console.log(`[WIDGET] extractChartConfig: widgetHtml length=${widgetHtml.length}`); | |
| const containerMatch = widgetHtml.match(/<div[^>]*style=["']([^"']*?)["']/); | |
| let width = 650, height = 300; | |
| let widthSource = 'default'; | |
| let heightSource = 'default'; | |
| if (containerMatch) { | |
| const w = containerMatch[1].match(/width:\s*(\d+)px/); | |
| const wPct = containerMatch[1].match(/width:\s*100%/); | |
| const h = containerMatch[1].match(/height:\s*(\d+)px/); | |
| if (w) { | |
| width = parseInt(w[1]); | |
| widthSource = `${width}px`; | |
| } else if (wPct) { | |
| width = 650; | |
| widthSource = '100%→650px(bubble-width)'; | |
| } | |
| if (h) { | |
| height = parseInt(h[1]); | |
| heightSource = `${height}px`; | |
| } | |
| } | |
| console.log(`[WIDGET] extractChartConfig: width=${width} (${widthSource}), height=${height} (${heightSource})`); | |
| let chartFnMatch = widgetHtml.match(/new\s+Chart\s*\([^,]+,\s*(\{)/); | |
| if (!chartFnMatch) { | |
| chartFnMatch = widgetHtml.match(/new\s+([A-Z])\s*\([^,]+,\s*(\{)/); | |
| } | |
| if (!chartFnMatch) { | |
| chartFnMatch = widgetHtml.match(/new\s+\w+\s*\([^,]+,\s*(\{)/); | |
| } | |
| if (!chartFnMatch) { | |
| console.log(`[WIDGET] extractChartConfig: FAIL — new Chart() or alias pattern not found`); | |
| return null; | |
| } | |
| // Find the start of the config object | |
| const configStart = chartFnMatch.index + chartFnMatch[0].length - 1; // position of the opening '{' | |
| let depth = 0; | |
| let inString = null; | |
| let configEnd = -1; | |
| for (let i = configStart; i < widgetHtml.length; i++) { | |
| const ch = widgetHtml[i]; | |
| if (inString) { | |
| if (ch === inString) { | |
| // Check for escape character | |
| if (i > configStart && widgetHtml[i - 1] === '\\') continue; | |
| inString = null; | |
| } | |
| continue; | |
| } | |
| if (ch === "'" || ch === '"' || ch === '`') { inString = ch; continue; } | |
| if (ch === '{') depth++; | |
| else if (ch === '}') { | |
| depth--; | |
| if (depth === 0) { configEnd = i; break; } | |
| } | |
| } | |
| if (configEnd === -1) { | |
| console.log(`[WIDGET] extractChartConfig: FAIL — could not find matching closing brace`); | |
| return null; | |
| } | |
| let configStr = widgetHtml.substring(configStart, configEnd + 1); | |
| // Clean up trailing artifacts (e.g., trailing commas, extra closing parens) | |
| configStr = configStr.replace(/,\s*\}\s*\)\s*;?\s*$/, '}'); | |
| configStr = configStr.replace(/,\s*}\s*$/, '}'); | |
| configStr = configStr.replace(/\bbackgroundImage\s*:\s*['"`][^'`]*['"`]/g, ''); | |
| configStr = configStr.replace(/,\s*}\)\s*;?$/g, '}'); | |
| // Extract variable declarations from the script to resolve external references | |
| // Chart.js configs often reference variables defined outside the config object | |
| // e.g. const labels = [...]; const type = 'doughnut'; new Chart(el, { type, data: { labels, datasets: [{ data, ...(type === 'doughnut' ? { cutout: '58%' } : {}) }] } }); | |
| const scriptMatch = widgetHtml.match(/<script[^>]*>([\s\S]*?)<\/script>/gi); | |
| const allScriptBlocks = []; | |
| if (scriptMatch) { | |
| for (const s of scriptMatch) { | |
| const inner = s.replace(/<script[^>]*>|<\/script>/gi, ''); | |
| if (inner.trim()) allScriptBlocks.push(inner); | |
| } | |
| } | |
| const fullScript = allScriptBlocks.join('\n'); | |
| // Find all const/let/var declarations using balanced bracket matching for values | |
| const varDeclarations = []; | |
| const varDeclStart = /\b(?:const|let|var)\s+(\w+)\s*=\s*/g; | |
| let startMatch; | |
| while ((startMatch = varDeclStart.exec(fullScript)) !== null) { | |
| const varName = startMatch[1]; | |
| const valueStart = startMatch.index + startMatch[0].length; | |
| // Determine the value by matching the first character | |
| const firstChar = fullScript[valueStart]; | |
| let valueEnd = -1; | |
| if (firstChar === '[' || firstChar === '{') { | |
| // Balanced bracket matching for arrays/objects | |
| const openChar = firstChar; | |
| const closeChar = openChar === '[' ? ']' : '}'; | |
| let depth = 0; | |
| let inStr = null; | |
| for (let i = valueStart; i < fullScript.length; i++) { | |
| const ch = fullScript[i]; | |
| if (inStr) { | |
| if (ch === inStr && (i === valueStart || fullScript[i - 1] !== '\\')) inStr = null; | |
| continue; | |
| } | |
| if (ch === "'" || ch === '"' || ch === '`') { inStr = ch; continue; } | |
| if (ch === openChar) depth++; | |
| else if (ch === closeChar) { | |
| depth--; | |
| if (depth === 0) { valueEnd = i + 1; break; } | |
| } | |
| } | |
| } else if (firstChar === "'" || firstChar === '"' || firstChar === '`') { | |
| // String literal | |
| const quoteChar = firstChar; | |
| for (let i = valueStart + 1; i < fullScript.length; i++) { | |
| if (fullScript[i] === quoteChar && fullScript[i - 1] !== '\\') { | |
| valueEnd = i + 1; | |
| break; | |
| } | |
| } | |
| } else if (/[\d]/.test(firstChar)) { | |
| // Numeric literal | |
| const numMatch = fullScript.substring(valueStart).match(/^[\d.]+/); | |
| if (numMatch) valueEnd = valueStart + numMatch[0].length; | |
| } | |
| if (valueEnd > valueStart) { | |
| varDeclarations.push({ name: varName, value: fullScript.substring(valueStart, valueEnd) }); | |
| } | |
| } | |
| console.log(`[WIDGET] extractChartConfig DEBUG: Found ${varDeclarations.length} variable declarations: ${varDeclarations.map(v => `${v.name}=${v.value.substring(0, 30)}${v.value.length > 30 ? '...' : ''}`).join(', ')}`); | |
| // Build a preamble with ONLY the variables that are actually referenced in the config string | |
| // This avoids polluting the scope with unrelated variables | |
| const referencedVars = varDeclarations.filter(v => { | |
| // Check if the variable name appears as a standalone identifier in the config | |
| const refRegex = new RegExp(`(?<![\\w])${v.name}(?![\\w])`); | |
| return refRegex.test(configStr); | |
| }); | |
| if (referencedVars.length > 0) { | |
| console.log(`[WIDGET] extractChartConfig DEBUG: Config references ${referencedVars.length} external variables: ${referencedVars.map(v => v.name).join(', ')}`); | |
| } | |
| // Detect ES6 shorthand properties in the config that reference undeclared variables | |
| // e.g. { type, data, labels } where 'type' is a function parameter, not a declared variable | |
| // The pattern `{ identifier,` or `{ identifier }` means shorthand for `{ identifier: identifier }` | |
| const undeclaredVars = new Set(); | |
| // Look for shorthand patterns: bare identifier used as property key (followed by comma or }) | |
| // Also check spread conditionals: ...(type === 'doughnut' ? { ... } : {}) | |
| const spreadRegex = /\.\.\.\s*\(\s*(\w+)\s*===/g; | |
| let cMatch; | |
| while ((cMatch = spreadRegex.exec(configStr)) !== null) { | |
| const varName = cMatch[1]; | |
| if (!varDeclarations.some(d => d.name === varName)) { | |
| undeclaredVars.add(varName); | |
| } | |
| } | |
| // Also check direct shorthand: `{ type,` or `{ type }` at top-level of config | |
| // Note: this regex can match inside string literals (e.g. rgba values), so we skip numeric captures | |
| const shorthandRegex = /(?:^|[\{,])\s*(\w+)\s*(?=[,}])/gm; | |
| let shMatch; | |
| while ((shMatch = shorthandRegex.exec(configStr)) !== null) { | |
| const varName = shMatch[1]; | |
| // Skip purely numeric matches (these are from inside string literals like rgba values) | |
| if (/^\d+$/.test(varName)) continue; | |
| if (varName.length > 1 && varName.length <= 20 && !varDeclarations.some(d => d.name === varName)) { | |
| // Verify this is NOT a property key (which would be followed by ':') | |
| const pos = shMatch.index + shMatch[0].length; | |
| if (pos >= configStr.length || configStr[pos] !== ':') { | |
| undeclaredVars.add(varName); | |
| } | |
| } | |
| } | |
| if (undeclaredVars.size > 0) { | |
| console.log(`[WIDGET] extractChartConfig DEBUG: Found ${undeclaredVars.size} undeclared shorthand vars (function params): ${[...undeclaredVars].join(', ')} — providing default values`); | |
| } | |
| const preamble = referencedVars.map(v => `var ${v.name} = ${v.value};`).join('\n'); | |
| // Add fallback values for undeclared vars (e.g. type='pie' for pie/doughnut charts) | |
| const fallbackPreamble = [...undeclaredVars].map(v => { | |
| if (v === 'type') return `var ${v} = 'pie';`; | |
| return `var ${v} = undefined;`; | |
| }).join('\n'); | |
| const fullPreamble = preamble + (fallbackPreamble ? '\n' + fallbackPreamble : ''); | |
| try { | |
| const chartConfig = new Function(fullPreamble + '\nreturn ' + configStr)(); | |
| console.log(`[WIDGET] extractChartConfig DEBUG: Parsed OK — type=${chartConfig.type}, labels=${chartConfig.data?.labels?.length}, datasets=${chartConfig.data?.datasets?.length}`); | |
| return { width, height, chartConfig }; | |
| } catch (parseError) { | |
| console.log(`[WIDGET] extractChartConfig parse failed (with preamble): ${parseError.message}`); | |
| // Fallback: try without preamble | |
| try { | |
| const chartConfig = new Function('return ' + configStr)(); | |
| console.log(`[WIDGET] extractChartConfig parse OK (without preamble)`); | |
| return { width, height, chartConfig }; | |
| } catch (e) { | |
| console.log(`[WIDGET] extractChartConfig parse failed (without preamble): ${e.message}`); | |
| } | |
| try { | |
| const jsonReady = configStr | |
| .replace(/(\w+)\s*:/g, '"$1":') | |
| .replace(/'/g, '"'); | |
| const chartConfig = JSON.parse(jsonReady); | |
| return { width, height, chartConfig }; | |
| } catch (e) { | |
| console.log(`[WIDGET] extractChartConfig JSON parse failed: ${e.message}`); | |
| return null; | |
| } | |
| } | |
| } | |
| async renderMermaid(widgetHtml, title) { | |
| const startTime = Date.now(); | |
| console.log(`[WIDGET] renderMermaid START: title=${title}`); | |
| const hasPrefersColorScheme = widgetHtml.includes('prefers-color-scheme'); | |
| const hasDarkKeyword = widgetHtml.includes('dark'); | |
| const hasDarkModeVar = widgetHtml.includes('darkMode'); | |
| const hasMermaidRender = widgetHtml.includes('mermaid.render'); | |
| console.log(`[WIDGET] renderMermaid DEBUG: hasPrefersColorScheme=${hasPrefersColorScheme}, hasDarkKeyword=${hasDarkKeyword}, hasDarkModeVar=${hasDarkModeVar}, hasMermaidRender=${hasMermaidRender}`); | |
| const defMatch = widgetHtml.match(/mermaid\.render\([^,]*,\s*`([\s\S]*?)`\s*\)/); | |
| if (!defMatch) { | |
| console.log(`[WIDGET] renderMermaid: no mermaid.render() backtick pattern found, falling back to Puppeteer`); | |
| return this.renderWidgetPuppeteer(widgetHtml, 'mermaid', title, 3000); | |
| } | |
| const definition = defMatch[1]; | |
| console.log(`[WIDGET] renderMermaid: extracted definition_length=${definition.length}`); | |
| try { | |
| const { renderMermaid: mmdRender } = await import('@mermaid-js/mermaid-cli'); | |
| const browser = await this.getWidgetBrowser(); | |
| console.log(`[WIDGET] renderMermaid: calling mermaid-cli with theme=default, bg=#ffffff`); | |
| const result = await mmdRender( | |
| browser, | |
| definition, | |
| 'svg', | |
| { | |
| mermaidConfig: { | |
| theme: 'default', | |
| fontFamily: 'system-ui, -apple-system, sans-serif', | |
| }, | |
| backgroundColor: '#ffffff', | |
| } | |
| ); | |
| let svgStr = new TextDecoder().decode(result.data); | |
| const svgWidthMatch = svgStr.match(/width="([^"]+)"/); | |
| const svgHeightMatch = svgStr.match(/height="([^"]+)"/); | |
| const svgViewBoxMatch = svgStr.match(/viewBox="([^"]+)"/); | |
| const svgMaxWidthMatch = svgStr.match(/max-width:\s*([\d.]+)px/); | |
| const svgW = svgWidthMatch ? svgWidthMatch[1] : 'N/A'; | |
| const svgH = svgHeightMatch ? svgHeightMatch[1] : 'N/A'; | |
| const svgVB = svgViewBoxMatch ? svgViewBoxMatch[1] : 'N/A'; | |
| const svgMaxW = svgMaxWidthMatch ? svgMaxWidthMatch[1] + 'px' : 'N/A'; | |
| console.log(`[WIDGET] DEBUG: SVG BEFORE FIX: width=${svgW}, height=${svgH}, viewBox=${svgVB}, max-width=${svgMaxW}`); | |
| if (svgWidthMatch && svgWidthMatch[1] === '100%' && svgViewBoxMatch) { | |
| const vbParts = svgViewBoxMatch[1].split(/\s+/); | |
| const vbWidth = parseFloat(vbParts[2]); | |
| const vbHeight = parseFloat(vbParts[3]); | |
| if (!isNaN(vbWidth) && !isNaN(vbHeight)) { | |
| const fixedWidth = Math.ceil(vbWidth) + 2; | |
| const fixedHeight = Math.ceil(vbHeight) + 2; | |
| svgStr = svgStr.replace(/width="100%"/, `width="${fixedWidth}"`); | |
| svgStr = svgStr.replace(/height="[^"]*"/, `height="${fixedHeight}"`); | |
| svgStr = svgStr.replace(/style="[^"]*max-width:\s*[\d.]+px[^"]*"/, `style=""`); | |
| console.log(`[WIDGET] renderMermaid FIX: converted width=100% to width=${fixedWidth}, height=${fixedHeight} (from viewBox: ${svgVB})`); | |
| } | |
| } | |
| const svgStrLower = svgStr.substring(0, 2000); | |
| const hasBlackBackground = svgStrLower.includes('fill="#000000') || svgStrLower.includes('fill="#18181b') || svgStrLower.includes('fill="#1e1e1e') || svgStrLower.includes('fill="#0a0a0a'); | |
| console.log(`[WIDGET] renderMermaid: SVG first 2000 chars has black bg: ${hasBlackBackground}`); | |
| console.log(`[WIDGET] renderMermaid: SVG preview (200 chars): ${svgStr.substring(0, 200).replace(/\n/g, '\\n')}`); | |
| const svgBase64 = Buffer.from(svgStr).toString('base64'); | |
| const dataUrl = `data:image/svg+xml;base64,${svgBase64}`; | |
| console.log(`[WIDGET] renderMermaid OK: ${((Date.now() - startTime) / 1000).toFixed(2)}s, svg_size=${(svgStr.length / 1024).toFixed(1)}KB`); | |
| return dataUrl; | |
| } catch (e) { | |
| console.log(`[WIDGET] renderMermaid ERROR: ${e.message}, falling back to Puppeteer`); | |
| return this.renderWidgetPuppeteer(widgetHtml, 'mermaid', title, 3000); | |
| } | |
| } | |
| // ─── ChatGPT charts_widget_v2 Rendering ───────────────── | |
| // Uses Puppeteer with Recharts v2.15.4 CDN (v3 SSR is broken). | |
| // html parameter is JSON string of chart data: | |
| // { chartType, meta: {title, description}, xKey, yKey, series[], data[] } | |
| async renderChatGPTChart(dataJson, title) { | |
| const startTime = Date.now(); | |
| console.log(`[WIDGET] renderChatGPTChart START: title=${title}, dataLen=${dataJson.length}`); | |
| let chartData; | |
| try { | |
| chartData = typeof dataJson === 'string' ? JSON.parse(dataJson) : dataJson; | |
| } catch (e) { | |
| console.log(`[WIDGET] renderChatGPTChart FAIL: JSON parse error: ${e.message}`); | |
| return null; | |
| } | |
| const chartType = chartData.chartType || 'bar'; | |
| const series = chartData.series || []; | |
| const data = chartData.data || []; | |
| const xKey = chartData.xKey || 'x'; | |
| const nameKey = chartData.nameKey || ''; | |
| const valueKey = chartData.valueKey || ''; | |
| const formattedData = chartData.formattedData || []; | |
| const axes = chartData.axes || []; | |
| const meta = chartData.meta || {}; | |
| const chartTitle = meta.title || title || 'Chart'; | |
| const chartDesc = meta.description || ''; | |
| console.log(`[WIDGET] renderChatGPTChart: type=${chartType}, series=${series.length}, rows=${data.length}, xKey=${xKey}, axes=${axes.length}`); | |
| const html = this.buildChatGPTChartHtml(chartType, series, data, xKey, nameKey, valueKey, formattedData, axes, chartTitle, chartDesc); | |
| return this._renderFullHtml(html, chartTitle, 8000, chartType); | |
| } | |
| /** | |
| * Render a complete HTML document via Puppeteer (for chatgpt_chart). | |
| * The HTML already contains full <html>/<head>/<body> with script tags. | |
| */ | |
| async _renderFullHtml(htmlContent, title, renderTimeout, chartType) { | |
| const startTime = Date.now(); | |
| console.log(`[WIDGET] _renderFullHtml START: title=${title}, timeout=${renderTimeout}ms, chartType=${chartType}`); | |
| let browser = null; | |
| try { | |
| const launchArgs = [ | |
| '--no-sandbox', | |
| '--disable-setuid-sandbox', | |
| '--disable-dev-shm-usage', | |
| '--disable-gpu', | |
| '--disable-software-rasterizer', | |
| '--memory-pressure-off' | |
| ]; | |
| browser = await puppeteer.launch({ | |
| executablePath: '/usr/bin/chromium', | |
| args: launchArgs, | |
| headless: 'shell' | |
| }); | |
| const page = await browser.newPage(); | |
| // CRITICAL: Use a compact viewport height to avoid excessive whitespace | |
| // in fullPage screenshots. Width 800 gives enough room for 650px chart. | |
| await page.setViewport({ width: 800, height: 500 }); | |
| // Set complete HTML document directly | |
| await page.setContent(htmlContent, { waitUntil: 'networkidle0', timeout: 15000 }); | |
| // Wait for React/Recharts to render (check window._widgetRendered flag) | |
| const rendered = await page.waitForFunction( | |
| () => window._widgetRendered === true, | |
| { timeout: renderTimeout } | |
| ).catch(() => null); | |
| if (!rendered) { | |
| console.log(`[WIDGET] _renderFullHtml timeout after ${renderTimeout}ms, trying screenshot anyway`); | |
| await new Promise(r => setTimeout(r, 1000)); | |
| } | |
| // Screenshot the widget container for chart rendering | |
| const container = await page.$('#widget-container'); | |
| let screenshot; | |
| if (container) { | |
| screenshot = await container.screenshot({ type: 'png', omitBackground: false }); | |
| } else { | |
| screenshot = await page.screenshot({ type: 'png', fullPage: true, omitBackground: false }); | |
| } | |
| const dataUrl = `data:image/png;base64,${screenshot.toString('base64')}`; | |
| const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); | |
| console.log(`[WIDGET] _renderFullHtml OK: ${elapsed}s, png=${(screenshot.length / 1024).toFixed(1)}KB`); | |
| return dataUrl; | |
| } catch (e) { | |
| console.log(`[WIDGET] _renderFullHtml ERROR: ${e.message}`); | |
| return null; | |
| } finally { | |
| if (browser) { | |
| try { await browser.close(); } catch (e) {} | |
| } | |
| } | |
| } | |
| buildChatGPTChartHtml(chartType, series, data, xKey, nameKey, valueKey, formattedData, axes, title, description) { | |
| // ChatGPT official chart color palette (extracted from live ChatGPT web) | |
| // ChatGPT official chart color palette (first 4) + D3 schemeCategory10 extended (10 total) | |
| // D3 schemeCategory10: industry-standard categorical palette, colorblind-friendly | |
| const COLORS = ['#339CFF', '#40C977', '#FF8549', '#FFD240', '#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#06b6d4']; | |
| // Serialize data safely for inline script | |
| const dataJson = JSON.stringify(data || []) | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const seriesJson = JSON.stringify(series || []) | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const chartTypeJson = JSON.stringify(chartType || 'bar') | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const titleJson = JSON.stringify(title || '') | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const descJson = JSON.stringify(description || '') | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const xKeyJson = JSON.stringify(xKey || 'x') | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const nameKeyJson = JSON.stringify(nameKey || '') | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const valueKeyJson = JSON.stringify(valueKey || '') | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const formattedDataJson = JSON.stringify(formattedData || []) | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const axesJson = JSON.stringify(axes || []) | |
| .replace(/<\/script/gi, '<\\/script'); | |
| return `<!DOCTYPE html> | |
| <html><head><meta charset="UTF-8"> | |
| <style> | |
| body { margin: 0; padding: 12px; background: #ffffff; font-family: system-ui, -apple-system, sans-serif; } | |
| .chart-title { font-size: 16px; font-weight: 600; margin-bottom: 4px; color: #111827; } | |
| .chart-subtitle { font-size: 12px; color: #6b7280; margin-bottom: 12px; } | |
| #widget-container { width: 720px; min-height: 300px; } | |
| /* Widen container to prevent last data point dot from overflowing SVG boundary. | |
| Recharts plots the last point at the plotting area edge; the dot (r=4-6) extends | |
| beyond. 720px gives ~70px extra right padding after the Y-axis. */ | |
| .recharts-wrapper { margin: 0 auto; } | |
| </style> | |
| <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"><\/script> | |
| <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"><\/script> | |
| <script src="https://unpkg.com/react-is@18.2.0/umd/react-is.production.min.js"><\/script> | |
| <script src="https://unpkg.com/prop-types@15.8.1/prop-types.min.js"><\/script> | |
| <script src="https://unpkg.com/recharts@2.12.7/umd/Recharts.js"><\/script> | |
| </head><body> | |
| <div id="widget-container"></div> | |
| <script> | |
| window._widgetRendered = false; | |
| (function() { | |
| try { | |
| var chartData = ${dataJson}; | |
| var chartSeries = ${seriesJson}; | |
| var chartTitle = ${titleJson}; | |
| var chartDesc = ${descJson}; | |
| var xKey = ${xKeyJson}; | |
| var chartType = ${chartTypeJson}; | |
| var nameKey = ${nameKeyJson}; | |
| var valueKey = ${valueKeyJson}; | |
| var formattedData = ${formattedDataJson}; | |
| var chartAxes = ${axesJson}; | |
| var COLORS = ${JSON.stringify(COLORS)}; | |
| var rc = Recharts; | |
| var h = React.createElement; | |
| // ── Scatter chart: detect x-axis key ── | |
| var scatterXKey = xKey; | |
| if (chartType === 'scatter' && chartData.length > 0) { | |
| var sFirstRow = chartData[0]; | |
| var sKeys = Object.keys(sFirstRow); | |
| var seriesDataKey = chartSeries[0] ? chartSeries[0].dataKey : null; | |
| // Find a non-series key for x-axis (prefer non-numeric for category axis) | |
| for (var si = 0; si < sKeys.length; si++) { | |
| if (sKeys[si] !== seriesDataKey) { | |
| scatterXKey = sKeys[si]; | |
| break; | |
| } | |
| } | |
| } | |
| // ── Universal axis configuration builder ── | |
| // Uses pre-calculated axis data from API JSON (axes[].ticks[].label) | |
| // instead of procedural formatting. This is the canonical approach: | |
| // ChatGPT's AI generates exact tick labels in the API JSON, and we | |
| // pass them directly to Recharts via the tick prop. | |
| function buildAxisConfig(axisId) { | |
| // Find matching axis from chartAxes by id | |
| var axisConfig = null; | |
| for (var ai = 0; ai < chartAxes.length; ai++) { | |
| if (chartAxes[ai].id === axisId) { | |
| axisConfig = chartAxes[ai]; | |
| break; | |
| } | |
| } | |
| if (!axisConfig) { | |
| // Fallback: use series-level formatting if no axes data | |
| var s = chartSeries[0] || {}; | |
| var prefix = s.valuePrefix || ''; | |
| var suffix = s.valueSuffix || ''; | |
| var format = s.valueFormat || 'standard'; | |
| return { | |
| tickFormatter: function(value) { | |
| var num = Number(value); | |
| if (isNaN(num)) return String(value); | |
| var display; | |
| if (format === 'compact') { | |
| if (Math.abs(num) >= 1e9) display = (num / 1e9).toFixed(0) + 'B'; | |
| else if (Math.abs(num) >= 1e6) display = (num / 1e6).toFixed(0) + 'M'; | |
| else if (Math.abs(num) >= 1e3) display = (num / 1e3).toFixed(0) + 'K'; | |
| else display = String(num); | |
| } else { | |
| display = String(num); | |
| } | |
| return prefix + display + suffix; | |
| } | |
| }; | |
| } | |
| // Build tick lookup map: value → label | |
| var tickMap = {}; | |
| for (var ti = 0; ti < axisConfig.ticks.length; ti++) { | |
| tickMap[String(axisConfig.ticks[ti].value)] = axisConfig.ticks[ti].label; | |
| } | |
| var tickValues = axisConfig.ticks.map(function(t) { return t.value; }); | |
| return { | |
| domain: axisConfig.domain || undefined, | |
| ticks: tickValues, | |
| tickFormatter: function(value) { | |
| var key = String(value); | |
| // Direct lookup for exact match | |
| if (tickMap[key] !== undefined) return tickMap[key]; | |
| // Fallback: procedural formatting for interpolated values | |
| var prefix = ''; | |
| var suffix = ''; | |
| var s0 = chartSeries[0] || {}; | |
| prefix = s0.valuePrefix || ''; | |
| suffix = s0.valueSuffix || ''; | |
| var num = Number(value); | |
| if (isNaN(num)) return String(value); | |
| if (Math.abs(num) >= 1e9) return prefix + (num / 1e9).toFixed(0) + 'B' + suffix; | |
| if (Math.abs(num) >= 1e6) return prefix + (num / 1e6).toFixed(0) + 'M' + suffix; | |
| if (Math.abs(num) >= 1e3) return prefix + (num / 1e3).toFixed(0) + 'K' + suffix; | |
| return prefix + String(num) + suffix; | |
| }, | |
| label: axisConfig.label || undefined | |
| }; | |
| } | |
| // Build series elements | |
| var seriesEls = []; | |
| // Each series may carry additional Recharts props (stackId, radius, type, etc.) | |
| // that AI could generate. We pass them through to the component props. | |
| if (chartType === 'bar') { | |
| chartSeries.forEach(function(s, i) { | |
| var props = { | |
| dataKey: s.dataKey, | |
| name: s.label || s.dataKey, | |
| fill: s.color || COLORS[i % COLORS.length] | |
| }; | |
| if (s.stackId !== undefined) props.stackId = s.stackId; | |
| if (s.radius !== undefined) props.radius = s.radius; | |
| if (s.barSize !== undefined) props.barSize = s.barSize; | |
| if (s.maxBarSize !== undefined) props.maxBarSize = s.maxBarSize; | |
| if (s.stroke !== undefined) props.stroke = s.stroke; | |
| if (s.strokeWidth !== undefined) props.strokeWidth = s.strokeWidth; | |
| if (s.unit !== undefined) props.unit = s.unit; | |
| if (s.label !== undefined) props.label = s.label; | |
| seriesEls.push(h(rc.Bar, props)); | |
| }); | |
| } else if (chartType === 'line') { | |
| chartSeries.forEach(function(s, i) { | |
| var props = { | |
| type: s.type || 'monotone', | |
| dataKey: s.dataKey, | |
| name: s.label || s.dataKey, | |
| stroke: s.color || COLORS[i % COLORS.length], | |
| strokeWidth: 2, | |
| dot: s.dot !== undefined ? s.dot : { r: 4 }, | |
| connectNulls: s.connectNulls !== undefined ? s.connectNulls : true, | |
| isAnimationActive: false | |
| }; | |
| if (s.strokeDasharray !== undefined) props.strokeDasharray = s.strokeDasharray; | |
| if (s.activeDot !== undefined) props.activeDot = s.activeDot; | |
| if (s.label !== undefined) props.label = s.label; | |
| if (s.unit !== undefined) props.unit = s.unit; | |
| if (s.fillOpacity !== undefined) props.fillOpacity = s.fillOpacity; | |
| seriesEls.push(h(rc.Line, props)); | |
| }); | |
| } else if (chartType === 'area') { | |
| chartSeries.forEach(function(s, i) { | |
| var props = { | |
| type: s.type || 'monotone', | |
| dataKey: s.dataKey, | |
| name: s.label || s.dataKey, | |
| fill: s.color || COLORS[i % COLORS.length], | |
| stroke: s.color || COLORS[i % COLORS.length], | |
| strokeWidth: 2, | |
| connectNulls: s.connectNulls !== undefined ? s.connectNulls : true, | |
| isAnimationActive: false | |
| }; | |
| if (s.fillOpacity !== undefined) props.fillOpacity = s.fillOpacity; | |
| if (s.baseValue !== undefined) props.baseValue = s.baseValue; | |
| if (s.strokeDasharray !== undefined) props.strokeDasharray = s.strokeDasharray; | |
| if (s.dot !== undefined) props.dot = s.dot; | |
| if (s.activeDot !== undefined) props.activeDot = s.activeDot; | |
| if (s.label !== undefined) props.label = s.label; | |
| if (s.unit !== undefined) props.unit = s.unit; | |
| if (s.stackId !== undefined) props.stackId = s.stackId; | |
| seriesEls.push(h(rc.Area, props)); | |
| }); | |
| } else if (chartType === 'scatter') { | |
| chartSeries.forEach(function(s, i) { | |
| var props = { | |
| name: s.label || s.dataKey, | |
| dataKey: s.dataKey, | |
| fill: s.color || COLORS[i % COLORS.length] | |
| }; | |
| if (s.shape !== undefined) props.shape = s.shape; | |
| if (s.activeDot !== undefined) props.activeDot = s.activeDot; | |
| if (s.line !== undefined) props.line = s.line; | |
| if (s.lineJointType !== undefined) props.lineJointType = s.lineJointType; | |
| if (s.unit !== undefined) props.unit = s.unit; | |
| seriesEls.push(h(rc.Scatter, props)); | |
| }); | |
| } else if (chartType === 'pie' || chartType === 'donut') { | |
| chartSeries.forEach(function(s, i) { | |
| var actualDataKey = valueKey || s.dataKey || 'value'; | |
| var actualNameKey = nameKey || 'name'; | |
| // Inject fill color into data items (Recharts v2 UMD: Cell children don't work reliably) | |
| var pieColoredData = chartData.map(function(row, ri) { | |
| var copy = {}; | |
| for (var _k in row) { copy[_k] = row[_k]; } | |
| copy.fill = COLORS[ri % COLORS.length]; | |
| return copy; | |
| }); | |
| // Pie label formatter: use formattedData for display values | |
| var pieRawToFormatted = {}; | |
| if (chartData && formattedData && chartData.length === formattedData.length) { | |
| for (var _pi = 0; _pi < chartData.length; _pi++) { | |
| var _pRaw = chartData[_pi]; | |
| var _pFmt = formattedData[_pi]; | |
| var _pRawVal = _pRaw[actualDataKey]; | |
| if (_pRawVal !== undefined && _pRawVal !== null && _pFmt) { | |
| pieRawToFormatted[String(_pRawVal)] = { | |
| name: _pFmt[actualNameKey] || _pRaw[actualNameKey], | |
| formatted: _pFmt[s.dataKey] | |
| }; | |
| } | |
| } | |
| } | |
| var labelFormatter = function(entry) { | |
| var rawVal = String(entry.value); | |
| var matched = pieRawToFormatted[rawVal]; | |
| if (matched) { | |
| return matched.name + ' ' + matched.formatted; | |
| } | |
| return String(entry.name) + ' ' + entry.value; | |
| }; | |
| var pieProps = { | |
| data: pieColoredData, | |
| dataKey: actualDataKey, | |
| nameKey: actualNameKey, | |
| name: s.label || s.dataKey, | |
| cx: '50%', cy: '50%', | |
| outerRadius: 100, | |
| innerRadius: 60, | |
| isAnimationActive: false, | |
| label: labelFormatter | |
| }; | |
| if (s.paddingAngle !== undefined) pieProps.paddingAngle = s.paddingAngle; | |
| if (s.cornerRadius !== undefined) pieProps.cornerRadius = s.cornerRadius; | |
| if (s.startAngle !== undefined) pieProps.startAngle = s.startAngle; | |
| if (s.endAngle !== undefined) pieProps.endAngle = s.endAngle; | |
| if (s.minAngle !== undefined) pieProps.minAngle = s.minAngle; | |
| seriesEls.push(h(rc.Pie, pieProps)); | |
| }); | |
| } else if (chartType === 'radar') { | |
| chartSeries.forEach(function(s, i) { | |
| var props = { | |
| dataKey: s.dataKey, | |
| name: s.label || s.dataKey, | |
| stroke: s.color || COLORS[i % COLORS.length], | |
| fill: s.color || COLORS[i % COLORS.length], | |
| fillOpacity: 0.3 | |
| }; | |
| if (s.dot !== undefined) props.dot = s.dot; | |
| if (s.activeDot !== undefined) props.activeDot = s.activeDot; | |
| if (s.strokeWidth !== undefined) props.strokeWidth = s.strokeWidth; | |
| if (s.unit !== undefined) props.unit = s.unit; | |
| seriesEls.push(h(rc.Radar, props)); | |
| }); | |
| } else { | |
| // Fallback: unsupported chart type | |
| document.getElementById('widget-container').innerHTML = '<div style="padding:20px;color:#666;">Unsupported chart type: ' + chartType + '</div>'; | |
| window._widgetRendered = true; | |
| return; | |
| } | |
| // Select chart component | |
| var ChartComponent; | |
| switch (chartType) { | |
| case 'bar': ChartComponent = rc.BarChart; break; | |
| case 'line': ChartComponent = rc.LineChart; break; | |
| case 'area': ChartComponent = rc.AreaChart; break; | |
| case 'scatter': ChartComponent = rc.ScatterChart; break; | |
| case 'pie': case 'donut': ChartComponent = rc.PieChart; break; | |
| case 'radar': ChartComponent = rc.RadarChart; break; | |
| default: ChartComponent = rc.BarChart; | |
| } | |
| var chartProps = { data: chartData, width: 720, height: 300 }; | |
| if (chartType === 'radar') { | |
| chartProps.cx = '50%'; | |
| chartProps.cy = '50%'; | |
| chartProps.outerRadius = 100; | |
| } else if (chartType === 'scatter') { | |
| chartProps.xAxisId = 0; | |
| } | |
| var gridEls = []; | |
| if (chartType !== 'pie' && chartType !== 'donut' && chartType !== 'radar') { | |
| gridEls.push(h(rc.CartesianGrid, { strokeDasharray: '3 3', key: 'grid' })); | |
| var axisKey = chartType === 'scatter' ? scatterXKey : xKey; | |
| // ── XAxis: use axes[].dimension from API JSON ── | |
| // Find X axis config from axes array by dimension="x" | |
| var xAxisConfig = null; | |
| for (var _xi = 0; _xi < chartAxes.length; _xi++) { | |
| if (chartAxes[_xi].dimension === 'x') { | |
| xAxisConfig = chartAxes[_xi]; | |
| break; | |
| } | |
| } | |
| var xAxisProps = { dataKey: axisKey, key: 'x' }; | |
| if (xAxisConfig && xAxisConfig.domain) { xAxisProps.domain = xAxisConfig.domain; } | |
| if (xAxisConfig && xAxisConfig.ticks) { | |
| var _xTickMap = {}; | |
| for (var _xti = 0; _xti < xAxisConfig.ticks.length; _xti++) { | |
| _xTickMap[String(xAxisConfig.ticks[_xti].value)] = xAxisConfig.ticks[_xti].label; | |
| } | |
| xAxisProps.tickFormatter = function(v) { var k = String(v); return _xTickMap[k] !== undefined ? _xTickMap[k] : String(v); }; | |
| } | |
| if (xAxisConfig && xAxisConfig.label) { xAxisProps.label = xAxisConfig.label; } | |
| gridEls.push(h(rc.XAxis, xAxisProps)); | |
| // ── YAxis: use axes[].dimension from API JSON, linked by series[].axisId ── | |
| var yAxisId = chartSeries[0] ? (chartSeries[0].axisId || null) : null; | |
| var yAxisConfig = yAxisId ? buildAxisConfig(yAxisId) : buildAxisConfig(null); | |
| var yAxisProps = { key: 'y', tickFormatter: yAxisConfig.tickFormatter }; | |
| if (yAxisConfig.domain) { yAxisProps.domain = yAxisConfig.domain; } | |
| if (yAxisConfig.ticks) { yAxisProps.ticks = yAxisConfig.ticks; } | |
| // Use axes[].label from API JSON for axis title (e.g., "用户数") | |
| if (yAxisConfig.label) { yAxisProps.label = yAxisConfig.label; } | |
| gridEls.push(h(rc.YAxis, yAxisProps)); | |
| } | |
| gridEls.push(h(rc.Tooltip, { key: 'tooltip' })); | |
| // Legend: for pie/donut charts, use custom payload to show category names | |
| // instead of the series name ("占比") | |
| if (chartType === 'pie' || chartType === 'donut') { | |
| var pieActualNameKey = nameKey || 'name'; | |
| var legendPayload = chartData.map(function(row, i) { | |
| var fmt = formattedData[i]; | |
| return { | |
| id: i, | |
| value: fmt ? (fmt[pieActualNameKey] || row[pieActualNameKey]) : (row[pieActualNameKey] || 'Item'), | |
| type: 'rect', | |
| color: COLORS[i % COLORS.length] | |
| }; | |
| }); | |
| gridEls.push(h(rc.Legend, { key: 'legend', payload: legendPayload })); | |
| } else { | |
| gridEls.push(h(rc.Legend, { key: 'legend' })); | |
| } | |
| var chart = h(ChartComponent, chartProps, | |
| gridEls.concat(seriesEls) | |
| ); | |
| // Use ReactDOM.render for Recharts v2.12.7 compatibility | |
| // Render chart directly without ResponsiveContainer to avoid coordinate scaling issues | |
| var el = h('div', {}, | |
| chartTitle && h('div', { className: 'chart-title' }, chartTitle), | |
| chartDesc && h('div', { className: 'chart-subtitle' }, chartDesc), | |
| chart | |
| ); | |
| if (ReactDOM.render) { | |
| ReactDOM.render(el, document.getElementById('widget-container')); | |
| } else { | |
| ReactDOM.createRoot(document.getElementById('widget-container')).render(el); | |
| } | |
| window._widgetRendered = true; | |
| } catch (err) { | |
| document.getElementById('widget-container').innerHTML = '<div style="padding:20px;color:#c00;">Chart render error: ' + err.message + '</div>'; | |
| window._widgetRendered = true; | |
| } | |
| })(); | |
| <\/script> | |
| </body></html>`; | |
| } | |
| async renderWidgetPuppeteer(widgetHtml, type, title, renderTimeout) { | |
| const startTime = Date.now(); | |
| console.log(`[WIDGET] renderPuppeteer START: type=${type}, title=${title}, timeout=${renderTimeout}ms`); | |
| let browser = null; | |
| try { | |
| const finalTimeout = renderTimeout || 3000; | |
| const launchArgs = [ | |
| '--no-sandbox', | |
| '--disable-setuid-sandbox', | |
| '--disable-dev-shm-usage', | |
| '--disable-gpu', | |
| '--disable-software-rasterizer', | |
| '--memory-pressure-off' | |
| ]; | |
| browser = await puppeteer.launch({ | |
| executablePath: '/usr/bin/chromium', | |
| args: launchArgs, | |
| headless: 'shell' | |
| }); | |
| const page = await browser.newPage(); | |
| await page.setViewport({ width: 1024, height: 768 }); | |
| const templatesDir = path.join(__dirname, 'templates'); | |
| let template; | |
| try { | |
| template = fs.readFileSync(path.join(templatesDir, 'widget-render.html'), 'utf8'); | |
| } catch (e) { | |
| console.log(`[WIDGET] renderPuppeteer template not found, using inline fallback`); | |
| template = `<!DOCTYPE html><html><head><meta charset="UTF-8"><style>body{margin:0;padding:8px;background:#fff;}</style></head><body><div id="widget-container"></div><script>window._widgetRendered=false;<\/script></body></html>`; | |
| } | |
| let fullHtml = template | |
| .replace('%%WIDGET_CODE%%', widgetHtml); | |
| await page.setContent(fullHtml, { waitUntil: 'networkidle0', timeout: 15000 }); | |
| await page.evaluate(() => { window._widgetRendered = false; }); | |
| const rendered = await page.waitForFunction( | |
| () => window._widgetRendered === true, | |
| { timeout: finalTimeout } | |
| ).catch(() => null); | |
| if (!rendered) { | |
| console.log(`[WIDGET] renderPuppeteer timeout after ${finalTimeout}ms, trying screenshot anyway`); | |
| await new Promise(r => setTimeout(r, 1000)); | |
| } | |
| const container = await page.$('#widget-container'); | |
| if (!container) { | |
| console.log(`[WIDGET] renderPuppeteer FAIL: no container found`); | |
| return null; | |
| } | |
| const screenshot = await container.screenshot({ type: 'png', omitBackground: false }); | |
| const dataUrl = `data:image/png;base64,${screenshot.toString('base64')}`; | |
| console.log(`[WIDGET] renderPuppeteer OK: ${((Date.now() - startTime) / 1000).toFixed(2)}s, png=${(screenshot.length / 1024).toFixed(1)}KB`); | |
| return dataUrl; | |
| } catch (e) { | |
| console.log(`[WIDGET] renderPuppeteer ERROR: ${e.message}`); | |
| return null; | |
| } finally { | |
| if (browser) { | |
| try { await browser.close(); } catch (e) {} | |
| } | |
| } | |
| } | |
| async render(widget) { | |
| const { type, html, title } = widget; | |
| try { | |
| switch (type) { | |
| case 'chart': | |
| return await this.renderChart(html, title); | |
| case 'mermaid': | |
| return await this.renderMermaid(html, title); | |
| case 'chatgpt_chart': | |
| return await this.renderChatGPTChart(html, title); | |
| case 'interactive': | |
| return await this.renderWidgetPuppeteer(html, 'interactive', title, 3000); | |
| default: | |
| return await this.renderWidgetPuppeteer(html, type, title, 2000); | |
| } | |
| } catch (e) { | |
| console.log(`[WIDGET] render FAILED: type=${type}, error=${e.message}`); | |
| return null; | |
| } | |
| } | |
| } | |
| const widgetRenderer = new WidgetRenderer(); | |
| const MAX_CONCURRENT_RENDER = 3; | |
| let activeRenderCount = 0; | |
| const renderQueue = []; | |
| function renderWithConcurrency(widget) { | |
| return new Promise((resolve, reject) => { | |
| renderQueue.push({ widget, resolve, reject }); | |
| processRenderQueue(); | |
| }); | |
| } | |
| async function processRenderQueue() { | |
| while (renderQueue.length > 0 && activeRenderCount < MAX_CONCURRENT_RENDER) { | |
| const { widget, resolve, reject } = renderQueue.shift(); | |
| activeRenderCount++; | |
| try { | |
| const result = await widgetRenderer.render(widget); | |
| resolve(result); | |
| } catch (e) { | |
| reject(e); | |
| } finally { | |
| activeRenderCount--; | |
| processRenderQueue(); | |
| } | |
| } | |
| } | |
| // ─── Widget Rendering Endpoint ───────────────────────────────── | |
| app.post('/api/render_charts', async (req, res) => { | |
| const startTime = Date.now(); | |
| console.log(`\n[WIDGET] ========== render_charts START ==========`); | |
| const { widgets, theme } = req.body; | |
| if (!widgets || !Array.isArray(widgets) || widgets.length === 0) { | |
| return res.status(400).json({ error: 'Missing widgets array' }); | |
| } | |
| console.log(`[WIDGET] Received ${widgets.length} widgets, theme=${theme || 'light'}`); | |
| const results = []; | |
| const renderPromises = widgets.map(async (widget, index) => { | |
| const wStart = Date.now(); | |
| const wType = widget.type || 'html'; | |
| const wTitle = widget.title || `widget_${index}`; | |
| console.log(`[WIDGET] [${index + 1}/${widgets.length}] Rendering: type=${wType}, title=${wTitle}`); | |
| try { | |
| const renderPromise = renderWithConcurrency(widget); | |
| const timeoutPromise = new Promise(resolve => setTimeout(() => resolve(null), 15000)); | |
| const dataUrl = await Promise.race([renderPromise, timeoutPromise]); | |
| const elapsed = ((Date.now() - wStart) / 1000).toFixed(2); | |
| if (dataUrl) { | |
| console.log(`[WIDGET] [${index + 1}/${widgets.length}] OK: ${elapsed}s, dataUrl_len=${(dataUrl.length / 1024).toFixed(1)}KB`); | |
| return { index, success: true, dataUrl, type: wType, title: wTitle }; | |
| } else { | |
| console.log(`[WIDGET] [${index + 1}/${widgets.length}] TIMEOUT: ${elapsed}s`); | |
| return { index, success: false, error: 'timeout', type: wType, title: wTitle }; | |
| } | |
| } catch (e) { | |
| console.log(`[WIDGET] [${index + 1}/${widgets.length}] ERROR: ${e.message}`); | |
| return { index, success: false, error: e.message, type: wType, title: wTitle }; | |
| } | |
| }); | |
| const renderResults = await Promise.all(renderPromises); | |
| const successCount = renderResults.filter(r => r.success).length; | |
| const failCount = renderResults.filter(r => !r.success).length; | |
| const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(2); | |
| console.log(`[WIDGET] ========== render_charts DONE: ${successCount} OK, ${failCount} FAIL, ${totalElapsed}s ==========\n`); | |
| res.json({ results: renderResults }); | |
| }); | |
| app.listen(port, () => { | |
| console.log(`Server listening at http://localhost:${port}`); | |
| }); | |