XWX-AI Claude Opus 4.6 commited on
Commit
44a8902
·
1 Parent(s): e550365

feat: add widget renderer (chart.js, mermaid) and bump to v2.0.0

Browse files
Files changed (5) hide show
  1. Dockerfile +6 -8
  2. package-lock.json +0 -0
  3. package.json +7 -5
  4. server.js +437 -1
  5. 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
- # 1. Install Chrome Dependencies
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
- # 2. Set working directory
17
  WORKDIR /app
18
 
19
- # 3. Install Node.js Dependencies
20
  COPY package.json .
21
  RUN npm install
22
 
23
- # 4. Copy Application Code
24
  COPY . .
25
 
26
- # 5. Expose Port (Hugging Face expects 7860)
27
  EXPOSE 7860
28
 
29
- # 6. Run the Application
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": "1.0.1",
5
- "description": "Puppeteer PDF Generator for Hugging Face Spaces",
6
  "main": "server.js",
7
  "dependencies": {
8
  "cors": "^2.8.5",
9
  "express": "^4.18.2",
10
- "puppeteer": "^22.0.0",
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>