Files
smart-fin-chart/docs/图片导出下载实现.md
2026-09-24 17:14:03 +08:00

139 lines
7.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 图表导出为图片:实现说明
> 记录 `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-*` 样式 |