在 bpmn.js 中优雅导出 PNG 图像:从 SVG 转换失败到 HTML2Canvas 的实践之路

引言

在现代 Web 应用中,使用 bpmn.js 来可视化和编辑业务流程模型已成为一种常见做法。然而,当需要将流程图导出为图片(如 PNG)用于文档归档、报告生成或分享时,开发者往往会遇到一些意料之外的挑战。

本文将分享我在项目中实现 高质量 PNG 图像导出 的完整探索过程。我尝试了两种主流方案:SVG 转 PNGHTML 转 Canvas 再转 PNG。最终,由于自定义元素渲染问题,我放弃了第一种方案,转而采用第二种,并通过 html2canvas 实现了稳定、清晰的导出效果。


为什么需要导出 PNG?

在实际业务场景中,我们经常需要:

  • 将流程图嵌入 PDF 报告;
  • 作为邮件附件发送给非技术人员;
  • 在移动端展示静态流程图;
  • 用于审批流程中的快照记录。

虽然 bpmn.js 提供了原生的 .saveSVG() 方法,但 SVG 格式并不总是适用。PNG 作为位图格式,兼容性更好,适合大多数展示和打印场景。


方案一:SVG 转 PNG(理想但受限)

最直观的想法是:bpmn.js 渲染的是 SVG,那我直接获取 SVG 字符串,再用 <foreignObject>canvg 等库将其绘制到 Canvas 上,最后转为 PNG 不就行了?

初步实现

JAVASCRIPT
async function exportAsPNGViaSVG(modeler) { const { svg } = await modeler.saveSVG(); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const img = new Image(); img.src = 'data:image/svg+xml,' + encodeURIComponent(svg); img.onload = () => { canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); const dataUrl = canvas.toDataURL('image/png'); downloadFile(dataUrl, 'process.png'); }; }

问题浮现:自定义 Overlay 元素丢失!

然而,现实很快打了脸。我们项目中使用了 bpmn.js 的 Overlay 功能 来添加任务说明等自定义信息:

JAVASCRIPT
modeler.get('overlays').add(elementId, { position: { bottom: 0, right: 0 }, html: '<div class="custom-overlay">审批人:张三</div>' });

这些 Overlay 元素是通过 HTML DOM 渲染的,位于 .djs-overlay-container 容器中,并不会包含在 bpmn.js 导出的 SVG 字符串中

因此,使用 saveSVG() 方法导出的图像缺少所有自定义标注,导致信息不完整,无法满足业务需求。

结论: saveSVG() 只包含流程图本身的矢量图形,不包含任何 HTML Overlay 或自定义 DOM 元素。


方案二:HTML 转 Canvas(最终解决方案)

既然 SVG 不包含 Overlay,那我们不如直接对整个 bpmn.js 渲染容器进行“截图”!这就是 html2canvas 的用武之地。

html2canvas 可以将任意 DOM 元素渲染为 Canvas,支持 CSS3 变换、透明度、阴影等,非常适合我们的需求。

核心思路

  1. 复制 bpmn.js 容器 DOM;
  2. 清理其位置和缩放状态,确保导出的是“完整、无缩放”的视图;
  3. 移除不必要的背景网格(可选);
  4. 使用 html2canvas 渲染为 Canvas;
  5. 转为 Data URL 并触发下载。

最终实现代码

以下是我在项目中稳定运行的完整实现:

