基于 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) │
└─────────────────┘
系统分为四层:
- 编排层 (
src/hooks/useCanvasExport.ts) --- 导出入口,负责选择导出路径、调度 Worker、进度回调 - 生成层 (
src/extension/export/pdf/) ---PdfGenerator类,遍历画布对象并逐个绘制为 PDF 原生操作符 - 底层库 (
packages/pdf-lib/) --- fork 自 pdf-lib,扩展了 SVG 路径解析、渐变着色等能力 - 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 块
}
元素类型与渲染方式
PdfGenerator 的 handleObjects() 方法根据元素类型分发到不同的处理器:
| 元素类型 | 处理方法 | PDF 渲染方式 |
|---|---|---|
| image / barcode / qrcode | handleImage() |
嵌入为 JPEG/PNG 图像 XObject |
| textbox | handleTextbox() |
drawText 或 drawSvgPath(转曲模式) |
| 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()
关键设计
- 一次性 Worker:每次导出创建新 Worker,完成后立即销毁,避免内存泄漏
- 懒加载 :Worker 内部使用动态
import()加载PdfGenerator,避免初始加载大模块 - requestId 关联 :使用
nanoid(12)生成请求 ID,防止多个导出请求交叉响应 - 主线程回退:如果 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 导出的核心挑战在于:
- 坐标系转换 --- fabric.js 屏幕坐标与 PDF 坐标的 Y 轴翻转、DPI 换算需要精确一致
- SVG 路径解析 --- 需要完整实现 SVG path 规范到 PDF 操作符的映射,特别是椭圆弧的贝塞尔近似
- 文字转曲 --- 通过 opentype.js 提取字形轮廓,解决字体兼容性问题
- CTM 状态管理 --- 手动维护变换矩阵栈,确保嵌套组的坐标变换正确
- 异步与性能 --- Web Worker 避免阻塞 UI,预加载和去重减少网络请求
YFT Design Pro 的实现证明,纯浏览器端完全可以实现生产级的矢量 PDF 导出,且在功能完整性上不逊于桌面端设计软件的导出能力。
YFT Design Pro 是一个基于 fabric.js + Vue 3 的在线设计编辑器,支持多页编辑、矢量 PDF 导出、SVG 导出、CMYK 印刷等功能。项目开源在 GitHub,欢迎 Star 和贡献。