# 图表导出为图片:实现说明 > 记录 `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()` 出一个弹窗(弹窗不受沙箱约束),在弹窗里挂 `` 触发下载。 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-*` 样式 |