基于 fabric.js 实现浏览器端矢量 PDF 导出

基于 fabric.js 实现浏览器端矢量 PDF 导出

yft.design/
github.com/dromara/yft...

前言

在 Web 端实现设计编辑器已经是常见的需求,但要在浏览器中导出矢量 PDF(而非简单地将画布光栅化为图片再包装成 PDF)仍然是一个有挑战性的工程问题。YFT Design Pro 基于 fabric.js + Vue 3 构建了一套完整的矢量 PDF 导出方案,支持文字转曲、SVG 路径绘制、渐变填充、CMYK 色彩空间、字体子集化等能力,并利用 Web Worker 实现异步导出不阻塞 UI。

本文将深入分析这套方案的架构设计与核心实现细节。

整体架构

perl 复制代码
┌─────────────────────────────────────────────────┐
│                  用户点击"导出 PDF"                 │
└──────────────────────┬──────────────────────────┘
                       │
              ┌────────▼────────┐
              │ useCanvasExport  │  编排层
              │   (调度入口)      │
              └────────┬────────┘
                       │
           ┌───────────┴───────────┐
           │                       │
    ┌──────▼──────┐        ┌──────▼──────┐
    │ colorSpace=0│        │ colorSpace≥1│
    │ 图片型 PDF   │        │ 矢量 PDF     │
    └──────┬──────┘        └──────┬──────┘
           │                       │
    ┌──────▼──────┐        ┌──────▼──────┐
    │ Canvas→JPEG │        │ PdfGenerator │
    │ 嵌入 PDF 页  │        │ (Web Worker) │
    └─────────────┘        └──────┬──────┘
                                   │
                          ┌────────▼────────┐
                          │ @yft-design/     │
                          │ pdf-lib (fork)   │
                          └─────────────────┘

系统分为四层:

  1. 编排层 (src/hooks/useCanvasExport.ts) --- 导出入口,负责选择导出路径、调度 Worker、进度回调
  2. 生成层 (src/extension/export/pdf/) --- PdfGenerator 类,遍历画布对象并逐个绘制为 PDF 原生操作符
  3. 底层库 (packages/pdf-lib/) --- fork 自 pdf-lib,扩展了 SVG 路径解析、渐变着色等能力
  4. Worker 层 (src/worker/) --- 将耗时的 PDF 生成移入 Web Worker,避免阻塞主线程

双路径导出策略

根据用户选择的色彩空间,系统走两条完全不同的路径:

图片型 PDF(colorSpace = 0)

将画布以 300 DPI 光栅化为 JPEG,再嵌入 PDF 页面。流程简单,速度快,但输出的是位图 PDF:

typescript 复制代码
// 核心逻辑:逐页光栅化
for (const template of templates) {
  const dataUrl = canvas.toDataURL({ format: 'jpeg', quality: 0.95, multiplier: dpi / 96 })
  const imageBytes = dataUrlToBytes(dataUrl)
  const pdfImage = await pdfDoc.embedJpg(imageBytes)
  const page = pdfDoc.addPage([pageWidth, pageHeight])
  page.drawImage(pdfImage, { x: 0, y: 0, width: pageWidth, height: pageHeight })
}

矢量 PDF(colorSpace ≥ 1)

遍历画布上的每一个对象,根据类型分发到对应的绘制方法,直接输出 PDF 矢量操作符。支持 RGB 和 CMYK 色彩空间。

坐标系转换

这是矢量导出中最基础也最容易出错的部分。Fabric.js 使用屏幕坐标系(原点左上角,Y 轴向下),而 PDF 使用数学坐标系(原点左下角,Y 轴向上)。

像素到 PDF 点的换算

typescript 复制代码
private px2pt(px: number, dpi: number = 300): number {
  return (px * 72) / dpi  // 300 DPI 像素 → 72 DPI PDF 点
}

PDF 标准使用 72 点/英寸,画布设计稿通常以 300 DPI 为基准。一个 300px 的元素在 PDF 中占 72pt(1 英寸)。

