139 lines
7.3 KiB
Markdown
139 lines
7.3 KiB
Markdown
# 图表导出为图片:实现说明
|
||
|
||
> 记录 `smart-fin-chart` 的图片下载是如何实现的、命名从哪来、以及一个重要的对照结论:
|
||
> **同一段下载代码,在真实浏览器顶层页面能下载,在被 sandbox 限制的 iframe 里会被静默拦截。**
|
||
|
||
---
|
||
|
||
## 一、两条导出路径
|
||
|
||
项目里有两种导出方式,都落在浏览器原生能力上,**没有任何第三方库**(没有 file-saver、没有 html2canvas)。
|
||
|
||
### 路径 A:单张图表导出
|
||
|
||
| 环节 | 位置 |
|
||
|---|---|
|
||
| 按钮 | `js/ui.js:197` —— 卡片右上角 `导出 PNG` |
|
||
| 取图 | `js/main.js:265 exportPNG()` —— `instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' })` |
|
||
| 下载 | 同一个函数里 `anchor.href = dataURL` → `anchor.download = '${filename}.png'` → `anchor.click()` |
|
||
|
||
关键点:
|
||
- 直接用 **ECharts 实例自带的 `getDataURL()`**,等于把画布按 2 倍像素密度重新导出一遍,**不截屏、不重绘**,所以导出结果和处理中的图完全一致,且天然是高清的(`pixelRatio: 2`)。
|
||
- `backgroundColor: '#fff'` 是必须的 —— 否则透明底在公众号里会变黑/变灰。
|
||
|
||
### 路径 B:多张合并成长图
|
||
|
||
入口是卡片上的 `➕ 加入合并`(`js/ui.js:191`),形成一个「合并队列」,然后走三步:
|
||
|
||
1. **入队即存快照**(`js/main.js:282 toggleCart`)
|
||
```js
|
||
cart.set(chart.instanceId, {
|
||
instanceId, title,
|
||
filename: chart.filename || chart.title,
|
||
dataUrl: instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' }),
|
||
});
|
||
```
|
||
存的是**入队那一刻的 dataURL**,不是 ECharts 实例引用。这样即使用户随后切换视图(全部/年度/半年)、改了参数、甚至窗口 resize,队列里的图也不会变。`captureSnapshot()`(`js/main.js:275`)负责在重渲染后刷新快照,保证「所见即所得」。
|
||
|
||
2. **Canvas 纵向拼接**(`js/merge.js:23 mergeImages`)
|
||
- 宽度取各图最大值,宽度不一致的等比缩放对齐;
|
||
- 高度 = 各图高度之和 + `gap × (张数 - 1)`,`gap = 96`(@2x 下视觉 48px);
|
||
- 先铺白底,再逐张 `ctx.drawImage()`。
|
||
- 为什么要纵向拼:公众号在手机上以竖版窗口预览,横版单图显示过小;两张拼成 2400×~2800 接近满屏。
|
||
|
||
3. **Blob 下载**(`js/merge.js:48 downloadCanvas`)
|
||
```js
|
||
canvas.toBlob((blob) => {
|
||
const url = URL.createObjectURL(blob);
|
||
const anchor = document.createElement('a');
|
||
anchor.href = url;
|
||
anchor.download = filename.endsWith('.png') ? filename : `${filename}.png`;
|
||
anchor.click();
|
||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||
}, 'image/png');
|
||
```
|
||
代码注释里写明了理由:**Blob URL 比超长 dataURL 下载更稳** —— 合并长图的 dataURL 字符串可能到几 MB 量级,塞进 `href` 在部分浏览器上会失败;转成 Blob 再由浏览器托管,稳定得多。用完 10 秒后释放,避免内存泄漏。
|
||
|
||
注意:**单图路径用的是 dataURL,合并路径用的是 Blob URL** —— 这不是不一致,是刻意分工(单图 dataURL 只有几百 KB,够用且更简单)。
|
||
|
||
### 合并预览弹窗
|
||
|
||
`showMergePreview()`(`js/ui.js:276`)只是在 canvas 上直接显示 + 挂两个下载按钮,**不产生第二份数据**:`entry.canvas` 就是拼好的那块 canvas,点下载才走 `toBlob`。
|
||
|
||
- 配对模式:队列两两合并(`buildCompositions()`,`js/main.js:324`),奇数张时最后一张单独导出并提示;
|
||
- 「全部下载」用 `setTimeout(..., index * 350)` 错峰触发(`js/main.js:353`)—— 连续同步触发多个下载会被浏览器判定为「自动批量下载」而拦截。
|
||
|
||
---
|
||
|
||
## 二、文件名从哪来
|
||
|
||
**统一来自 `manifest.json` 的 `filename` 字段,回退到 `chart.title`。**
|
||
|
||
- 挂载点:`js/ui.js:198` → `onExport(chart.instanceId, chart.filename || chart.title)`
|
||
- `manifest.json` 里共 **85 处** `filename`,例如:
|
||
```json
|
||
{ "key": "ppmt-profit", "title": "盈利能力", ..., "filename": "泡泡玛特盈利能力" }
|
||
```
|
||
- 也就是说**展示名和下载名是两套**:卡片标题是短的(「盈利能力」),下载名是完整的(「泡泡玛特盈利能力」)。这样屏幕上不啰嗦,存下来的一堆图又不会重名。
|
||
- 合并导出时用 `clipName()` 截断到 24 字,多张用 `+` 连接(`js/main.js:319, 338`)。
|
||
|
||
---
|
||
|
||
## 三、对照结论:为什么同一段代码在别处就下载不了
|
||
|
||
**这个项目能下载,根本原因是它跑在真实浏览器的顶层页面**(`start.command` → `http://localhost:8899/` → `open "$URL"`),**不在任何 iframe 沙箱里**。
|
||
|
||
对照实验(无头 Chromium + CDP 显式 `Browser.setDownloadBehavior: allow`,两组跑**完全同一段**下载代码):
|
||
|
||
| 场景 | 结果 | CDP 事件 |
|
||
|---|---|---|
|
||
| 顶层页面 | ✅ 文件落盘 | `Browser.downloadWillBegin` → `downloadProgress` ×6 |
|
||
| `sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals"` 的 iframe | ❌ 未落盘 | **连 `downloadWillBegin` 都没有** |
|
||
|
||
**第二条是要害**:沙箱拦截时**不报错、不弹提示、不发事件** —— 下载请求在发起前就被丢弃。页面上看不出任何异常,控制台也没有告警,所以表现成「按钮点了没反应」,而且回溯起来会误以为是代码写错了。
|
||
|
||
结论:
|
||
1. **沙箱 iframe 缺 `allow-downloads`** 是唯一原因,和 `toBlob` / `createObjectURL` / `a.click()` 的写法无关;
|
||
2. 补救方向是让下载动作**离开沙箱**:宿主允许 `allow-popups-to-escape-sandbox` 时,从 iframe 里 `window.open()` 出一个弹窗(弹窗不受沙箱约束),在弹窗里挂 `<a download>` 触发下载。
|
||
3. 注意 `window.open()` **必须有真实用户手势**(真实点击),脚本内直接调用会被弹窗拦截器挡掉。
|
||
|
||
---
|
||
|
||
## 四、可复用的最小实现
|
||
|
||
```js
|
||
// canvas → PNG 下载(Blob URL 版本,适合大图)
|
||
function downloadCanvas(canvas, filename) {
|
||
canvas.toBlob((blob) => {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename.endsWith('.png') ? filename : `${filename}.png`;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||
}, 'image/png');
|
||
}
|
||
|
||
// ECharts 实例 → PNG 下载(高清,dataURL 版本)
|
||
function downloadChart(chartInstance, filename) {
|
||
const a = document.createElement('a');
|
||
a.href = chartInstance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' });
|
||
a.download = `${filename}.png`;
|
||
a.click();
|
||
}
|
||
```
|
||
|
||
前提:**这段代码必须在非沙箱的顶层页面里执行**。若运行在 iframe 中,先确认宿主给了 `allow-downloads`,否则要走弹窗逃逸。
|
||
|
||
---
|
||
|
||
## 五、相关文件索引
|
||
|
||
| 文件 | 职责 |
|
||
|---|---|
|
||
| `js/merge.js` | `mergeImages()` 纵向拼接、`downloadCanvas()` Blob 下载 |
|
||
| `js/main.js` | `exportPNG()` 单图导出、`toggleCart()` 队列快照、`buildCompositions()` 配对、`openMergePreview()` |
|
||
| `js/ui.js` | `renderCards()` 挂导出按钮、`showMergePreview()` 预览弹窗 |
|
||
| `manifest.json` | `filename` 字段(下载命名,85 处) |
|
||
| `css/style.css` | `.export-button` / `.cart-*` / `.modal-*` / `.preview-*` 样式 |
|