Ray1ee01's picture
Add runnable ChartPipeline Space
216c0a4 verified
Raw
History Blame Contribute Delete
19.5 kB
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