Y 轴翻转

typescript 复制代码
// Fabric.js 坐标 → PDF 坐标
let baseY = this.px2pt(pageHeight - element.top)  // 翻转 Y
// 再加上元素内部偏移
let pdfY = baseY + this.px2pt(element.height * scaleY - localY * scaleY)

CTM(当前变换矩阵)跟踪

PdfGenerator 维护了一套独立的 CTM 状态,镜像 PDF 内容流的图形状态栈。因为 pdf-lib 不暴露底层 CTM,我们需要自己追踪:

typescript 复制代码
private currentCTM: [number, number, number, number, number, number]  // [a,b,c,d,e,f]
private ctmStack: Array<[number, number, number, number, number, number]> = []

// PDF 的 q/Q 操作符对应压栈/出栈
pushGraphicsState(page) {
  this.ctmStack.push([...this.currentCTM])
  page.pushOperators(pushGraphicsState())
}

popGraphicsState(page) {
  this.currentCTM = this.ctmStack.pop()!
  page.pushOperators(popGraphicsState())
}

每次 transformOperator 调用都会将新矩阵乘到当前 CTM 上,确保后续的坐标计算始终正确。

SVG 路径到 PDF 操作符的转换

这是矢量导出的核心能力------将 SVG 路径数据转换为 PDF 原生绘制指令。

转换流程

scss 复制代码
SVG path string (如 "M10 20 L30 40 C50 60 70 80 90 100 Z")
        │
        ▼
parse() --- 词法分析,拆分为命令+参数
        │
        ▼
apply() --- 逐命令生成 PDFOperator[]
        │
        ▼
PDF Content Stream (m 10 20 l 30 40 c 50 60 70 80 90 100 h f)

命令映射表

packages/pdf-lib/src/api/svgPath.ts 中,每个 SVG 命令都映射到对应的 PDF 操作符:

SVG 命令 含义 PDF 操作符
M/m moveTo m (moveTo)
L/l lineTo l (lineTo)
H/h 水平线 l (仅 X 变化)
V/v 垂直线 l (仅 Y 变化)
C/c 三次贝塞尔曲线 c (appendBezierCurve)
S/s 平滑三次贝塞尔 c (计算反射控制点)
Q/q 二次贝塞尔曲线 v (appendQuadraticCurve)
A/a 椭圆弧 c (转换为多段三次贝塞尔)

椭圆弧的特殊处理

SVG 的椭圆弧(Arc)命令无法直接映射到 PDF 操作符,需要先转换为三次贝塞尔曲线近似。这使用了源自 Inkscape 的 svgtopdf 算法:

typescript 复制代码
// 将椭圆弧分解为角度段
function arcToSegments(rx, ry, rotation, largeArc, sweep, x1, y1, x2, y2) {
  // ... 计算圆心、起始角、角度增量
  // 将弧分割为不超过 90° 的段
}

// 每段用三次贝塞尔曲线近似
function segmentToBezier(cx, cy, th1, th2, rx, ry, sinPhi, cosPhi) {
  // 使用参数公式计算控制点
  const t = 4/3 * Math.tan((th2 - th1) / 4)
  // ... 返回 [cp1x, cp1y, cp2x, cp2y, x, y]
}

drawSvgPath 封装

pdf-lib 的 fork 版本提供了 drawSvgPath 高级 API,封装了坐标变换和绘制:

typescript 复制代码
page.drawSvgPath(svgPathData, {
  x: elementX,
  y: elementY,
  scale: scaleFactor,
  rotate: degrees(angle),
  color: fillColor ? rgb(r, g, b) : undefined,
  borderColor: strokeColor ? rgb(r, g, b) : undefined,
  borderWidth: strokeWidth,
})

内部会自动处理 Y 轴翻转(scale(1, -1))、旋转和平移。

文字转曲(Text to Path)

