Building True Vector PDF Export in the Browser with fabric.js
https://yft.design/
https://github.com/dromara/yft-design

Most browser-based design editors can export a PDF by taking a screenshot of the canvas and wrapping that bitmap in a PDF file. That works for quick previews, but it falls apart as soon as users need print-ready output: text is no longer selectable, shapes blur when zoomed in, file sizes grow quickly, and CMYK workflows are basically out of reach.
In YFT Design Pro, we built a client-side PDF export pipeline on top of Fabric.js and Vue 3 that can generate real vector PDFs directly in the browser. It supports text-to-path conversion, SVG path rendering, gradients, CMYK output, font subsetting, multi-page documents, and asynchronous export through Web Workers.
This post walks through how the system is structured and the engineering problems we had to solve.
High-Level Architecture
The export pipeline has four main layers:
User clicks "Export PDF"
|
v
useCanvasExport
orchestration layer
|
+--------------------+
| |
v v
Raster PDF Vector PDF
colorSpace = 0 colorSpace >= 1
| |
v v
Canvas -> JPEG PdfGenerator
embedded in PDF running in Web Worker
|
v
@yft-design/pdf-lib
custom pdf-lib fork
The responsibilities are split like this:
src/hooks/useCanvasExport.ts
Handles the export entry point, chooses the export path, starts the Worker, and reports progress.src/extension/export/pdf/
ContainsPdfGenerator, which walks through Fabric.js objects and converts them into native PDF drawing operations.packages/pdf-lib/
A fork ofpdf-libwith additional support for SVG path parsing, gradient shadings, CMYK helpers, and lower-level PDF operators.src/worker/
Runs the expensive PDF generation work outside the main thread so the editor UI stays responsive.
Why There Are Two Export Paths
YFT Design Pro supports both raster PDF export and vector PDF export.
Raster PDF
When colorSpace = 0, each page is rendered to a 300 DPI JPEG and embedded into a PDF page:
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,
})
}
This path is simple and fast. It is useful when users only need a visual PDF preview.
The tradeoff is obvious: the whole page is a bitmap. Text cannot be selected, vector shapes lose quality when zoomed, and the PDF is not suitable for professional print workflows.
Vector PDF
When colorSpace >= 1, every object on the Fabric.js canvas is traversed and rendered as native PDF content:
- Text can be drawn using embedded fonts or converted into vector paths.
- Shapes become PDF path operators.
- SVGs are parsed and rendered recursively.
- Images are embedded as image XObjects.
- Gradients become PDF shading dictionaries.
- RGB or CMYK color spaces can be selected depending on the export mode.
This path is slower, but it produces a real vector PDF.
Coordinate Conversion
Coordinate conversion is one of the first places where browser-to-PDF export gets tricky.
Fabric.js uses a screen coordinate system:
- Origin at the top-left corner
- Y axis goes downward
- Units are canvas pixels
PDF uses a page coordinate system:
- Origin at the bottom-left corner
- Y axis goes upward
- Units are points, where 1 inch = 72 points
The basic pixel-to-point conversion is:
private px2pt(px: number, dpi: number = 300): number {
return (px * 72) / dpi
}
At 300 DPI, a 300 px object becomes 72 pt in the PDF, which is exactly 1 inch.
The Y axis also needs to be flipped:
let baseY = this.px2pt(pageHeight - element.top)
let pdfY = baseY + this.px2pt(element.height * scaleY - localY * scaleY)
This sounds small, but it affects every feature: text baselines, grouped objects, SVGs, masks, rotations, gradients, and nested transforms.
Tracking the Current Transformation Matrix
PDF has a graphics state stack and a current transformation matrix, usually called the CTM. pdf-lib does not expose enough of that internal state for our use case, so PdfGenerator tracks its own CTM in parallel with the PDF content stream.
private currentCTM: [number, number, number, number, number, number]
private ctmStack: Array<[number, number, number, number, number, number]> = []
pushGraphicsState(page) {
this.ctmStack.push([...this.currentCTM])
page.pushOperators(pushGraphicsState())
}
popGraphicsState(page) {
this.currentCTM = this.ctmStack.pop()!
page.pushOperators(popGraphicsState())
}
Every transform operation is multiplied into the tracked CTM. That lets us keep nested groups, rotated elements, and recursively parsed SVG nodes aligned with the actual PDF drawing state.
Converting SVG Paths to PDF Operators
A major part of the vector export pipeline is converting SVG path data into PDF drawing commands.
SVG path string
"M10 20 L30 40 C50 60 70 80 90 100 Z"
|
v
parse commands and parameters
|
v
map each command to PDF operators
|
v
PDF content stream
"10 20 m 30 40 l 50 60 70 80 90 100 c h f"
The custom svgPath.ts implementation in our pdf-lib fork maps SVG commands to PDF operators:
| SVG command | Meaning | PDF operator |
|---|---|---|
M/m |
Move to | m |
L/l |
Line to | l |
H/h |
Horizontal line | l |
V/v |
Vertical line | l |
C/c |
Cubic Bezier | c |
S/s |
Smooth cubic Bezier |
c with reflected control point |
Q/q |
Quadratic Bezier | quadratic helper operator |
A/a |
Elliptical arc | converted to cubic Bezier curves |
Z/z |
Close path | h |
The hardest command is A/a, the SVG elliptical arc. PDF does not have a native arc operator with the same semantics, so each arc is approximated as one or more cubic Bezier segments.
The implementation follows the same general approach used by tools such as Inkscape's SVG-to-PDF conversion:
function arcToSegments(rx, ry, rotation, largeArc, sweep, x1, y1, x2, y2) {
// Compute center, start angle, and angle delta.
// Split the arc into segments no larger than 90 degrees.
}
function segmentToBezier(cx, cy, th1, th2, rx, ry, sinPhi, cosPhi) {
const t = 4 / 3 * Math.tan((th2 - th1) / 4)
// Return cubic Bezier control points and endpoint.
}
The forked library exposes this as a higher-level API:
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,
})
Internally, it handles translation, rotation, scaling, and the SVG-to-PDF Y-axis flip.
Text: Embedded Fonts vs Text-to-Path
Text export has two modes.
In normal font mode, text is drawn with an embedded PDF font:
page.drawText(text, {
font: pdfFont,
size: fontSize,
color: fillColor,
})
In text-to-path mode, each glyph is converted into an SVG path and drawn as vector outlines:
const textPath = font.getPath(char, 0, 0, fontSize)
const svgPath = textPath.toPathData(2)
page.drawSvgPath(svgPath, {
x,
y,
color: fillColor,
})
Text-to-path is useful for print and template workflows where the exported PDF must look identical on every machine, even if the recipient does not have the original fonts installed.
The downside is that the resulting text is no longer real text in the PDF. It cannot be selected or searched. File size also increases because every glyph outline becomes drawing data.
For stroked text, we draw the outline first and then draw the fill on top:
page.drawSvgPath(svgPath, {
borderColor: strokeColor,
borderWidth: strokeWidth * 2,
})
page.drawSvgPath(svgPath, {
color: fillColor,
})
Fake bold can be approximated by adding a small stroke around the glyph outline:
const boldStrokeWidth = fontSize * 0.05
page.drawSvgPath(svgPath, {
color: fillColor,
borderColor: fillColor,
borderWidth: boldStrokeWidth,
})
Italic text is handled through matrix transforms:
const italicSkew = -Math.tan(15 * Math.PI / 180)
page.pushOperators(
setTextMatrix(1, 0, italicSkew, 1, x, y),
showText(text),
)
Font Subsetting
When fonts are embedded directly, the PDF does not need the full font file. It only needs the glyphs used in the document.
Our pdf-lib fork supports subsetting through embedFont:
const pdfFont = await pdfDoc.embedFont(fontBuffer, {
subset: true,
customName: fontFamily,
})
During text encoding, each used glyph is recorded:
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)
}
return PDFHexString.of(hexGlyphIds)
}
When the PDF is serialized, only the used glyphs are written:
serializeFont() {
return this.subset.encode()
}
This keeps vector PDFs much smaller, especially for CJK fonts.
For SVG export, font subsetting is handled on the server side with Python fonttools, then injected back into the SVG as WOFF @font-face rules.
Object Rendering Strategy
PdfGenerator dispatches each Fabric.js object to a dedicated handler:
| Fabric.js object | Handler | PDF output |
|---|---|---|
image, barcode, qrcode
|
handleImage() |
Embedded JPEG/PNG image XObject |
textbox |
handleTextbox() |
drawText or drawSvgPath
|
arctext |
handleArctext() |
Per-character positioning and rotation |
waisttext, flagtext
|
handleWarpedText() |
Rasterized PNG fallback |
rect |
handleRect() |
Rectangle path, including rounded corners |
circle |
handleCircle() |
Ellipse path |
triangle |
handleTriangle() |
Triangle path |
polygon, polyline, line, arrow
|
handleLine() |
Move/line operators, plus arrowhead paths |
path |
handlePath() |
SVG path rendered directly |
group, puzzle, table
|
handleGroup() |
Nested transforms plus recursive rendering |
mask |
handleMask() |
Offscreen canvas composition embedded as PNG |
svg |
handleSvg() |
Recursive SVG node parsing |
Groups are especially sensitive because every child inherits the group's translation, rotation, and scale:
handleGroup(group, page, pageHeight) {
this.pushGraphicsState(page)
this.transformOperator(page, 1, 0, 0, 1, left + width / 2, top + height / 2)
this.transformOperator(page, cos, sin, -sin, cos, 0, 0)
this.transformOperator(page, scaleX, 0, 0, scaleY, 0, 0)
this.transformOperator(page, 1, 0, 0, 1, -width / 2, -height / 2)
for (const child of group.objects) {
this.handleObjects(child, page, pageHeight)
}
this.popGraphicsState(page)
}
Getting this right is what makes nested layouts export correctly.
Gradients
Fabric.js gradients are converted into native PDF shadings.
Linear gradients use PDF Shading Type 2:
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],
})
Radial gradients use PDF Shading Type 3:
const shading = pdfDoc.context.obj({
Type: 'Shading',
ShadingType: 3,
Coords: [cx1, cy1, r1, cx2, cy2, r2],
Function: buildStitchedColorFunction(stops),
})
For multiple color stops, the exporter builds a Type 3 stitched function. Each segment is a Type 2 interpolation function:
buildStitchedColorFunction(stops) {
if (stops.length === 1) {
return { FunctionType: 2, Domain: [0, 1], C0: color, C1: color }
}
if (stops.length === 2) {
return { FunctionType: 2, Domain: [0, 1], C0: color1, C1: color2 }
}
return {
FunctionType: 3,
Domain: [0, 1],
Functions: stops.map(pair => type2Function(pair)),
Bounds: internalStopPositions,
Encode: stops.map(() => [0, 1]),
}
}
Gradient resources are collected during rendering and then injected into each page's resource dictionary in one batch.
CMYK Output
For print-oriented exports, colorSpace = 2 switches colors to CMYK:
private convertColor(color: string): { r, g, b } | { c, m, y, k } {
if (this.colorSpace === 2) {
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 }
}
In CMYK mode, fills, strokes, and gradient functions all use four-component color values, and the PDF shading dictionaries use DeviceCMYK.
Moving Export Work into a Web Worker
Generating a vector PDF is CPU-heavy. Doing it on the main thread can freeze the editor, especially for multi-page designs with many text objects and SVG paths.
The export flow uses a one-off Web Worker:
Main thread Worker
----------- ------
handleExportPdfByWorker()
|
+-- new Worker(pdf.worker.ts)
|
+-- postMessage({
| type: 'CREATE_PDF',
| requestId,
| templateData,
| colorSpace,
| })
onmessage
|
+-- loadGenerator()
| dynamic import
|
+-- new PdfGenerator()
+-- generator.createPdf()
| preload fonts
| preload images
| render objects
|
PDF_PROGRESS <------------------------+
PDF_DONE <------------------------+
PDF_ERROR <------------------------+
Important details:
- A fresh Worker is created for each export and terminated when the job finishes.
-
PdfGeneratoris lazy-loaded inside the Worker to avoid increasing initial app startup cost. - Each request has a
requestId, so concurrent or stale responses can be ignored safely. - If Worker export fails, the system falls back to main-thread export.
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)
}
The Worker environment also needs a few shims because some Fabric.js code expects DOM-like APIs:
class WorkerStyleDeclaration {
// Minimal CSSStyleDeclaration replacement.
}
import { parseXmlString } from 'txml'
setEnv({ isNode: true })
Export Context Isolation
The exporter loads Fabric.js JSON through a separate FabricStatic canvas and sets an internal export flag while deserializing:
let _isExportContext = false
export class FabricStatic extends StaticCanvas {
loadFromJSON(json, reviver) {
_isExportContext = true
return super.loadFromJSON(json, reviver).finally(() => {
_isExportContext = false
})
}
}
Other parts of the editor check this flag to adjust behavior during export:
- Skip UI-only rendering logic.
- Disable object caching for maximum output quality.
- Hide guides and helper elements.
- Skip animations.
This keeps the export pipeline isolated from interactive editor behavior.
Performance Optimizations
Before rendering, the exporter recursively collects all image URLs and font families, then preloads them in parallel:
const allImages = collectImageUrls(template)
const allFonts = collectFontFamilies(template)
await Promise.all([
...allImages.map(url => preloadImage(url)),
...allFonts.map(family => preloadFont(family)),
])
Images and fonts are cached so the same resource is only embedded once:
private imageCache = new Map<string, PDFImage>()
private pdfFontCache = new Map<string, PDFFont>()
For multi-page documents, pages can be rendered in parallel while keeping CTM state isolated per page:
const pagePromises = templates.map((template, index) => {
return Promise.resolve().then(() => {
this.ctmPerPage.set(page, initialCTM)
this.ctmStackPerPage.set(page, [])
this.handleObjects(template.objects, page, pageHeight)
})
})
await Promise.all(pagePromises)
The exporter also records timing information for each phase:
type PdfExportTimings = {
preloadMs: number
drawMs: number
saveMs: number
totalMs: number
bytes: number
}
That makes it easier to tell whether a slow export is caused by resource loading, object rendering, or final PDF serialization.
Results
For a typical design containing text, shapes, images, and gradients, the difference looks roughly like this:
| Metric | Raster PDF | Vector PDF |
|---|---|---|
| File size | around 2.5 MB | around 800 KB |
| Selectable text | No | Yes, unless text-to-path is enabled |
| Infinite zoom without blur | No | Yes |
| Export time | around 1 second | around 3-5 seconds |
| CMYK print workflow | No | Yes |
When text-to-path is enabled, file size usually increases by about 30-50%, but the exported PDF becomes independent of installed fonts.
Takeaways
Building real vector PDF export in the browser is possible, but the difficult parts are not the PDF file creation itself. The hard parts are all the details around visual fidelity:
- Matching Fabric.js coordinates to PDF coordinates.
- Tracking CTM state through nested transforms.
- Converting SVG paths, especially elliptical arcs.
- Handling text as either fonts or glyph outlines.
- Supporting gradients and CMYK color spaces.
- Keeping export work off the main thread.
- Avoiding duplicated resources in multi-page documents.
For YFT Design Pro, this approach gives us browser-only PDF export that is much closer to what users expect from a desktop design tool: smaller files, crisp vector output, optional font-safe text outlines, and print-oriented CMYK support.
YFT Design Pro is an open-source online design editor built with Fabric.js and Vue 3. It supports multi-page editing, vector PDF export, SVG export, and print-focused workflows.
Top comments (0)