File size: 19,457 Bytes
216c0a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
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