基于FabricJS的图形编辑器:导出Canvas为可编辑PDF的技术需求
嘿,这个需求我刚好折腾过!要导出能在Adobe Illustrator或Corel里编辑的矢量PDF,绝对不能走“转成图片再嵌入PDF”的路子——得把FabricJS里的矢量对象直接转成PDF的原生矢量元素才行。下面是我总结的实现思路和具体方案:
核心思路
FabricJS的所有图形对象(路径、文本、形状等)本质上都是矢量数据,我们要做的就是把这些对象的属性(路径坐标、填充色、描边样式、字体参数等)映射成PDF支持的矢量指令,再用专业的PDF生成库把这些指令组装成完整的PDF文档。
具体实现步骤
1. 选对PDF生成库
推荐用pdf-lib,它对矢量元素的支持非常灵活,能轻松生成可编辑的PDF内容。相比之下,jsPDF默认处理方式可能会偏向位图,需要额外配置才能支持矢量,所以pdf-lib是更省心的选择。
2. 提取FabricJS对象的矢量数据
先遍历画布上的对象(或者指定区域内的对象),把每个对象的关键信息提取出来,方便后续转换成PDF元素:
// 获取画布所有对象 const canvasObjects = canvas.getObjects(); const pdfReadyObjects = []; canvasObjects.forEach(obj => { // 跳过隐藏对象 if (!obj.visible) return; if (obj.type === 'path') { pdfReadyObjects.push({ type: 'path', pathData: obj.path, fill: obj.fill, stroke: obj.stroke, strokeWidth: obj.strokeWidth, left: obj.left, top: obj.top, width: obj.width, height: obj.height, scaleX: obj.scaleX, scaleY: obj.scaleY, angle: obj.angle }); } else if (obj.type === 'text' || obj.type === 'textbox') { pdfReadyObjects.push({ type: 'text', content: obj.text, fontFamily: obj.fontFamily, fontSize: obj.fontSize, fill: obj.fill, left: obj.left, top: obj.top, angle: obj.angle }); } else if (obj.type === 'rect') { pdfReadyObjects.push({ type: 'rect', width: obj.width * obj.scaleX, height: obj.height * obj.scaleY, fill: obj.fill, stroke: obj.stroke, strokeWidth: obj.strokeWidth, left: obj.left, top: obj.top, angle: obj.angle }); } // 圆形、三角形等其他形状可以类似处理 });
3. 用pdf-lib绘制矢量PDF
接下来创建PDF文档,把提取的对象逐个绘制进去,这里要注意坐标转换(FabricJS原点在左上角,PDF原点在左下角)和对象变换(缩放、旋转)的处理:
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; async function exportEditablePDF(canvas, exportArea = null) { // 创建新PDF文档 const pdfDoc = await PDFDocument.create(); // 按画布尺寸创建页面,支持指定导出区域的话可以调整页面大小 const pageWidth = exportArea ? exportArea.width : canvas.width; const pageHeight = exportArea ? exportArea.height : canvas.height; const page = pdfDoc.addPage([pageWidth, pageHeight]); const { width, height } = page.getSize(); // 处理指定导出区域:设置裁剪范围 let offsetX = 0, offsetY = 0; if (exportArea) { offsetX = exportArea.x; offsetY = exportArea.y; // 给PDF页面设置裁剪区域 page.pushOperators([ pdfDoc.context.operator('w', pageWidth), pdfDoc.context.operator('h', pageHeight), pdfDoc.context.operator('re'), pdfDoc.context.operator('W'), pdfDoc.context.operator('n'), ]); } // 嵌入字体(这里用标准字体,自定义字体需要加载字体文件嵌入) const baseFont = await pdfDoc.embedFont(StandardFonts.Helvetica); // 遍历绘制每个对象 pdfReadyObjects.forEach(obj => { // 跳过不在导出区域的对象 if (exportArea && !obj.intersectsWithRect(exportArea)) return; // 转换FabricJS坐标到PDF坐标 const pdfX = obj.left - offsetX; const pdfY = height - (obj.top + obj.height - offsetY); if (obj.type === 'path') { // 把FabricJS路径数组转换成PDF路径字符串 const pathStr = obj.path.map(seg => { const [cmd, ...params] = seg; return `${cmd}${params.join(' ')}`; }).join(' '); // 设置填充和描边 if (obj.fill && obj.fill !== 'transparent') { page.setFillColor(rgb(...hexToRgb(obj.fill))); } if (obj.stroke && obj.stroke !== 'transparent') { page.setStrokeColor(rgb(...hexToRgb(obj.stroke))); page.setLineWidth(obj.strokeWidth); } // 应用对象的缩放、旋转变换 page.saveGraphicsState(); page.translate(pdfX + obj.width/2, pdfY + obj.height/2); page.rotate(-obj.angle); page.scale(obj.scaleX, obj.scaleY); page.translate(-obj.width/2, -obj.height/2); // 绘制路径 page.drawPath(pathStr, { fill: obj.fill !== 'transparent' ? 'non-zero' : undefined, stroke: obj.stroke !== 'transparent' ? true : undefined, }); page.restoreGraphicsState(); } else if (obj.type === 'text') { // 绘制文本 page.drawText(obj.content, { x: pdfX, y: pdfY + obj.fontSize, // 调整文本基线位置 font: baseFont, size: obj.fontSize, color: rgb(...hexToRgb(obj.fill)), rotate: -obj.angle * Math.PI / 180, // 转成弧度 }); } else if (obj.type === 'rect') { // 绘制矩形 page.saveGraphicsState(); page.translate(pdfX + obj.width/2, pdfY + obj.height/2); page.rotate(-obj.angle); page.translate(-obj.width/2, -obj.height/2); page.drawRectangle({ width: obj.width, height: obj.height, fill: obj.fill !== 'transparent' ? rgb(...hexToRgb(obj.fill)) : undefined, stroke: obj.stroke !== 'transparent' ? rgb(...hexToRgb(obj.stroke)) : undefined, borderWidth: obj.strokeWidth, }); page.restoreGraphicsState(); } }); // 导出PDF字节数据,可用于下载或保存 const pdfBytes = await pdfDoc.save(); return pdfBytes; } // 辅助工具:十六进制颜色转RGB数组(适配pdf-lib的rgb函数) function hexToRgb(hex) { const r = parseInt(hex.slice(1, 3), 16) / 255; const g = parseInt(hex.slice(3, 5), 16) / 255; const b = parseInt(hex.slice(5, 7), 16) / 255; return [r, g, b]; }
4. 关键坑点提醒
- 字体嵌入:如果用了自定义字体,一定要把字体文件嵌入到PDF里!不然AI/Corel打开时会自动替换成系统字体,排版直接乱掉。
pdf-lib支持加载字体的ArrayBuffer来嵌入,只需要把字体文件读成ArrayBuffer传入embedFont即可。 - 渐变填充:FabricJS的渐变需要转换成PDF的渐变指令,
pdf-lib支持线性和径向渐变,需要手动映射渐变的起止点、颜色停止值。 - 组对象处理:FabricJS的组对象可以遍历它的
_objects属性,把组里的子对象单独提取出来处理,或者应用组的整体变换后再绘制子对象。 - 导出区域精度:判断对象是否在导出区域内时,要用FabricJS的
intersectsWithRect方法,确保不会漏掉边缘对象。
5. 验证效果
导出PDF后,直接用AI或Corel打开,检查每个元素:路径能拖动锚点修改,文本能双击编辑内容,形状能调整尺寸——这样就说明导出的是真正可编辑的矢量PDF了。
偷懒方案
如果不想手动写转换逻辑,可以试试fabricjs-pdf这个第三方库,它封装了FabricJS到PDF的矢量转换,但注意要选支持矢量导出的版本,部分旧版本可能还是会转成位图。
内容的提问来源于stack exchange,提问作者yavona




