引言
在现代 Web 应用中,使用 bpmn.js 来可视化和编辑业务流程模型已成为一种常见做法。然而,当需要将流程图导出为图片(如 PNG)用于文档归档、报告生成或分享时,开发者往往会遇到一些意料之外的挑战。
本文将分享我在项目中实现 高质量 PNG 图像导出 的完整探索过程。我尝试了两种主流方案:SVG 转 PNG 和 HTML 转 Canvas 再转 PNG。最终,由于自定义元素渲染问题,我放弃了第一种方案,转而采用第二种,并通过 html2canvas 实现了稳定、清晰的导出效果。
为什么需要导出 PNG?
在实际业务场景中,我们经常需要:
- 将流程图嵌入 PDF 报告;
- 作为邮件附件发送给非技术人员;
- 在移动端展示静态流程图;
- 用于审批流程中的快照记录。
虽然 bpmn.js 提供了原生的 .saveSVG() 方法,但 SVG 格式并不总是适用。PNG 作为位图格式,兼容性更好,适合大多数展示和打印场景。
方案一:SVG 转 PNG(理想但受限)
最直观的想法是:bpmn.js 渲染的是 SVG,那我直接获取 SVG 字符串,再用 <foreignObject> 或 canvg 等库将其绘制到 Canvas 上,最后转为 PNG 不就行了?
初步实现
JAVASCRIPTasync 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 功能 来添加任务说明等自定义信息:
JAVASCRIPTmodeler.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 变换、透明度、阴影等,非常适合我们的需求。
核心思路
- 复制 bpmn.js 容器 DOM;
- 清理其位置和缩放状态,确保导出的是“完整、无缩放”的视图;
- 移除不必要的背景网格(可选);
- 使用
html2canvas渲染为 Canvas; - 转为 Data URL 并触发下载。
最终实现代码
以下是我在项目中稳定运行的完整实现:
JAVASCRIPTimport 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 的作用
html2canvas 的 scale 选项用于提高输出图像的分辨率。默认为 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 图像导出的路上提供一些启发和帮助。