JAVASCRIPT
import html2canvas from 'html2canvas'; /** * 将 bpmn.js 容器导出为 PNG 图像(Data URL) * @param {HTMLElement} bpmnContainer - bpmn.js 的容器 DOM 元素 * @returns {Promise<string>} 图像的 Data URL */ function htmlToImage(bpmnContainer) { return new Promise(async (resolve, reject) => { // 1. 克隆容器,避免影响当前视图 let bpmnContainerClone = bpmnContainer.cloneNode(true); bpmnContainerClone.style.position = 'absolute'; bpmnContainerClone.style.left = '0px'; bpmnContainerClone.style.top = '0px'; bpmnContainerClone.style.zIndex = '-1'; document.body.appendChild(bpmnContainerClone); // 2. 获取关键元素 let viewport = bpmnContainerClone.querySelector('.viewport'); let djsOverlayContainer = bpmnContainerClone.querySelector('.djs-overlay-container'); // 3. 重置 transform,回到初始状态 viewport.style.transform = 'none'; djsOverlayContainer.style.transform = 'none'; // 4. 移除背景网格(可选,提升美观度) let layerDjsGrid = bpmnContainerClone.querySelector('.layer-djs-grid'); if (layerDjsGrid) { layerDjsGrid.parentNode.removeChild(layerDjsGrid); } // 5. 获取视口真实尺寸 let { x: offsetX, y: offsetY, width, height } = viewport.getBoundingClientRect(); // 6. 重新设置 transform,确保内容完整显示 viewport.style.transform = `translate(${-offsetX}px, ${-offsetY}px)`; djsOverlayContainer.style.transform = `translate(${-offsetX}px, ${-offsetY}px)`; // 7. 设置克隆容器尺寸 bpmnContainerClone.style.width = width + 'px'; bpmnContainerClone.style.height = height + 'px'; try { // 8. 使用 html2canvas 渲染 const canvas = await html2canvas(bpmnContainerClone, { useCORS: true, // 支持跨域资源 scale: 5, // 提高清晰度(默认为1) width, // 显式指定宽度 height // 显式指定高度 }); // 9. 清理临时 DOM document.body.removeChild(bpmnContainerClone); // 10. 转为 PNG Data URL const dataUrl = canvas.toDataURL('image/png'); resolve(dataUrl); } catch (error) { reject('Canvas 导出失败:' + error.message); } }); } /** * 导出 bpmn 流程图为 PNG 文件 * @param {BpmnModeler} modeler - bpmn.js modeler 实例 * @param {HTMLElement} bpmnContainer - 容器 DOM * @param {string} filename - 文件名(不含后缀) */ const exportBpmnAsImage = async (modeler, bpmnContainer, filename = 'process') => { try { const dataUrl = await htmlToImage(bpmnContainer); downloadFile(dataUrl, `${filename}.png`); console.log('图片导出成功'); } catch (error) { console.error('图片导出失败:', error); } }; // 辅助函数:触发文件下载 function downloadFile(dataUrl, filename) { const a = document.createElement('a'); a.href = dataUrl; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); }

关键技术点解析

1. 为什么要克隆 DOM?

直接对原容器操作会影响用户当前视图。克隆后在后台处理,避免页面闪烁或布局错乱。

2. 为什么需要重置和重新设置 transform

bpmn.js 使用 transform: translate(x, y) scale(s) 来实现平移和缩放。如果不重置,getBoundingClientRect() 获取的尺寸可能不准确。重置后获取真实尺寸,再通过 translate(-offsetX, -offsetY) 确保内容完整显示在 Canvas 中。

3. scale: 5 的作用

html2canvasscale 选项用于提高输出图像的分辨率。默认为 1,图像可能模糊。设置为 5 可获得高清图像,适合打印或高清屏展示。

⚠️ 注意:过高的 scale 会增加内存消耗和渲染时间。

4. 移除 .layer-djs-grid

背景网格在编辑时有用,但导出图片时可能显得杂乱。移除后图像更简洁。


使用方式

JAVASCRIPT
// 假设你已经初始化了 modeler 和 container const modeler = new BpmnModeler({ container: '#bpmn-container' }); const bpmnContainer = document.getElementById('bpmn-container'); // 导出为 PNG exportBpmnAsImage(modeler, bpmnContainer, '审批流程');

优缺点总结

方案 优点 缺点
SVG 转 PNG 矢量无损,文件小 不包含 Overlay,信息不全
HTML 转 Canvas 包含所有 DOM 元素,完整准确 生成位图,文件较大;依赖 html2canvas

推荐选择: 如果你使用了 Overlay 或自定义 HTML 元素,必须选择 HTML 转 Canvas 方案


结语

在 bpmn.js 中导出 PNG 看似简单,实则暗藏玄机。通过这次实践,我深刻体会到:工具的“标准”方法未必适合所有业务场景。只有深入理解其渲染机制(SVG + HTML Overlay 分离),才能找到真正可靠的解决方案。

最终,我们通过 html2canvas 成功实现了包含所有自定义信息的高清 PNG 导出,满足了业务需求。希望本文能为你在 bpmn.js 图像导出的路上提供一些启发和帮助。

【END】

本文链接:

版权声明:本博客所有文章除声明转载外,均采用 BY-NC-SA 3.0 许可协议。转载请注明来自 知己知事知理

阅读 5 | 发布于 2025-10-19
暂无评论