Spaces:
Sleeping
Sleeping
| 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 = `<svg width="${viewWidth}" height="${viewHeight}" xmlns="http://www.w3.org/2000/svg"> | |
| <title>Chart (Fallback)</title> | |
| <desc>This is a fallback SVG using a PNG screenshot.</desc> | |
| <image href="${pngFileName}" width="${viewWidth}" height="${viewHeight}" /> | |
| </svg>`; | |
| 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 | |