Logo

SVG Custom Fonts in Figma/Illustrator: Converting Text to Paths with opentype.js

5 min read
Next.jsopentype.js

Table of Contents

The Problem

In my previous article, I described building a chart generator that exports SVG files. One solution I mentioned was embedding fonts as base64 data URLs to avoid canvas tainting during PNG export:

1const styleElement = document.createElementNS(
2 'http://www.w3.org/2000/svg',
3 'style',
4)
5styleElement.textContent = `
6 @font-face {
7 font-family: 'CustomFont';
8 src: url('${fontBase64}') format('woff');
9 }
10`

This works perfectly in browsers. The SVG renders with the correct font, and PNG exports work because the font is embedded rather than loaded from an external source.

But when designers opened these SVGs in Figma or Adobe Illustrator, the custom font was gone. The text fell back to a system font, breaking the visual consistency.

Why @font-face Doesn't Work in Design Tools

Browsers are runtime environments. They parse CSS, fetch fonts (or decode embedded base64), and render text dynamically. Design tools like Figma and Illustrator are fundamentally different. They need to work with the file as a static document.

While both tools technically support embedded fonts in some contexts, the reality is messy:

The reliable solution? Don't embed fonts at all. Convert text to paths.

The Solution: opentype.js

opentype.js is a JavaScript parser for TrueType and OpenType fonts. It can read font files, access glyph data, and generate SVG path data for any string of text.

Instead of:

1<text font-family="CustomFont" x="100" y="50">Revenue</text>

We generate:

1<path d="M10.2 0L12.4 8.2L20.8 8.2L14.1 13.2..." fill="#000000" />

The text becomes a series of bezier curves. No font reference needed. The SVG looks identical everywhere because there's nothing to interpret—it's just geometry.

Implementation

Here's how I implemented it:

Loading the Font

First, fetch and parse the font file:

1import opentype from 'opentype.js'
2
3const FONT_URL = 'https://example.com/fonts/custom-font.woff'
4let cachedFont: opentype.Font | null = null
5
6const loadFont = async (): Promise<opentype.Font | null> => {
7 if (cachedFont) return cachedFont
8
9 try {
10 const response = await fetch(FONT_URL)
11 const arrayBuffer = await response.arrayBuffer()
12 cachedFont = opentype.parse(arrayBuffer)
13 return cachedFont
14 } catch {
15 return null
16 }
17}

The font is cached after the first load to avoid redundant network requests.

Deciding What to Convert

Not all text needs conversion. I only convert text that uses the custom font and contains ASCII characters (Japanese characters weren't supported by my specific font):

1const usesCustomFont = (textElement: SVGTextElement): boolean => {
2 const fontFamily = textElement.getAttribute('font-family') || ''
3 return fontFamily.includes('CustomFont')
4}
5
6const containsOnlyAscii = (text: string): boolean => {
7 return /^[\x20-\x7E]*$/.test(text)
8}
9
10const shouldConvertToPath = (textElement: SVGTextElement): boolean => {
11 const text = textElement.textContent || ''
12 return usesCustomFont(textElement) && containsOnlyAscii(text)
13}

Converting Text to Path

The core conversion function handles font size, positioning, and text alignment:

1const convertTextToPath = (
2 textElement: SVGTextElement,
3 font: opentype.Font,
4): SVGPathElement | null => {
5 const text = textElement.textContent || ''
6 if (!text.trim()) return null
7
8 const fontSize = parseFloat(textElement.getAttribute('font-size') || '12')
9 const x = parseFloat(textElement.getAttribute('x') || '0')
10 const y = parseFloat(textElement.getAttribute('y') || '0')
11 const fill = textElement.getAttribute('fill') || '#000000'
12 const textAnchor = textElement.getAttribute('text-anchor') || 'start'
13
14 // Calculate text width for alignment
15 const textWidth = font.getAdvanceWidth(text, fontSize)
16
17 // Adjust x position based on text-anchor
18 let adjustedX = x
19 if (textAnchor === 'middle') {
20 adjustedX = x - textWidth / 2
21 } else if (textAnchor === 'end') {
22 adjustedX = x - textWidth
23 }
24
25 // Generate path data
26 const path = font.getPath(text, adjustedX, y, fontSize)
27 const pathData = path.toPathData(2) // 2 decimal places
28
29 // Create SVG path element
30 const pathElement = document.createElementNS(
31 'http://www.w3.org/2000/svg',
32 'path',
33 )
34 pathElement.setAttribute('d', pathData)
35 pathElement.setAttribute('fill', fill)
36
37 return pathElement
38}

Key details:

Putting It Together

The export function clones the SVG, converts eligible text elements, and downloads:

1export const downloadSVG = async (
2 svgElement: SVGSVGElement,
3 filename: string = 'chart.svg',
4) => {
5 const clonedSvg = svgElement.cloneNode(true) as SVGSVGElement
6
7 const font = await loadFont()
8 if (font) {
9 const textElements = clonedSvg.querySelectorAll('text')
10 textElements.forEach((textEl) => {
11 if (shouldConvertToPath(textEl as SVGTextElement)) {
12 const pathElement = convertTextToPath(textEl as SVGTextElement, font)
13 if (pathElement && textEl.parentNode) {
14 textEl.parentNode.replaceChild(pathElement, textEl)
15 }
16 }
17 })
18 }
19
20 const serializer = new XMLSerializer()
21 const svgString = serializer.serializeToString(clonedSvg)
22 const blob = new Blob([svgString], { type: 'image/svg+xml' })
23
24 // Download...
25}

Trade-offs

Converting text to paths has consequences:

Pros:

Cons:

For my use case—generating charts for presentations—these trade-offs were acceptable. The charts are final output, not templates for further editing. File size increase was minimal for the short labels in charts.

Conclusion

If you're generating SVGs that need to work in design tools with custom fonts, embedded @font-face won't cut it. opentype.js provides a clean way to convert text to paths, ensuring your fonts render correctly everywhere.

The approach is surgical: load the font once, identify text elements that need conversion, replace them with path equivalents. The result is an SVG that looks identical in the browser, Figma, Illustrator, and anywhere else.

Related Articles