"文字转曲"是将文本字符转换为矢量路径的过程,导出的 PDF 中不再包含字体信息,而是以路径形式绘制每个字符。这确保了在任何 PDF 阅读器中文字都能正确显示,不依赖字体安装。

双路径设计

系统维护了两种文本渲染路径,通过 fontStore.pathCheck 标志切换:

typescript 复制代码
const usePath = this.fontStore.pathCheck && options.openfont && svgPath

if (usePath) {
  // 路径模式:每个字符转为 SVG path,用 drawSvgPath 绘制
  page.drawSvgPath(svgPath, { color: fillColor })
} else {
  // 字体模式:使用 PDF 字体对象直接绘制文本
  page.drawText(text, { font: pdfFont, size: fontSize, color: fillColor })
}

opentype.js 路径提取

文字转曲依赖 opentype.js 从字体文件中提取字符轮廓:

typescript 复制代码
// 1. 从 opentype Font 对象获取字形路径
const textPath = font.getPath(char, 0, 0, fontSize)
// 2. 转为 SVG path data 字符串
const svgPath = textPath.toPathData(2)  // 精度 2 位小数
// 3. 用 drawSvgPath 绘制到 PDF
page.drawSvgPath(svgPath, { x, y, color: fillColor })

效果描边的路径绘制

当文字带有描边效果时,需要分两步绘制------先描边后填充,确保填充层在上:

typescript 复制代码
if (usePath) {
  // 1. 描边层
  page.drawSvgPath(svgPath, {
    borderColor: strokeColor,
    borderWidth: strokeWidth * 2,  // 路径居中描边,需双倍宽度
  })
  // 2. 填充层(覆盖在描边上方)
  page.drawSvgPath(svgPath, { color: fillColor })
}

粗体和斜体的模拟

当文字转曲时,粗体通过增加描边宽度模拟,斜体通过文本矩阵变换实现:

typescript 复制代码
// 粗体:在字形轮廓外加描边
const boldStrokeWidth = fontSize * 0.05
page.drawSvgPath(svgPath, {
  color: fillColor,
  borderColor: fillColor,
  borderWidth: boldStrokeWidth,
})

// 斜体:修改文本矩阵,skew 角度约 15°
const italicSkew = -Math.tan(15 * Math.PI / 180)
page.pushOperators(
  setTextMatrix(1, 0, italicSkew, 1, x, y),
  showText(text),
)

字体子集化

PDF 文件中的字体不需要包含全部字符,只需嵌入文档中实际使用的字符,这就是字体子集化。

PDF 级别的子集化

在 fork 的 pdf-lib 中,embedFont 支持 subset: true 选项:

typescript 复制代码
const pdfFont = await pdfDoc.embedFont(fontBuffer, {
  subset: true,
  customName: fontFamily,
})

CustomFontSubsetEmbedder 会追踪每个被使用的字形(glyph),在最终序列化时只输出包含使用字形的精简字体:

typescript 复制代码
encodeText(text: string) {
  const glyphs = this.font.layout(text, fontFeatures)
  for (const glyph of glyphs) {
    this.subset.includeGlyph(glyph)  // 标记使用的字形
    const glyphId = this.glyphIdMap.get(glyph.id)  // 映射到子集 ID
  }
  return PDFHexString.of(hexGlyphIds)
}

serializeFont() {
  return this.subset.encode()  // 只输出被使用的字形
}

服务端子集化(SVG 导出用)

对于 SVG 导出场景,字体子集化通过服务端 API 完成,使用 Python fonttools:

typescript 复制代码
const fetchFontSubsets = async (fonts: string[], chars: string[]) => {
  // 调用后端 getFontSubset API
  // 返回 WOFF 格式的子集字体(仅包含指定字符)
  // 注入为 @font-face CSS 块
}

元素类型与渲染方式

PdfGeneratorhandleObjects() 方法根据元素类型分发到不同的处理器:

