You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

基于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

火山引擎 最新活动