DEV Community

morestrive
morestrive

Posted on

Building True Vector PDF Export in the Browser with fabric.js

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
Enter fullscreen mode Exit fullscreen mode

The responsibilities are split like this:

  1. src/hooks/useCanvasExport.ts
    Handles the export entry point, chooses the export path, starts the Worker, and reports progress.

  2. src/extension/export/pdf/
    Contains PdfGenerator, which walks through Fabric.js objects and converts them into native PDF drawing operations.

  3. packages/pdf-lib/
    A fork of pdf-lib with additional support for SVG path parsing, gradient shadings, CMYK helpers, and lower-level PDF operators.

  4. 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,
  })
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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),
)
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

When the PDF is serialized, only the used glyphs are written:

serializeFont() {
  return this.subset.encode()
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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],
})
Enter fullscreen mode Exit fullscreen mode

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),
})
Enter fullscreen mode Exit fullscreen mode

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]),
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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    <------------------------+
Enter fullscreen mode Exit fullscreen mode

Important details:

  • A fresh Worker is created for each export and terminated when the job finishes.
  • PdfGenerator is 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)
}
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

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
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

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)),
])
Enter fullscreen mode Exit fullscreen mode

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>()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

The exporter also records timing information for each phase:

type PdfExportTimings = {
  preloadMs: number
  drawMs: number
  saveMs: number
  totalMs: number
  bytes: number
}
Enter fullscreen mode Exit fullscreen mode

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)