元素类型 处理方法 PDF 渲染方式
image / barcode / qrcode handleImage() 嵌入为 JPEG/PNG 图像 XObject
textbox handleTextbox() drawTextdrawSvgPath(转曲模式)
arctext handleArctext() 逐字符定位+旋转
waisttext / flagtext handleWarpedText() 光栅化为 PNG 后嵌入
rect handleRect() 矩形路径操作符,支持圆角
circle handleCircle() 椭圆路径操作符
triangle handleTriangle() 三角形路径操作符
polygon / polyline / line / arrow handleLine() moveTo/lineTo 操作符,箭头为三角形路径
path handlePath() drawSvgPath 直接绘制路径数据
group / puzzle / table handleGroup() 嵌套 CTM 变换 + 递归 handleObjects()
mask handleMask() 离屏 Canvas 合成后嵌入 PNG
svg handleSvg() 递归解析 SVG 节点为矢量操作符

组元素的递归处理

组(Group)是最复杂的类型之一,需要正确处理嵌套的坐标变换:

typescript 复制代码
handleGroup(group, page, pageHeight) {
  this.pushGraphicsState(page)

  // 1. 平移到组中心
  this.transformOperator(page, 1, 0, 0, 1, left + width/2, top + height/2)
  // 2. 旋转
  this.transformOperator(page, cos, sin, -sin, cos, 0, 0)
  // 3. 缩放
  this.transformOperator(page, scaleX, 0, 0, scaleY, 0, 0)
  // 4. 平移回左上角
  this.transformOperator(page, 1, 0, 0, 1, -width/2, -height/2)

  // 5. 递归处理子对象
  for (const child of group.objects) {
    this.handleObjects(child, page, pageHeight)
  }

  this.popGraphicsState(page)
}

渐变填充

PDF 原生支持渐变着色(Shading),系统将 Fabric.js 的渐变配置转换为 PDF Shading 字典。

线性渐变

typescript 复制代码
// PDF Shading Type 2 (Axial)
const shading = pdfDoc.context.obj({
  Type: 'Shading',
  ShadingType: 2,
  ColorSpace: colorSpace === 2 ? 'DeviceCMYK' : 'DeviceRGB',
  Coords: [x1, y1, x2, y2],  // 起止点
  Function: buildStitchedColorFunction(stops),  // 颜色插值函数
  Extend: [true, true],
})

径向渐变

typescript 复制代码
// PDF Shading Type 3 (Radial)
const shading = pdfDoc.context.obj({
  Type: 'Shading',
  ShadingType: 3,
  Coords: [cx1, cy1, r1, cx2, cy2, r2],  // 两个圆
  Function: buildStitchedColorFunction(stops),
})

颜色插值函数

多个色标(stops)通过 PDF Type 3 拼接函数实现平滑过渡:

typescript 复制代码
buildStitchedColorFunction(stops) {
  if (stops.length === 1) {
    // 单色:Type 2 函数,C0 = C1
    return { FunctionType: 2, Domain: [0, 1], C0: color, C1: color }
  }
  if (stops.length === 2) {
    // 两色:Type 2 函数,线性插值
    return { FunctionType: 2, Domain: [0, 1], C0: color1, C1: color2 }
  }
  // 多色:Type 3 拼接函数
  return {
    FunctionType: 3,
    Domain: [0, 1],
    Functions: stops.map(pair => type2Function(pair)),  // 每段一个 Type 2
    Bounds: internalStopPositions,
    Encode: stops.map(() => [0, 1]),
  }
}

CMYK 色彩空间支持

印刷场景需要 CMYK 色彩空间。系统在 colorSpace = 2 时将所有颜色转换为 CMYK:

typescript 复制代码
private convertColor(color: string): { r, g, b } | { c, m, y, k } {
  if (this.colorSpace === 2) {
    // RGB → CMYK 转换
    const k = 1 - Math.max(r, g, b)
    return {
      c: (1 - r - k) / (1 - k),
      m: (1 - g - k) / (1 - k),
      y: (1 - b - k) / (1 - k),
      k,
    }
  }
  return { r, g, b }
}

