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:
- Figma imports SVGs but doesn't reliably process embedded @font-face rules. If the font isn't installed on the system or available in Figma's font library, it falls back.
- Illustrator has similar issues. It can sometimes pick up embedded fonts, but behavior varies across versions and configurations.
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'23const FONT_URL = 'https://example.com/fonts/custom-font.woff'4let cachedFont: opentype.Font | null = null56const loadFont = async (): Promise<opentype.Font | null> => {7 if (cachedFont) return cachedFont89 try {10 const response = await fetch(FONT_URL)11 const arrayBuffer = await response.arrayBuffer()12 cachedFont = opentype.parse(arrayBuffer)13 return cachedFont14 } catch {15 return null16 }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}56const containsOnlyAscii = (text: string): boolean => {7 return /^[\x20-\x7E]*$/.test(text)8}910const 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 null78 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'1314 // Calculate text width for alignment15 const textWidth = font.getAdvanceWidth(text, fontSize)1617 // Adjust x position based on text-anchor18 let adjustedX = x19 if (textAnchor === 'middle') {20 adjustedX = x - textWidth / 221 } else if (textAnchor === 'end') {22 adjustedX = x - textWidth23 }2425 // Generate path data26 const path = font.getPath(text, adjustedX, y, fontSize)27 const pathData = path.toPathData(2) // 2 decimal places2829 // Create SVG path element30 const pathElement = document.createElementNS(31 'http://www.w3.org/2000/svg',32 'path',33 )34 pathElement.setAttribute('d', pathData)35 pathElement.setAttribute('fill', fill)3637 return pathElement38}
Key details:
font.getAdvanceWidth()returns the rendered width of the text, which we need fortext-anchoralignmentfont.getPath()generates an opentype.js Path object with all the glyph outlinespath.toPathData(2)converts it to an SVG pathdattribute with 2 decimal precision
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 SVGSVGElement67 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 }1920 const serializer = new XMLSerializer()21 const svgString = serializer.serializeToString(clonedSvg)22 const blob = new Blob([svgString], { type: 'image/svg+xml' })2324 // Download...25}
Trade-offs
Converting text to paths has consequences:
Pros:
- Universal compatibility. Works in any tool that supports SVG paths
- No font licensing issues. The font data isn't distributed, just the resulting shapes
- Predictable rendering. No font substitution surprises
Cons:
- Text is no longer editable. Designers can't click and retype
- File size increases. Path data is verbose compared to text
- Accessibility is lost. Screen readers can't parse path data as text
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.
