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

7.3 KiB
Raw Blame History

图表导出为图片:实现说明

记录 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)

    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)

    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,例如:
    { "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() 必须有真实用户手势(真实点击),脚本内直接调用会被弹窗拦截器挡掉。

四、可复用的最小实现

// 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-* 样式