Spaces:
Running
Running
feat: add widget renderer (chart.js, mermaid) and bump to v2.0.0
Browse files- Dockerfile +6 -8
- package-lock.json +0 -0
- package.json +7 -5
- server.js +437 -1
- templates/widget-render.html +82 -0
Dockerfile
CHANGED
|
@@ -1,9 +1,7 @@
|
|
| 1 |
-
|
| 2 |
# Use Node.js 20 on Debian Bullseye (Stable for Puppeteer)
|
| 3 |
FROM node:20-bullseye-slim
|
| 4 |
|
| 5 |
-
#
|
| 6 |
-
# Puppeteer requires a lot of system libraries to run Chrome in Docker
|
| 7 |
RUN apt-get update && apt-get install -y \
|
| 8 |
chromium \
|
| 9 |
fonts-noto-cjk \
|
|
@@ -13,18 +11,18 @@ RUN apt-get update && apt-get install -y \
|
|
| 13 |
--no-install-recommends \
|
| 14 |
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
|
| 16 |
-
#
|
| 17 |
WORKDIR /app
|
| 18 |
|
| 19 |
-
#
|
| 20 |
COPY package.json .
|
| 21 |
RUN npm install
|
| 22 |
|
| 23 |
-
#
|
| 24 |
COPY . .
|
| 25 |
|
| 26 |
-
#
|
| 27 |
EXPOSE 7860
|
| 28 |
|
| 29 |
-
#
|
| 30 |
CMD ["node", "server.js"]
|
|
|
|
|
|
|
| 1 |
# Use Node.js 20 on Debian Bullseye (Stable for Puppeteer)
|
| 2 |
FROM node:20-bullseye-slim
|
| 3 |
|
| 4 |
+
# Install Chrome Dependencies
|
|
|
|
| 5 |
RUN apt-get update && apt-get install -y \
|
| 6 |
chromium \
|
| 7 |
fonts-noto-cjk \
|
|
|
|
| 11 |
--no-install-recommends \
|
| 12 |
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
|
| 14 |
+
# Set working directory
|
| 15 |
WORKDIR /app
|
| 16 |
|
| 17 |
+
# Install Node.js Dependencies
|
| 18 |
COPY package.json .
|
| 19 |
RUN npm install
|
| 20 |
|
| 21 |
+
# Copy Application Code (including templates/)
|
| 22 |
COPY . .
|
| 23 |
|
| 24 |
+
# Expose Port (Hugging Face expects 7860)
|
| 25 |
EXPOSE 7860
|
| 26 |
|
| 27 |
+
# Run the Application
|
| 28 |
CMD ["node", "server.js"]
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
| 1 |
-
|
| 2 |
{
|
| 3 |
"name": "pdf-server",
|
| 4 |
-
"version": "
|
| 5 |
-
"description": "Puppeteer PDF
|
| 6 |
"main": "server.js",
|
| 7 |
"dependencies": {
|
| 8 |
"cors": "^2.8.5",
|
| 9 |
"express": "^4.18.2",
|
| 10 |
-
"puppeteer": "^
|
| 11 |
-
"shiki": "^1.24.0"
|
|
|
|
|
|
|
|
|
|
| 12 |
},
|
| 13 |
"scripts": {
|
| 14 |
"start": "node server.js"
|
|
|
|
|
|
|
| 1 |
{
|
| 2 |
"name": "pdf-server",
|
| 3 |
+
"version": "2.0.0",
|
| 4 |
+
"description": "Puppeteer PDF + Widget Renderer for XWX AI Chat Exporter",
|
| 5 |
"main": "server.js",
|
| 6 |
"dependencies": {
|
| 7 |
"cors": "^2.8.5",
|
| 8 |
"express": "^4.18.2",
|
| 9 |
+
"puppeteer": "^24.0.0",
|
| 10 |
+
"shiki": "^1.24.0",
|
| 11 |
+
"chart.js": "^4.4.8",
|
| 12 |
+
"chartjs-node-canvas": "^5.0.0",
|
| 13 |
+
"@mermaid-js/mermaid-cli": "^11.0.0"
|
| 14 |
},
|
| 15 |
"scripts": {
|
| 16 |
"start": "node server.js"
|
server.js
CHANGED
|
@@ -26,6 +26,15 @@ const express = require('express');
|
|
| 26 |
const puppeteer = require('puppeteer');
|
| 27 |
const cors = require('cors');
|
| 28 |
const { getHighlighter } = require('shiki');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
// ─── Shiki Highlighter Initialization ─────────────────
|
| 31 |
// Maps frontend codeTheme settings to Shiki theme names
|
|
@@ -403,7 +412,7 @@ app.post('/api/generate_pdf', async (req, res) => {
|
|
| 403 |
|
| 404 |
res.setHeader('Content-Type', 'application/pdf');
|
| 405 |
res.setHeader('Content-Disposition', 'attachment; filename=export.pdf');
|
| 406 |
-
res.send(pdfBuffer);
|
| 407 |
|
| 408 |
} catch (error) {
|
| 409 |
console.error(`[PDF-GEN] [${getElapsed()}] 发生错误:`, error);
|
|
@@ -414,6 +423,433 @@ app.post('/api/generate_pdf', async (req, res) => {
|
|
| 414 |
}
|
| 415 |
});
|
| 416 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
app.listen(port, () => {
|
| 418 |
console.log(`Server listening at http://localhost:${port}`);
|
| 419 |
});
|
|
|
|
| 26 |
const puppeteer = require('puppeteer');
|
| 27 |
const cors = require('cors');
|
| 28 |
const { getHighlighter } = require('shiki');
|
| 29 |
+
const fs = require('fs');
|
| 30 |
+
const path = require('path');
|
| 31 |
+
|
| 32 |
+
let ChartJSNodeCanvas = null;
|
| 33 |
+
try {
|
| 34 |
+
ChartJSNodeCanvas = require('chartjs-node-canvas').ChartJSNodeCanvas;
|
| 35 |
+
} catch (e) {
|
| 36 |
+
console.log('[WIDGET] chartjs-node-canvas not yet installed, will retry on demand');
|
| 37 |
+
}
|
| 38 |
|
| 39 |
// ─── Shiki Highlighter Initialization ─────────────────
|
| 40 |
// Maps frontend codeTheme settings to Shiki theme names
|
|
|
|
| 412 |
|
| 413 |
res.setHeader('Content-Type', 'application/pdf');
|
| 414 |
res.setHeader('Content-Disposition', 'attachment; filename=export.pdf');
|
| 415 |
+
res.send(Buffer.from(pdfBuffer));
|
| 416 |
|
| 417 |
} catch (error) {
|
| 418 |
console.error(`[PDF-GEN] [${getElapsed()}] 发生错误:`, error);
|
|
|
|
| 423 |
}
|
| 424 |
});
|
| 425 |
|
| 426 |
+
// ─── Widget Renderer Class ───────────────────────────────────
|
| 427 |
+
|
| 428 |
+
class WidgetRenderer {
|
| 429 |
+
constructor() {
|
| 430 |
+
this._chartInstances = new Map();
|
| 431 |
+
this._widgetBrowser = null;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
async getWidgetBrowser() {
|
| 435 |
+
if (this._widgetBrowser && this._widgetBrowser.isConnected()) {
|
| 436 |
+
return this._widgetBrowser;
|
| 437 |
+
}
|
| 438 |
+
this._widgetBrowser = await puppeteer.launch({
|
| 439 |
+
executablePath: '/usr/bin/chromium',
|
| 440 |
+
args: [
|
| 441 |
+
'--no-sandbox',
|
| 442 |
+
'--disable-setuid-sandbox',
|
| 443 |
+
'--disable-dev-shm-usage',
|
| 444 |
+
'--font-render-hinting=none',
|
| 445 |
+
'--disable-gpu',
|
| 446 |
+
'--disable-software-rasterizer',
|
| 447 |
+
'--enable-webgl',
|
| 448 |
+
'--use-gl=angle',
|
| 449 |
+
'--use-angle=swiftshader',
|
| 450 |
+
'--memory-pressure-off'
|
| 451 |
+
],
|
| 452 |
+
headless: 'shell'
|
| 453 |
+
});
|
| 454 |
+
return this._widgetBrowser;
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
async close() {
|
| 458 |
+
if (this._widgetBrowser) {
|
| 459 |
+
try { await this._widgetBrowser.close(); } catch (e) {}
|
| 460 |
+
this._widgetBrowser = null;
|
| 461 |
+
}
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
_getChartInstance(width, height) {
|
| 465 |
+
const key = `${width}x${height}`;
|
| 466 |
+
if (!this._chartInstances.has(key)) {
|
| 467 |
+
let Ctor = ChartJSNodeCanvas;
|
| 468 |
+
if (!Ctor) {
|
| 469 |
+
ChartJSNodeCanvas = require('chartjs-node-canvas').ChartJSNodeCanvas;
|
| 470 |
+
Ctor = ChartJSNodeCanvas;
|
| 471 |
+
}
|
| 472 |
+
this._chartInstances.set(key, new Ctor({ width, height, backgroundColour: '#ffffff' }));
|
| 473 |
+
}
|
| 474 |
+
return this._chartInstances.get(key);
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
async renderChart(widgetHtml, title) {
|
| 478 |
+
const startTime = Date.now();
|
| 479 |
+
console.log(`[WIDGET] renderChart START: title=${title}`);
|
| 480 |
+
|
| 481 |
+
const config = this.extractChartConfig(widgetHtml);
|
| 482 |
+
if (!config) {
|
| 483 |
+
console.log(`[WIDGET] renderChart FAIL: no chart config found, falling back`);
|
| 484 |
+
return null;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
const width = config.width || 800;
|
| 488 |
+
const height = config.height || 300;
|
| 489 |
+
const chartConfig = config.chartConfig;
|
| 490 |
+
|
| 491 |
+
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}`);
|
| 492 |
+
|
| 493 |
+
chartConfig.options = chartConfig.options || {};
|
| 494 |
+
chartConfig.options.animation = { duration: 0 };
|
| 495 |
+
|
| 496 |
+
const instance = this._getChartInstance(width, height);
|
| 497 |
+
const buffer = await instance.renderToBuffer(chartConfig, 'image/png');
|
| 498 |
+
const dataUrl = `data:image/png;base64,${buffer.toString('base64')}`;
|
| 499 |
+
|
| 500 |
+
console.log(`[WIDGET] renderChart OK: type=${chartConfig.type}, renderSize=${width}x${height}, size=${(buffer.length / 1024).toFixed(1)}KB`);
|
| 501 |
+
return dataUrl;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
extractChartConfig(widgetHtml) {
|
| 505 |
+
const containerMatch = widgetHtml.match(/<div[^>]*style=["']([^"']*?)["']/);
|
| 506 |
+
let width = 650, height = 300;
|
| 507 |
+
|
| 508 |
+
let widthSource = 'default';
|
| 509 |
+
let heightSource = 'default';
|
| 510 |
+
|
| 511 |
+
if (containerMatch) {
|
| 512 |
+
const w = containerMatch[1].match(/width:\s*(\d+)px/);
|
| 513 |
+
const wPct = containerMatch[1].match(/width:\s*100%/);
|
| 514 |
+
const h = containerMatch[1].match(/height:\s*(\d+)px/);
|
| 515 |
+
if (w) {
|
| 516 |
+
width = parseInt(w[1]);
|
| 517 |
+
widthSource = `${width}px`;
|
| 518 |
+
} else if (wPct) {
|
| 519 |
+
width = 650;
|
| 520 |
+
widthSource = '100%→650px(bubble-width)';
|
| 521 |
+
}
|
| 522 |
+
if (h) {
|
| 523 |
+
height = parseInt(h[1]);
|
| 524 |
+
heightSource = `${height}px`;
|
| 525 |
+
}
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
console.log(`[WIDGET] extractChartConfig: width=${width} (${widthSource}), height=${height} (${heightSource})`);
|
| 529 |
+
|
| 530 |
+
const chartFnMatch = widgetHtml.match(/new\s+Chart\s*\([^,]+,\s*({[\s\S]*})\s*\)\s*;?/);
|
| 531 |
+
if (!chartFnMatch) {
|
| 532 |
+
console.log(`[WIDGET] extractChartConfig: FAIL — new Chart() pattern not found`);
|
| 533 |
+
return null;
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
let configStr = chartFnMatch[1];
|
| 537 |
+
configStr = configStr.replace(/,\s*\}\s*\)\s*;?\s*$/, '}');
|
| 538 |
+
|
| 539 |
+
configStr = configStr.replace(/\bbackgroundImage\s*:\s*['"`][^'`]*['"`]/g, '');
|
| 540 |
+
configStr = configStr.replace(/,\s*}\)\s*;?$/g, '}');
|
| 541 |
+
|
| 542 |
+
// DEBUG: Extract variable declarations from the script to resolve external references
|
| 543 |
+
// Chart.js configs often reference variables defined outside the config object
|
| 544 |
+
// e.g. const labels = [...]; new Chart(el, { data: { labels, datasets: [{ data }] } });
|
| 545 |
+
const scriptMatch = widgetHtml.match(/<script[^>]*>([\s\S]*?)<\/script>/g);
|
| 546 |
+
const allScriptBlocks = [];
|
| 547 |
+
if (scriptMatch) {
|
| 548 |
+
for (const s of scriptMatch) {
|
| 549 |
+
const inner = s.replace(/<script[^>]*>|<\/script>/gi, '');
|
| 550 |
+
if (inner.trim()) allScriptBlocks.push(inner);
|
| 551 |
+
}
|
| 552 |
+
}
|
| 553 |
+
const fullScript = allScriptBlocks.join('\n');
|
| 554 |
+
|
| 555 |
+
// Find all const/let/var declarations in the script
|
| 556 |
+
const varDeclarations = [];
|
| 557 |
+
const varDeclRegex = /\b(?:const|let|var)\s+(\w+)\s*=\s*([\s\S]*?);/g;
|
| 558 |
+
let varMatch;
|
| 559 |
+
while ((varMatch = varDeclRegex.exec(fullScript)) !== null) {
|
| 560 |
+
varDeclarations.push({ name: varMatch[1], value: varMatch[2] });
|
| 561 |
+
}
|
| 562 |
+
console.log(`[WIDGET] extractChartConfig DEBUG: Found ${varDeclarations.length} variable declarations: ${varDeclarations.map(v => v.name).join(', ')}`);
|
| 563 |
+
|
| 564 |
+
// Build a preamble with all variable declarations to resolve external references
|
| 565 |
+
const preamble = varDeclarations.map(v => `var ${v.name} = ${v.value};`).join('\n');
|
| 566 |
+
|
| 567 |
+
// Find which variables are referenced in the config string
|
| 568 |
+
const referencedVars = varDeclarations.filter(v => configStr.includes(v.name));
|
| 569 |
+
if (referencedVars.length > 0) {
|
| 570 |
+
console.log(`[WIDGET] extractChartConfig DEBUG: Config references ${referencedVars.length} external variables: ${referencedVars.map(v => v.name).join(', ')}`);
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
try {
|
| 574 |
+
const chartConfig = new Function(preamble + '\nreturn ' + configStr)();
|
| 575 |
+
console.log(`[WIDGET] extractChartConfig DEBUG: Parsed OK — type=${chartConfig.type}, labels=${chartConfig.data?.labels?.length}, datasets=${chartConfig.data?.datasets?.length}`);
|
| 576 |
+
return { width, height, chartConfig };
|
| 577 |
+
} catch (parseError) {
|
| 578 |
+
console.log(`[WIDGET] extractChartConfig parse failed (with preamble): ${parseError.message}`);
|
| 579 |
+
// Fallback: try without preamble
|
| 580 |
+
try {
|
| 581 |
+
const chartConfig = new Function('return ' + configStr)();
|
| 582 |
+
console.log(`[WIDGET] extractChartConfig parse OK (without preamble)`);
|
| 583 |
+
return { width, height, chartConfig };
|
| 584 |
+
} catch (e) {
|
| 585 |
+
console.log(`[WIDGET] extractChartConfig parse failed (without preamble): ${e.message}`);
|
| 586 |
+
}
|
| 587 |
+
try {
|
| 588 |
+
const jsonReady = configStr
|
| 589 |
+
.replace(/(\w+)\s*:/g, '"$1":')
|
| 590 |
+
.replace(/'/g, '"');
|
| 591 |
+
const chartConfig = JSON.parse(jsonReady);
|
| 592 |
+
return { width, height, chartConfig };
|
| 593 |
+
} catch (e) {
|
| 594 |
+
console.log(`[WIDGET] extractChartConfig JSON parse failed: ${e.message}`);
|
| 595 |
+
return null;
|
| 596 |
+
}
|
| 597 |
+
}
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
async renderMermaid(widgetHtml, title) {
|
| 601 |
+
const startTime = Date.now();
|
| 602 |
+
console.log(`[WIDGET] renderMermaid START: title=${title}`);
|
| 603 |
+
|
| 604 |
+
const hasPrefersColorScheme = widgetHtml.includes('prefers-color-scheme');
|
| 605 |
+
const hasDarkKeyword = widgetHtml.includes('dark');
|
| 606 |
+
const hasDarkModeVar = widgetHtml.includes('darkMode');
|
| 607 |
+
const hasMermaidRender = widgetHtml.includes('mermaid.render');
|
| 608 |
+
console.log(`[WIDGET] renderMermaid DEBUG: hasPrefersColorScheme=${hasPrefersColorScheme}, hasDarkKeyword=${hasDarkKeyword}, hasDarkModeVar=${hasDarkModeVar}, hasMermaidRender=${hasMermaidRender}`);
|
| 609 |
+
|
| 610 |
+
const defMatch = widgetHtml.match(/mermaid\.render\([^,]*,\s*`([\s\S]*?)`\s*\)/);
|
| 611 |
+
if (!defMatch) {
|
| 612 |
+
console.log(`[WIDGET] renderMermaid: no mermaid.render() backtick pattern found, falling back to Puppeteer`);
|
| 613 |
+
return this.renderWidgetPuppeteer(widgetHtml, 'mermaid', title, 3000);
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
const definition = defMatch[1];
|
| 617 |
+
console.log(`[WIDGET] renderMermaid: extracted definition_length=${definition.length}`);
|
| 618 |
+
|
| 619 |
+
try {
|
| 620 |
+
const { renderMermaid: mmdRender } = await import('@mermaid-js/mermaid-cli');
|
| 621 |
+
const browser = await this.getWidgetBrowser();
|
| 622 |
+
|
| 623 |
+
console.log(`[WIDGET] renderMermaid: calling mermaid-cli with theme=default, bg=#ffffff`);
|
| 624 |
+
const result = await mmdRender(
|
| 625 |
+
browser,
|
| 626 |
+
definition,
|
| 627 |
+
'svg',
|
| 628 |
+
{
|
| 629 |
+
mermaidConfig: {
|
| 630 |
+
theme: 'default',
|
| 631 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 632 |
+
},
|
| 633 |
+
backgroundColor: '#ffffff',
|
| 634 |
+
}
|
| 635 |
+
);
|
| 636 |
+
|
| 637 |
+
let svgStr = new TextDecoder().decode(result.data);
|
| 638 |
+
|
| 639 |
+
const svgWidthMatch = svgStr.match(/width="([^"]+)"/);
|
| 640 |
+
const svgHeightMatch = svgStr.match(/height="([^"]+)"/);
|
| 641 |
+
const svgViewBoxMatch = svgStr.match(/viewBox="([^"]+)"/);
|
| 642 |
+
const svgMaxWidthMatch = svgStr.match(/max-width:\s*([\d.]+)px/);
|
| 643 |
+
const svgW = svgWidthMatch ? svgWidthMatch[1] : 'N/A';
|
| 644 |
+
const svgH = svgHeightMatch ? svgHeightMatch[1] : 'N/A';
|
| 645 |
+
const svgVB = svgViewBoxMatch ? svgViewBoxMatch[1] : 'N/A';
|
| 646 |
+
const svgMaxW = svgMaxWidthMatch ? svgMaxWidthMatch[1] + 'px' : 'N/A';
|
| 647 |
+
console.log(`[WIDGET] DEBUG: SVG BEFORE FIX: width=${svgW}, height=${svgH}, viewBox=${svgVB}, max-width=${svgMaxW}`);
|
| 648 |
+
|
| 649 |
+
if (svgWidthMatch && svgWidthMatch[1] === '100%' && svgViewBoxMatch) {
|
| 650 |
+
const vbParts = svgViewBoxMatch[1].split(/\s+/);
|
| 651 |
+
const vbWidth = parseFloat(vbParts[2]);
|
| 652 |
+
const vbHeight = parseFloat(vbParts[3]);
|
| 653 |
+
if (!isNaN(vbWidth) && !isNaN(vbHeight)) {
|
| 654 |
+
const fixedWidth = Math.ceil(vbWidth) + 2;
|
| 655 |
+
const fixedHeight = Math.ceil(vbHeight) + 2;
|
| 656 |
+
svgStr = svgStr.replace(/width="100%"/, `width="${fixedWidth}"`);
|
| 657 |
+
svgStr = svgStr.replace(/height="[^"]*"/, `height="${fixedHeight}"`);
|
| 658 |
+
svgStr = svgStr.replace(/style="[^"]*max-width:\s*[\d.]+px[^"]*"/, `style=""`);
|
| 659 |
+
console.log(`[WIDGET] renderMermaid FIX: converted width=100% to width=${fixedWidth}, height=${fixedHeight} (from viewBox: ${svgVB})`);
|
| 660 |
+
}
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
const svgStrLower = svgStr.substring(0, 2000);
|
| 664 |
+
const hasBlackBackground = svgStrLower.includes('fill="#000000') || svgStrLower.includes('fill="#18181b') || svgStrLower.includes('fill="#1e1e1e') || svgStrLower.includes('fill="#0a0a0a');
|
| 665 |
+
console.log(`[WIDGET] renderMermaid: SVG first 2000 chars has black bg: ${hasBlackBackground}`);
|
| 666 |
+
console.log(`[WIDGET] renderMermaid: SVG preview (200 chars): ${svgStr.substring(0, 200).replace(/\n/g, '\\n')}`);
|
| 667 |
+
|
| 668 |
+
const svgBase64 = Buffer.from(svgStr).toString('base64');
|
| 669 |
+
const dataUrl = `data:image/svg+xml;base64,${svgBase64}`;
|
| 670 |
+
|
| 671 |
+
console.log(`[WIDGET] renderMermaid OK: ${((Date.now() - startTime) / 1000).toFixed(2)}s, svg_size=${(svgStr.length / 1024).toFixed(1)}KB`);
|
| 672 |
+
return dataUrl;
|
| 673 |
+
} catch (e) {
|
| 674 |
+
console.log(`[WIDGET] renderMermaid ERROR: ${e.message}, falling back to Puppeteer`);
|
| 675 |
+
return this.renderWidgetPuppeteer(widgetHtml, 'mermaid', title, 3000);
|
| 676 |
+
}
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
async renderWidgetPuppeteer(widgetHtml, type, title, renderTimeout) {
|
| 680 |
+
const startTime = Date.now();
|
| 681 |
+
console.log(`[WIDGET] renderPuppeteer START: type=${type}, title=${title}, timeout=${renderTimeout}ms`);
|
| 682 |
+
|
| 683 |
+
let browser = null;
|
| 684 |
+
try {
|
| 685 |
+
const finalTimeout = renderTimeout || 3000;
|
| 686 |
+
|
| 687 |
+
const launchArgs = [
|
| 688 |
+
'--no-sandbox',
|
| 689 |
+
'--disable-setuid-sandbox',
|
| 690 |
+
'--disable-dev-shm-usage',
|
| 691 |
+
'--disable-gpu',
|
| 692 |
+
'--disable-software-rasterizer',
|
| 693 |
+
'--memory-pressure-off'
|
| 694 |
+
];
|
| 695 |
+
|
| 696 |
+
browser = await puppeteer.launch({
|
| 697 |
+
executablePath: '/usr/bin/chromium',
|
| 698 |
+
args: launchArgs,
|
| 699 |
+
headless: 'shell'
|
| 700 |
+
});
|
| 701 |
+
|
| 702 |
+
const page = await browser.newPage();
|
| 703 |
+
await page.setViewport({ width: 1024, height: 768 });
|
| 704 |
+
|
| 705 |
+
const templatesDir = path.join(__dirname, 'templates');
|
| 706 |
+
let template;
|
| 707 |
+
try {
|
| 708 |
+
template = fs.readFileSync(path.join(templatesDir, 'widget-render.html'), 'utf8');
|
| 709 |
+
} catch (e) {
|
| 710 |
+
console.log(`[WIDGET] renderPuppeteer template not found, using inline fallback`);
|
| 711 |
+
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>`;
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
let fullHtml = template
|
| 715 |
+
.replace('%%WIDGET_CODE%%', widgetHtml)
|
| 716 |
+
.replace('%%INJECTED_SCRIPTS%%', '');
|
| 717 |
+
|
| 718 |
+
await page.setContent(fullHtml, { waitUntil: 'networkidle0', timeout: 15000 });
|
| 719 |
+
|
| 720 |
+
await page.evaluate(() => { window._widgetRendered = false; });
|
| 721 |
+
|
| 722 |
+
const rendered = await page.waitForFunction(
|
| 723 |
+
() => window._widgetRendered === true,
|
| 724 |
+
{ timeout: finalTimeout }
|
| 725 |
+
).catch(() => null);
|
| 726 |
+
|
| 727 |
+
if (!rendered) {
|
| 728 |
+
console.log(`[WIDGET] renderPuppeteer timeout after ${finalTimeout}ms, trying screenshot anyway`);
|
| 729 |
+
await new Promise(r => setTimeout(r, 1000));
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
const container = await page.$('#widget-container');
|
| 733 |
+
if (!container) {
|
| 734 |
+
console.log(`[WIDGET] renderPuppeteer FAIL: no container found`);
|
| 735 |
+
return null;
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
const screenshot = await container.screenshot({ type: 'png', omitBackground: false });
|
| 739 |
+
const dataUrl = `data:image/png;base64,${screenshot.toString('base64')}`;
|
| 740 |
+
|
| 741 |
+
console.log(`[WIDGET] renderPuppeteer OK: ${((Date.now() - startTime) / 1000).toFixed(2)}s, png=${(screenshot.length / 1024).toFixed(1)}KB`);
|
| 742 |
+
return dataUrl;
|
| 743 |
+
} catch (e) {
|
| 744 |
+
console.log(`[WIDGET] renderPuppeteer ERROR: ${e.message}`);
|
| 745 |
+
return null;
|
| 746 |
+
} finally {
|
| 747 |
+
if (browser) {
|
| 748 |
+
try { await browser.close(); } catch (e) {}
|
| 749 |
+
}
|
| 750 |
+
}
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
async render(widget) {
|
| 754 |
+
const { type, html, title } = widget;
|
| 755 |
+
try {
|
| 756 |
+
switch (type) {
|
| 757 |
+
case 'chart':
|
| 758 |
+
return await this.renderChart(html, title);
|
| 759 |
+
case 'mermaid':
|
| 760 |
+
return await this.renderMermaid(html, title);
|
| 761 |
+
case 'interactive':
|
| 762 |
+
return await this.renderWidgetPuppeteer(html, 'interactive', title, 3000);
|
| 763 |
+
default:
|
| 764 |
+
return await this.renderWidgetPuppeteer(html, type, title, 2000);
|
| 765 |
+
}
|
| 766 |
+
} catch (e) {
|
| 767 |
+
console.log(`[WIDGET] render FAILED: type=${type}, error=${e.message}`);
|
| 768 |
+
return null;
|
| 769 |
+
}
|
| 770 |
+
}
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
const widgetRenderer = new WidgetRenderer();
|
| 774 |
+
const MAX_CONCURRENT_RENDER = 3;
|
| 775 |
+
let activeRenderCount = 0;
|
| 776 |
+
const renderQueue = [];
|
| 777 |
+
|
| 778 |
+
function renderWithConcurrency(widget) {
|
| 779 |
+
return new Promise((resolve, reject) => {
|
| 780 |
+
renderQueue.push({ widget, resolve, reject });
|
| 781 |
+
processRenderQueue();
|
| 782 |
+
});
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
async function processRenderQueue() {
|
| 786 |
+
while (renderQueue.length > 0 && activeRenderCount < MAX_CONCURRENT_RENDER) {
|
| 787 |
+
const { widget, resolve, reject } = renderQueue.shift();
|
| 788 |
+
activeRenderCount++;
|
| 789 |
+
try {
|
| 790 |
+
const result = await widgetRenderer.render(widget);
|
| 791 |
+
resolve(result);
|
| 792 |
+
} catch (e) {
|
| 793 |
+
reject(e);
|
| 794 |
+
} finally {
|
| 795 |
+
activeRenderCount--;
|
| 796 |
+
processRenderQueue();
|
| 797 |
+
}
|
| 798 |
+
}
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
// ─── Widget Rendering Endpoint ─────────────────────────────────
|
| 802 |
+
|
| 803 |
+
app.post('/api/render_charts', async (req, res) => {
|
| 804 |
+
const startTime = Date.now();
|
| 805 |
+
console.log(`\n[WIDGET] ========== render_charts START ==========`);
|
| 806 |
+
|
| 807 |
+
const { widgets, theme } = req.body;
|
| 808 |
+
|
| 809 |
+
if (!widgets || !Array.isArray(widgets) || widgets.length === 0) {
|
| 810 |
+
return res.status(400).json({ error: 'Missing widgets array' });
|
| 811 |
+
}
|
| 812 |
+
|
| 813 |
+
console.log(`[WIDGET] Received ${widgets.length} widgets, theme=${theme || 'light'}`);
|
| 814 |
+
|
| 815 |
+
const results = [];
|
| 816 |
+
const renderPromises = widgets.map(async (widget, index) => {
|
| 817 |
+
const wStart = Date.now();
|
| 818 |
+
const wType = widget.type || 'html';
|
| 819 |
+
const wTitle = widget.title || `widget_${index}`;
|
| 820 |
+
|
| 821 |
+
console.log(`[WIDGET] [${index + 1}/${widgets.length}] Rendering: type=${wType}, title=${wTitle}`);
|
| 822 |
+
|
| 823 |
+
try {
|
| 824 |
+
const renderPromise = renderWithConcurrency(widget);
|
| 825 |
+
const timeoutPromise = new Promise(resolve => setTimeout(() => resolve(null), 15000));
|
| 826 |
+
const dataUrl = await Promise.race([renderPromise, timeoutPromise]);
|
| 827 |
+
|
| 828 |
+
const elapsed = ((Date.now() - wStart) / 1000).toFixed(2);
|
| 829 |
+
if (dataUrl) {
|
| 830 |
+
console.log(`[WIDGET] [${index + 1}/${widgets.length}] OK: ${elapsed}s, dataUrl_len=${(dataUrl.length / 1024).toFixed(1)}KB`);
|
| 831 |
+
return { index, success: true, dataUrl, type: wType, title: wTitle };
|
| 832 |
+
} else {
|
| 833 |
+
console.log(`[WIDGET] [${index + 1}/${widgets.length}] TIMEOUT: ${elapsed}s`);
|
| 834 |
+
return { index, success: false, error: 'timeout', type: wType, title: wTitle };
|
| 835 |
+
}
|
| 836 |
+
} catch (e) {
|
| 837 |
+
console.log(`[WIDGET] [${index + 1}/${widgets.length}] ERROR: ${e.message}`);
|
| 838 |
+
return { index, success: false, error: e.message, type: wType, title: wTitle };
|
| 839 |
+
}
|
| 840 |
+
});
|
| 841 |
+
|
| 842 |
+
const renderResults = await Promise.all(renderPromises);
|
| 843 |
+
|
| 844 |
+
const successCount = renderResults.filter(r => r.success).length;
|
| 845 |
+
const failCount = renderResults.filter(r => !r.success).length;
|
| 846 |
+
const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
| 847 |
+
|
| 848 |
+
console.log(`[WIDGET] ========== render_charts DONE: ${successCount} OK, ${failCount} FAIL, ${totalElapsed}s ==========\n`);
|
| 849 |
+
|
| 850 |
+
res.json({ results: renderResults });
|
| 851 |
+
});
|
| 852 |
+
|
| 853 |
app.listen(port, () => {
|
| 854 |
console.log(`Server listening at http://localhost:${port}`);
|
| 855 |
});
|
templates/widget-render.html
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<style>
|
| 6 |
+
body { margin: 0; padding: 8px; background: #ffffff; font-family: system-ui, -apple-system, sans-serif; }
|
| 7 |
+
|
| 8 |
+
/* Claude Semantic Color Classes */
|
| 9 |
+
.c-purple { fill: #EEEDFE; stroke: #534AB7; color: #3C3489; }
|
| 10 |
+
.c-teal { fill: #E1F5EE; stroke: #0F6E56; color: #085041; }
|
| 11 |
+
.c-coral { fill: #FAECE7; stroke: #993C1D; color: #712B13; }
|
| 12 |
+
.c-pink { fill: #FBEAF0; stroke: #993556; color: #72243E; }
|
| 13 |
+
.c-gray { fill: #F1EFE8; stroke: #5F5E5A; color: #444441; }
|
| 14 |
+
.c-blue { fill: #E6F1FB; stroke: #185FA5; color: #0C447C; }
|
| 15 |
+
.c-green { fill: #EAF3DE; stroke: #3B6D11; color: #27500A; }
|
| 16 |
+
.c-amber { fill: #FAEEDA; stroke: #854F0B; color: #633806; }
|
| 17 |
+
.c-red { fill: #FCEBEB; stroke: #A32D2D; color: #791F1F; }
|
| 18 |
+
|
| 19 |
+
.c-purple rect, .c-purple circle, .c-purple ellipse, .c-purple polygon { fill: #EEEDFE; stroke: #534AB7; }
|
| 20 |
+
.c-teal rect, .c-teal circle, .c-teal ellipse, .c-teal polygon { fill: #E1F5EE; stroke: #0F6E56; }
|
| 21 |
+
.c-coral rect, .c-coral circle, .c-coral ellipse, .c-coral polygon { fill: #FAECE7; stroke: #993C1D; }
|
| 22 |
+
.c-pink rect, .c-pink circle, .c-pink ellipse, .c-pink polygon { fill: #FBEAF0; stroke: #993556; }
|
| 23 |
+
.c-gray rect, .c-gray circle, .c-gray ellipse, .c-gray polygon { fill: #F1EFE8; stroke: #5F5E5A; }
|
| 24 |
+
.c-blue rect, .c-blue circle, .c-blue ellipse, .c-blue polygon { fill: #E6F1FB; stroke: #185FA5; }
|
| 25 |
+
.c-green rect, .c-green circle, .c-green ellipse, .c-green polygon { fill: #EAF3DE; stroke: #3B6D11; }
|
| 26 |
+
.c-amber rect, .c-amber circle, .c-amber ellipse, .c-amber polygon { fill: #FAEEDA; stroke: #854F0B; }
|
| 27 |
+
.c-red rect, .c-red circle, .c-red ellipse, .c-red polygon { fill: #FCEBEB; stroke: #A32D2D; }
|
| 28 |
+
|
| 29 |
+
.th, text.th { font-weight: 500; fill: #212121; }
|
| 30 |
+
.ts, text.ts { font-size: 12px; fill: #6b7280; }
|
| 31 |
+
.t, text.t { font-size: 14px; fill: #212121; }
|
| 32 |
+
|
| 33 |
+
svg .arr { fill: none; stroke: #888780; stroke-width: 1.5; }
|
| 34 |
+
svg .leader { fill: none; stroke: #888780; stroke-width: 0.5; stroke-dasharray: 2 2; }
|
| 35 |
+
svg .box { fill: #f9f9f9; stroke: #e5e5e5; }
|
| 36 |
+
|
| 37 |
+
/* Claude CSS variable fallbacks */
|
| 38 |
+
:root {
|
| 39 |
+
--color-text-primary: #212121;
|
| 40 |
+
--color-text-secondary: #6b7280;
|
| 41 |
+
--color-border-primary: #e5e5e5;
|
| 42 |
+
--color-border-secondary: #e5e5e5;
|
| 43 |
+
--color-border-tertiary: #f0f0f0;
|
| 44 |
+
--color-background-primary: #ffffff;
|
| 45 |
+
--color-background-secondary: #fafafa;
|
| 46 |
+
--color-text-success: #059669;
|
| 47 |
+
--color-text-warning: #d97706;
|
| 48 |
+
--color-text-error: #dc2626;
|
| 49 |
+
}
|
| 50 |
+
</style>
|
| 51 |
+
</head>
|
| 52 |
+
<body>
|
| 53 |
+
<div id="widget-container">%%WIDGET_CODE%%</div>
|
| 54 |
+
<script>
|
| 55 |
+
window._widgetRendered = false;
|
| 56 |
+
</script>
|
| 57 |
+
%%INJECTED_SCRIPTS%%
|
| 58 |
+
<script>
|
| 59 |
+
(function() {
|
| 60 |
+
// Disable Chart.js animations globally
|
| 61 |
+
if (typeof Chart !== 'undefined') {
|
| 62 |
+
const orig = Chart.defaults;
|
| 63 |
+
if (orig.animation) orig.animation.duration = 0;
|
| 64 |
+
if (orig.animations) orig.animations.duration = 0;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function signalDone() {
|
| 68 |
+
window._widgetRendered = true;
|
| 69 |
+
if (window.parent) {
|
| 70 |
+
try { window.parent.postMessage({ type: 'XWX_WIDGET_READY' }, '*'); } catch(e) {}
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
if (document.getElementById('widget-container').innerHTML.includes('canvas')) {
|
| 75 |
+
setTimeout(signalDone, 1500);
|
| 76 |
+
} else {
|
| 77 |
+
requestAnimationFrame(function() { setTimeout(signalDone, 500); });
|
| 78 |
+
}
|
| 79 |
+
})();
|
| 80 |
+
</script>
|
| 81 |
+
</body>
|
| 82 |
+
</html>
|