所有渐变函数和填充颜色在 CMYK 模式下都使用 4 分量颜色值,着色字典的 ColorSpace 设为 DeviceCMYK

Web Worker 异步导出

PDF 生成是 CPU 密集型操作,直接在主线程执行会导致 UI 冻结。系统利用 Web Worker 实现异步导出:

架构

scss 复制代码
主线程                              Worker 线程
──────                              ──────────
handleExportPdfByWorker()
  │
  ├─ new Worker(pdf.worker.ts)
  ├─ postMessage({                  onmessage
  │    type: 'CREATE_PDF',           │
  │    requestId,                    ├─ loadGenerator()
  │    templateData,                 │   └─ import('./PDFGenerator')  // 懒加载
  │    colorSpace,                   │
  │  })                              ├─ new PdfGenerator()
  │                                  ├─ generator.createPdf()
  │  PDF_PROGRESS ──► 更新进度条     │   ├─ preloadFonts()
  │                                  │   ├─ preloadImages()
  │  PDF_DONE ──► 下载文件           │   └─ handleObjects() × N
  │                                  │
  │  PDF_ERROR ──► 主线程回退        ├─ postMessage({ type: 'PDF_DONE', data })
  │                                  │
  ├─ worker.terminate()             worker.terminate()

关键设计

  1. 一次性 Worker:每次导出创建新 Worker,完成后立即销毁,避免内存泄漏
  2. 懒加载 :Worker 内部使用动态 import() 加载 PdfGenerator,避免初始加载大模块
  3. requestId 关联 :使用 nanoid(12) 生成请求 ID,防止多个导出请求交叉响应
  4. 主线程回退:如果 Worker 创建或执行失败,自动回退到主线程执行
typescript 复制代码
// Worker 回退机制
try {
  const result = await handleExportPdfByWorker(templateData, colorSpace)
  return result
} catch (error) {
  console.warn('Worker export failed, falling back to main thread')
  return await handleExportPdfMainThread(templateData, colorSpace)
}

Worker 内部的环境适配

Worker 中没有 DOM,Fabric.js 的某些功能依赖 DOM API。Worker 版本的 PDFGenerator 自带了环境 shim:

typescript 复制代码
// Worker 中的 DOM 替代
class WorkerStyleDeclaration {
  // 替代 CSSStyleDeclaration
}

// 使用 txml 替代 DOMParser
import { parseXmlString } from 'txml'

// 注入无头环境标记
setEnv({ isNode: true })

导出耗时追踪

系统记录了详细的分阶段耗时,便于性能优化:

typescript 复制代码
type PdfExportTimings = {
  preloadMs: number   // 字体 + 图片预加载
  drawMs: number      // 对象绘制为 PDF 操作符
  saveMs: number      // pdfDoc.save() 序列化
  totalMs: number     // 总耗时
  bytes: number       // 文件大小
}

导出上下文隔离

在导出过程中,系统使用 FabricStatic 加载画布 JSON,并通过 _isExportContext 全局标志修改反序列化行为:

typescript 复制代码
// src/app/fabricStatic.ts
let _isExportContext = false

export class FabricStatic extends StaticCanvas {
  loadFromJSON(json, reviver) {
    _isExportContext = true
    return super.loadFromJSON(json, reviver).finally(() => {
      _isExportContext = false
    })
  }
}

各处代码检查此标志以优化导出行为:

  • 跳过仅用于 UI 交互的渲染逻辑
  • 禁用对象缓存(objectCaching),确保最高渲染质量
  • 隐藏参考线等辅助元素
  • 跳过动画播放

性能优化策略

1. 预加载阶段

在绘制任何对象之前,先递归收集所有需要的资源(图片 URL、字体系列),然后并行加载:

typescript 复制代码
// 递归收集所有资源引用
const allImages = collectImageUrls(template)
const allFonts = collectFontFamilies(template)

