import os import shutil import subprocess import tempfile import atexit import json import threading from modules.chart_engine.utils.file_utils import create_temp_file, cleanup_temp_file _PERSISTENT_RENDERER = None _PERSISTENT_RENDERER_LOCK = threading.Lock() def _puppeteer_env(): env = os.environ.copy() if env.get("PUPPETEER_EXECUTABLE_PATH"): return env for chrome_bin in ( shutil.which("google-chrome"), shutil.which("chromium"), shutil.which("chromium-browser"), "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", ): if chrome_bin and os.path.exists(chrome_bin): env["PUPPETEER_EXECUTABLE_PATH"] = chrome_bin break return env class _PersistentHtmlToSvgRenderer: def __init__(self): script_path = os.path.join( os.path.dirname(__file__), "persistent_html_to_svg_renderer.cjs", ) self.proc = subprocess.Popen( ["node", script_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1, env=_puppeteer_env(), ) self._next_id = 0 self._write_lock = threading.Lock() self._pending_lock = threading.Lock() self._pending = {} self._reader = threading.Thread(target=self._read_loop, daemon=True) self._reader.start() def _read_loop(self): for line in self.proc.stdout: if not line.strip(): continue response = json.loads(line) request_id = response["id"] with self._pending_lock: item = self._pending.get(request_id) if item is not None: item["response"] = response item["event"].set() def render(self, html_file, output_svg, width, height): event = threading.Event() with self._write_lock: self._next_id += 1 request_id = self._next_id with self._pending_lock: self._pending[request_id] = {"event": event, "response": None} request = { "id": request_id, "htmlFile": os.path.abspath(html_file), "outputSvg": os.path.abspath(output_svg), "width": int(width), "height": int(height), } self.proc.stdin.write(json.dumps(request) + "\n") self.proc.stdin.flush() if not event.wait(60): with self._pending_lock: self._pending.pop(request_id, None) print(f"Error: puppeteer render timed out after 60s for {os.path.basename(html_file)}") return None with self._pending_lock: item = self._pending.pop(request_id) response = item["response"] if response.get("ok"): return output_svg print( f"Error: persistent puppeteer render failed for " f"{os.path.basename(html_file)}: {response.get('error')}" ) return None def close(self): if self.proc.poll() is None: self.proc.terminate() def _get_persistent_renderer(): global _PERSISTENT_RENDERER with _PERSISTENT_RENDERER_LOCK: if _PERSISTENT_RENDERER is None or _PERSISTENT_RENDERER.proc.poll() is not None: _PERSISTENT_RENDERER = _PersistentHtmlToSvgRenderer() return _PERSISTENT_RENDERER def _close_persistent_renderer(): if _PERSISTENT_RENDERER is not None: _PERSISTENT_RENDERER.close() atexit.register(_close_persistent_renderer) def html_to_svg(html_file, output_svg=None, width=1200, height=800): """ Convert an HTML file with ECharts or D3.js to SVG using Puppeteer. This requires a Node.js script to perform the conversion. Args: html_file: Path to the HTML file output_svg: Path to save the SVG file (optional) width: Width of the SVG height: Height of the SVG Returns: Path to the generated SVG file """ if output_svg is None: output_svg = os.path.splitext(html_file)[0] + '.svg' if os.environ.get("CHARTPIPELINE_PERSISTENT_PUPPETEER", "1") != "0": return _get_persistent_renderer().render(html_file, output_svg, width, height) # Create a temporary Node.js script for the conversion using CommonJS syntax js_script = """ const puppeteer = require('puppeteer'); const fs = require('fs'); const path = require('path'); (async () => { const launchOpts = { headless: 'new', // Use the new headless mode args: ['--no-sandbox', '--disable-setuid-sandbox'] }; if (process.env.PUPPETEER_EXECUTABLE_PATH) { launchOpts.executablePath = process.env.PUPPETEER_EXECUTABLE_PATH; } const browser = await puppeteer.launch(launchOpts); const page = await browser.newPage(); await page.setViewport({ width: %d, height: %d }); try { // Load the HTML file await page.goto('file://' + path.resolve('%s'), { waitUntil: 'networkidle0' }); const svgReady = () => page.evaluate(() => { const hasReadySvg = (svg) => { if (!svg || svg.childNodes.length === 0) { return false; } return svg.querySelectorAll('path, circle, rect, g, text').length > 0; }; const chartSvg = document.querySelector('#chart-container svg'); if (hasReadySvg(chartSvg)) { return true; } for (const selector of ['#svg-output', '#manual-svg-output']) { const output = document.querySelector(selector); if (output && output.innerHTML.trim() && output.querySelector('svg')) { return true; } } return false; }); const waitStart = Date.now(); while (Date.now() - waitStart < 3000 && !(await svgReady())) { await new Promise(resolve => setTimeout(resolve, 100)); } // Check if it's an ECharts or D3.js chart const isECharts = await page.evaluate(() => { return typeof echarts !== 'undefined'; }); let svgContent; if (isECharts) { // For ECharts, we'll check if the SVG has already been rendered to #svg-output const hasSvgOutput = await page.evaluate(() => { return document.querySelector('#svg-output') !== null; }); if (hasSvgOutput) { // If we have the SVG output div, use its content svgContent = await page.evaluate(() => { const svgOutput = document.querySelector('#svg-output'); return svgOutput.innerHTML; }); console.log('Successfully retrieved SVG from #svg-output'); } else { // If there's no SVG output yet, try alternative method console.log('No #svg-output found, using alternative method'); // Add a button to trigger SVG export for ECharts await page.evaluate(() => { // Add a button to the page to trigger SVG export const button = document.createElement('button'); button.id = 'export-svg-button'; button.style.display = 'none'; button.onclick = function() { const chart = echarts.getInstanceByDom(document.querySelector('#chart-container')); if (chart) { // Force animation off chart.setOption({animation: false}); // Try to explicitly render as SVG try { chart.setOption({ renderer: 'svg' }); } catch (e) { console.error('Failed to set SVG renderer:', e); } // Try to get SVG string and save it try { const svgContent = chart.renderToSVGString(); if (svgContent) { const svgOutput = document.createElement('div'); svgOutput.id = 'manual-svg-output'; svgOutput.style.display = 'none'; svgOutput.innerHTML = svgContent; document.body.appendChild(svgOutput); } } catch (e) { console.error('Failed to get SVG string:', e); } } }; document.body.appendChild(button); // Click the button to trigger SVG export document.getElementById('export-svg-button').click(); }); // Wait for the manual SVG to be rendered await new Promise(resolve => setTimeout(resolve, 500)); // Try to get the manual SVG content const hasManualSvg = await page.evaluate(() => { return document.querySelector('#manual-svg-output') !== null; }); if (hasManualSvg) { svgContent = await page.evaluate(() => { return document.querySelector('#manual-svg-output').innerHTML; }); console.log('Successfully retrieved SVG from #manual-svg-output'); } } } // If we haven't got the SVG content yet, try to get it from the DOM if (!svgContent) { console.log('Attempting to extract SVG directly from DOM'); svgContent = await page.evaluate(() => { const container = document.querySelector('#chart-container'); if (!container) { console.error('Chart container not found'); return null; } const hasRenderableSvg = (svg) => ( svg && svg.childNodes.length > 0 && svg.querySelectorAll('path, circle, rect, g, text, line, polygon, polyline, ellipse, image').length > 0 ); const svgElement = Array.from(container.querySelectorAll('svg')).find(hasRenderableSvg); if (!svgElement) { console.error('SVG element not found in container'); return null; } // 检查SVG内是否有内容 if (svgElement.childNodes.length === 0) { console.error('SVG element is empty'); return null; } // 检查是否有path或g元素 const hasGraphicElements = svgElement.querySelectorAll('path, circle, rect, g, text').length > 0; if (!hasGraphicElements) { console.error('SVG has no graphic elements'); // 但如果强制需要,我们仍然返回它 } // 查看ECharts图表的情况 if (typeof echarts !== 'undefined') { const chart = echarts.getInstanceByDom(container); if (chart) { // 再次强制关闭动画 chart.setOption({animation: false}); // 等待一小段时间 setTimeout(() => { // 再次触发重绘 chart.resize(); }, 100); } } // Clone the SVG to avoid modifying the original const svgClone = svgElement.cloneNode(true); // Add width and height attributes if they don't exist if (!svgClone.hasAttribute('width')) { svgClone.setAttribute('width', container.clientWidth); } if (!svgClone.hasAttribute('height')) { svgClone.setAttribute('height', container.clientHeight); } // Add viewBox if it doesn't exist if (!svgClone.hasAttribute('viewBox')) { svgClone.setAttribute('viewBox', `0 0 ${container.clientWidth} ${container.clientHeight}`); } // 输出SVG中元素的数量用于调试 console.log(`SVG contains ${svgClone.querySelectorAll('*').length} elements`); console.log(`SVG contains ${svgClone.querySelectorAll('path').length} paths`); console.log(`SVG contains ${svgClone.querySelectorAll('g').length} groups`); console.log(`SVG contains ${svgClone.querySelectorAll('text').length} text elements`); // Return the SVG as a string return svgClone.outerHTML; }); } if (!svgContent) { // 尝试最后的方法 - 直接从页面截图 console.log('Failed to extract SVG content, attempting screenshot as fallback'); // 截图前等待额外时间确保绘制完成 await new Promise(resolve => setTimeout(resolve, 500)); // 1. 获取页面截图 // 为了避免格式化问题,使用硬编码的文件名 const outputPath = '${OUTPUT_SVG_PATH}'; const pngPath = outputPath.replace('.svg', '.png'); await page.screenshot({path: pngPath, fullPage: true}); console.log('Created PNG screenshot as fallback: ' + pngPath); // 2. 创建一个简单的SVG,引用PNG const pngFileName = path.basename(pngPath); const viewWidth = ${WIDTH}; const viewHeight = ${HEIGHT}; svgContent = ` Chart (Fallback) This is a fallback SVG using a PNG screenshot. `; console.error('Generated fallback SVG with embedded PNG reference'); } // Write SVG to file fs.writeFileSync('%s', svgContent); } catch (error) { console.error('Error generating SVG:', error); process.exit(1); } finally { await browser.close(); } })(); """ # 替换JavaScript模板中的特殊占位符 js_script = js_script.replace('${OUTPUT_SVG_PATH}', output_svg.replace('\\', '\\\\')) js_script = js_script.replace('${WIDTH}', str(width)) js_script = js_script.replace('${HEIGHT}', str(height)) # 应用Python格式化参数 js_script = js_script % ( width, # SVG viewport width height, # SVG viewport height html_file.replace('\\', '\\\\'),# HTML file path output_svg.replace('\\', '\\\\') # Final SVG output path ) # Create a temporary script file with random name in tmp directory js_file = create_temp_file(prefix="html_to_svg_", suffix=".cjs", content=js_script) # Run the Node.js script. Capture stdout/stderr so puppeteer's # ``console.log`` chatter ("Attempting to extract SVG directly from DOM", # etc.) doesn't pollute the parent process' fd=1 (which would bypass # any Python-level sys.stdout redirection done by the caller). # # We do still capture stdout (instead of DEVNULL) so that when the chart # falls through to the PNG fallback path we can surface what puppeteer # was complaining about. On the success path nothing is printed. def _decode(buf): if buf is None: return "" if isinstance(buf, (bytes, bytearray)): return buf.decode("utf-8", errors="replace") return str(buf) def _is_fallback_output(svg_path: str) -> bool: try: with open(svg_path, "r", encoding="utf-8", errors="ignore") as fh: head = fh.read(4096) return "This is a fallback SVG using a PNG screenshot" in head except OSError: return False try: completed = subprocess.run( ['node', js_file], check=True, timeout=60, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=_puppeteer_env(), ) if _is_fallback_output(output_svg): out = _decode(completed.stdout).strip() err = _decode(completed.stderr).strip() print( "Error: puppeteer fell back to PNG screenshot for " f"{os.path.basename(html_file)}; node stderr=<<<\n{err}\n>>> " f"stdout-tail=<<<\n{out[-1500:]}\n>>>" ) cleanup_temp_file(js_file) return output_svg except subprocess.TimeoutExpired as e: print( f"Error: puppeteer render timed out after 60s for " f"{os.path.basename(html_file)}" ) cleanup_temp_file(js_file) return None except subprocess.CalledProcessError as e: err = _decode(e.stderr) out = _decode(e.stdout) print( f"Error: puppeteer render failed (rc={e.returncode}) for " f"{os.path.basename(html_file)}: stderr=<<<\n{err}\n>>> " f"stdout-tail=<<<\n{out[-1500:]}\n>>>" ) cleanup_temp_file(js_file) return None