惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

A
About on SuperTechFans
C
Cybersecurity and Infrastructure Security Agency CISA
N
News and Events Feed by Topic
C
Cisco Blogs
Cisco Talos Blog
Cisco Talos Blog
A
Arctic Wolf
Scott Helme
Scott Helme
P
Palo Alto Networks Blog
S
Schneier on Security
D
Darknet – Hacking Tools, Hacker News & Cyber Security
T
Tor Project blog
量子位
G
Google Developers Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
B
Blog RSS Feed
NISL@THU
NISL@THU
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
AWS News Blog
AWS News Blog
爱范儿
爱范儿
Last Week in AI
Last Week in AI
Y
Y Combinator Blog
L
LINUX DO - 最新话题
Security Archives - TechRepublic
Security Archives - TechRepublic
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
S
Secure Thoughts
Cloudbric
Cloudbric
aimingoo的专栏
aimingoo的专栏
L
Lohrmann on Cybersecurity
TaoSecurity Blog
TaoSecurity Blog
Recent Commits to openclaw:main
Recent Commits to openclaw:main
Hacker News: Ask HN
Hacker News: Ask HN
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
The GitHub Blog
The GitHub Blog
有赞技术团队
有赞技术团队
S
Security @ Cisco Blogs
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
C
Cyber Attacks, Cyber Crime and Cyber Security
G
GRAHAM CLULEY
P
Proofpoint News Feed
V
V2EX
Martin Fowler
Martin Fowler
C
CERT Recently Published Vulnerability Notes
Attack and Defense Labs
Attack and Defense Labs
C
CXSECURITY Database RSS Feed - CXSecurity.com
The Cloudflare Blog
SecWiki News
SecWiki News
罗磊的独立博客
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
小众软件
小众软件
The Last Watchdog
The Last Watchdog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Building True Vector PDF Export in the Browser with fabric.js
morestrive · 2026-05-19 · via DEV Community

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.