// 并行预加载
await Promise.all([
  ...allImages.map(url => preloadImage(url)),
  ...allFonts.map(family => preloadFont(family)),
])

2. 资源去重

图片和字体都通过缓存 Map 去重,同一个资源只嵌入一次:

typescript 复制代码
private imageCache = new Map<string, PDFImage>()
private pdfFontCache = new Map<string, PDFFont>()

3. 多页并行

主版本使用 Promise.all 并行渲染多页,每页维护独立的 CTM 状态:

typescript 复制代码
const pagePromises = templates.map((template, index) => {
  return Promise.resolve().then(() => {
    // 每页独立的 CTM 状态
    this.ctmPerPage.set(page, initialCTM)
    this.ctmStackPerPage.set(page, [])
    // 渲染该页
    this.handleObjects(template.objects, page, pageHeight)
  })
})
await Promise.all(pagePromises)

4. 渐变资源批量注入

渐变 Shading 字典不在绘制时逐个添加,而是先收集,最后批量写入页面资源:

typescript 复制代码
// 收集阶段
this.pageGradients.get(page).push({ name, shadingRef })

// 批量注入
setPageGradientResources(page, this.pageGradients.get(page))

实际效果

以一个包含文字、形状、图片、渐变的典型设计稿为例:

指标 图片型 PDF 矢量 PDF
文件大小 ~2.5 MB ~800 KB
文字可选中复制
无限放大不失真
导出耗时 ~1s ~3-5s
CMYK 印刷

开启文字转曲后,文件体积会增大约 30%-50%,但完全消除了字体兼容性问题。

总结

在浏览器端实现矢量 PDF 导出的核心挑战在于:

  1. 坐标系转换 --- fabric.js 屏幕坐标与 PDF 坐标的 Y 轴翻转、DPI 换算需要精确一致
  2. SVG 路径解析 --- 需要完整实现 SVG path 规范到 PDF 操作符的映射,特别是椭圆弧的贝塞尔近似
  3. 文字转曲 --- 通过 opentype.js 提取字形轮廓,解决字体兼容性问题
  4. CTM 状态管理 --- 手动维护变换矩阵栈,确保嵌套组的坐标变换正确
  5. 异步与性能 --- Web Worker 避免阻塞 UI,预加载和去重减少网络请求

YFT Design Pro 的实现证明,纯浏览器端完全可以实现生产级的矢量 PDF 导出,且在功能完整性上不逊于桌面端设计软件的导出能力。


YFT Design Pro 是一个基于 fabric.js + Vue 3 的在线设计编辑器,支持多页编辑、矢量 PDF 导出、SVG 导出、CMYK 印刷等功能。项目开源在 GitHub,欢迎 Star 和贡献。

相关推荐
Bolt1 小时前
用 pnpm 11 省掉项目里的 .nvmrc 与 .npmrc
前端·npm·node.js
猪猪聪明_V2 小时前
前端码农的本地项目启动器
前端·javascript
时光不负努力2 小时前
每天一个高级前端知识 - Day 21
前端
暗不需求2 小时前
前端性能优化 防抖与节流完全指南:从原理到最佳实践
前端·javascript·面试
@大迁世界2 小时前
45.什么是内联条件表达式(inline conditional expressions)?在事件处理里怎么用?
开发语言·前端·javascript·react.js·ecmascript
一颗趴菜2 小时前
微信小程序如何去下载PDF呢
前端·javascript
KaMeidebaby2 小时前
卡梅德生物技术快报|细菌 FISH 实验 + 流式细胞术:尿路感染活菌快速定量系统实现与数据验证
前端·数据库·其他·百度·新浪微博
昆曲之源_娄江河畔2 小时前
DBGridEh Footer的使用
前端·数据库·delphi·dbgrideh
廖松洋(Alina)3 小时前
02数据模型与单词仓库-鸿蒙PC端Electron开发
前端·华为·electron·开源·harmonyos·鸿蒙