From 2efd63ea0956cfe05b5bc20832905def7d049f03 Mon Sep 17 00:00:00 2001 From: Shai Rubinstein Date: Mon, 16 Mar 2026 19:57:58 +0200 Subject: [PATCH] fix: normalize font family names on .fig export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip optical size suffixes (e.g. "DM Sans 9pt" → "DM Sans") and "Variable" suffixes when writing fontName.family to .fig files. This ensures Figma recognizes the font instead of showing a "Missing font" dialog. Closes #131 --- CHANGELOG.md | 1 + packages/core/src/fonts.ts | 2 +- packages/core/src/kiwi/kiwi-serialize.ts | 6 +- tests/engine/font-normalize-export.test.ts | 84 ++++++++++++++++++++++ tests/engine/fonts.test.ts | 17 +++++ 5 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 tests/engine/font-normalize-export.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b97550ff2..ad4031375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Fix imported text rendering in browser and headless export — preserve stored bounds until fonts are ready, restore missing font-loaded guards, use natural width for `WIDTH_AND_HEIGHT` text, and clip text to node bounds - Fix browser/headless rendering mismatch for imported toolbar/instance content by correcting runtime imported layout recomputation instead of diverging browser rendering behavior - Fix `set_layout` tool not defaulting to HUG sizing when enabling auto-layout — frames now shrink/grow to fit children instead of keeping fixed dimensions +- Normalize font family names on `.fig` export — strip optical size suffixes (e.g. "DM Sans 9pt" → "DM Sans") so Figma recognizes the font - Fix save crash when COLOR variable is missing alpha channel - Fix console error spam on deployed web app from automation WebSocket reconnect loop - Fix headless CLI font fallback — bundled Inter font now ships with `@open-pencil/core` and loads without a web server diff --git a/packages/core/src/fonts.ts b/packages/core/src/fonts.ts index 0a1e861a8..34ac729a7 100644 --- a/packages/core/src/fonts.ts +++ b/packages/core/src/fonts.ts @@ -65,7 +65,7 @@ const googleFontsCache = new Map>() const googleFontsFailed = new Set() export function normalizeFontFamily(family: string): string { - return family.replace(/\s+Variable$/i, '') + return family.replace(/\s+(Variable|\d+(?:pt|px|em))$/i, '') } async function retryWithNormalizedFamily(family: string): Promise | null> { diff --git a/packages/core/src/kiwi/kiwi-serialize.ts b/packages/core/src/kiwi/kiwi-serialize.ts index f88d9c645..4ef600a08 100644 --- a/packages/core/src/kiwi/kiwi-serialize.ts +++ b/packages/core/src/kiwi/kiwi-serialize.ts @@ -2,7 +2,7 @@ export const FIG_KIWI_VERSION = 106 import { deflateSync, inflateSync } from 'fflate' -import { weightToStyle, getLoadedFontData } from '../fonts' +import { getLoadedFontData, normalizeFontFamily, weightToStyle } from '../fonts' import { encodeVectorNetworkBlob } from '../vector' import { stringToGuid, VARIABLE_BINDING_FIELDS } from './kiwi-convert' @@ -225,7 +225,7 @@ function exportTextData(node: SceneNode): NodeChange['textData'] { const weight = style.fontWeight ?? node.fontWeight const italic = style.italic ?? node.italic override.fontName = { - family: style.fontFamily ?? node.fontFamily, + family: normalizeFontFamily(style.fontFamily ?? node.fontFamily), style: weightToStyle(weight, italic), postscript: '' } @@ -302,7 +302,7 @@ function serializeTextProps( ): void { nc.fontSize = node.fontSize nc.fontName = { - family: node.fontFamily, + family: normalizeFontFamily(node.fontFamily), style: weightToStyle(node.fontWeight, node.italic), postscript: '' } diff --git a/tests/engine/font-normalize-export.test.ts b/tests/engine/font-normalize-export.test.ts new file mode 100644 index 000000000..d222bc7e1 --- /dev/null +++ b/tests/engine/font-normalize-export.test.ts @@ -0,0 +1,84 @@ +import { describe, test, expect, beforeAll } from 'bun:test' + +import { + exportFigFile, + parseFigFile, + initCodec, + SceneGraph, +} from '@open-pencil/core' + +beforeAll(async () => { + await initCodec() +}) + +function pageId(graph: SceneGraph) { + return graph.getPages()[0].id +} + +describe('Font family normalization on .fig export', () => { + test('strips optical size suffix from font family', async () => { + const graph = new SceneGraph() + graph.createNode('TEXT', pageId(graph), { + name: 'Test', + x: 0, + y: 0, + width: 100, + height: 20, + text: 'Hello', + fontFamily: 'DM Sans 9pt', + fontWeight: 400, + fontSize: 14, + }) + + const exported = await exportFigFile(graph) + const reimported = await parseFigFile(exported.buffer as ArrayBuffer) + + const nodes = [...reimported.nodes.values()] + const textNode = nodes.find((n) => n.type === 'TEXT')! + expect(textNode.fontFamily).toBe('DM Sans') + }) + + test('preserves normal font family names', async () => { + const graph = new SceneGraph() + graph.createNode('TEXT', pageId(graph), { + name: 'Test', + x: 0, + y: 0, + width: 100, + height: 20, + text: 'Hello', + fontFamily: 'Inter', + fontWeight: 400, + fontSize: 14, + }) + + const exported = await exportFigFile(graph) + const reimported = await parseFigFile(exported.buffer as ArrayBuffer) + + const nodes = [...reimported.nodes.values()] + const textNode = nodes.find((n) => n.type === 'TEXT')! + expect(textNode.fontFamily).toBe('Inter') + }) + + test('strips Variable suffix from font family', async () => { + const graph = new SceneGraph() + graph.createNode('TEXT', pageId(graph), { + name: 'Test', + x: 0, + y: 0, + width: 100, + height: 20, + text: 'Hello', + fontFamily: 'Roboto Variable', + fontWeight: 400, + fontSize: 14, + }) + + const exported = await exportFigFile(graph) + const reimported = await parseFigFile(exported.buffer as ArrayBuffer) + + const nodes = [...reimported.nodes.values()] + const textNode = nodes.find((n) => n.type === 'TEXT')! + expect(textNode.fontFamily).toBe('Roboto') + }) +}) diff --git a/tests/engine/fonts.test.ts b/tests/engine/fonts.test.ts index 3e5eaa229..26a787f2f 100644 --- a/tests/engine/fonts.test.ts +++ b/tests/engine/fonts.test.ts @@ -318,6 +318,23 @@ describe('normalizeFontFamily', () => { test('does not strip Variable in the middle', () => { expect(normalizeFontFamily('Variable Sans')).toBe('Variable Sans') }) + + test('strips optical size suffix (pt)', () => { + expect(normalizeFontFamily('DM Sans 9pt')).toBe('DM Sans') + expect(normalizeFontFamily('DM Sans 14pt')).toBe('DM Sans') + }) + + test('strips optical size suffix (px)', () => { + expect(normalizeFontFamily('Noto Sans 12px')).toBe('Noto Sans') + }) + + test('strips optical size suffix (em)', () => { + expect(normalizeFontFamily('Custom Font 1em')).toBe('Custom Font') + }) + + test('does not strip size units in the middle', () => { + expect(normalizeFontFamily('12pt Serif')).toBe('12pt Serif') + }) }) describe('styleToVariant', () => {