fix(kiwi): preserve edited font variation axes

This commit is contained in:
Danila Poyarkov 2026-05-23 00:26:58 +03:00
parent 8c85d2b953
commit 054199ac15
8 changed files with 122 additions and 9 deletions

View file

@ -1,7 +1,7 @@
import type { NodeChange } from '#core/kiwi/fig/codec'
import type { FontVariation } from '#core/scene-graph'
function figmaAxisTagToString(axisTag: number): string {
export function figmaAxisTagToString(axisTag: number): string {
return String.fromCharCode(
(axisTag >> 24) & 0xff,
(axisTag >> 16) & 0xff,
@ -10,13 +10,25 @@ function figmaAxisTagToString(axisTag: number): string {
)
}
export function stringToFigmaAxisTag(axis: string): number | undefined {
if (axis.length !== 4) return undefined
return (
((axis.charCodeAt(0) << 24) |
(axis.charCodeAt(1) << 16) |
(axis.charCodeAt(2) << 8) |
axis.charCodeAt(3)) >>>
0
)
}
export function convertFontVariations(nc: NodeChange): FontVariation[] {
const result: FontVariation[] = []
for (const variation of nc.fontVariations ?? []) {
if (typeof variation.value !== 'number') continue
const axis =
variation.axisName ||
(typeof variation.axisTag === 'number' ? figmaAxisTagToString(variation.axisTag) : '')
typeof variation.axisTag === 'number'
? figmaAxisTagToString(variation.axisTag)
: variation.axisName || ''
if (axis) result.push({ axis, value: variation.value })
}
return result

View file

@ -19,6 +19,7 @@ import type { Color, GUID, Matrix } from '#core/types'
import { guidToString, stringToGuid, VARIABLE_BINDING_FIELDS } from './convert'
import { sceneNodeToKiwiWithContext, type KiwiNodeChange } from './export-node'
import { stringToFigmaAxisTag } from './font-variations'
import {
BOUND_VARIABLES_PLUGIN_KEY,
LAYOUT_DIRECTION_PLUGIN_KEY,
@ -177,7 +178,10 @@ function buildDerivedTextData(
}
function fontVariationToKiwi(variation: SceneNode['fontVariations'][number]) {
return { axisName: variation.axis, value: variation.value }
const axisTag = stringToFigmaAxisTag(variation.axis)
return axisTag === undefined
? { axisName: variation.axis, value: variation.value }
: { axisTag, axisName: variation.axis, value: variation.value }
}
function exportTextData(node: SceneNode): NodeChange['textData'] {

View file

@ -32,6 +32,7 @@ const RAW_NODE_FIELD_KEYS = new Set([
'letterSpacing',
'maxLines',
'styleRuns',
'fontVariations',
'textTruncation',
'layoutMode',
'itemSpacing',

View file

@ -16,4 +16,23 @@ describe('Figma boolean operation export', () => {
expect(changes[0].type).toBe('BOOLEAN_OPERATION')
expect(changes[0].booleanOperation).toBe('INTERSECT')
})
test('exports boolean operation children in order', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const node = graph.createNode('BOOLEAN_OPERATION', page.id, {
booleanOperation: 'SUBTRACT'
})
graph.createNode('RECTANGLE', node.id, { name: 'Left operand' })
graph.createNode('ELLIPSE', node.id, { name: 'Right operand' })
const changes = sceneNodeToKiwi(node, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [])
expect(changes.map((change) => change.type)).toEqual([
'BOOLEAN_OPERATION',
'RECTANGLE',
'ELLIPSE'
])
expect(changes.map((change) => change.parentIndex?.position)).toEqual(['!', '!', '"'])
})
})

View file

@ -22,9 +22,11 @@ describe('Figma font variation export', () => {
const changes = sceneNodeToKiwi(text, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [])
const nodeChange = changes[0]
expect(nodeChange.fontVariations).toEqual([{ axisName: 'wght', value: 650 }])
expect(nodeChange.fontVariations).toEqual([
{ axisTag: 0x77676874, axisName: 'wght', value: 650 }
])
expect(nodeChange.textData?.styleOverrideTable?.[0]?.fontVariations).toEqual([
{ axisName: 'wdth', value: 88 }
{ axisTag: 0x77647468, axisName: 'wdth', value: 88 }
])
})
})

View file

@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test'
import type { NodeChange } from '#core/kiwi/fig/codec'
import { importNodeChanges } from '#core/kiwi/fig/import'
import { nodeChangeToProps } from '#core/kiwi/fig/node-change/convert'
describe('Figma boolean operation import', () => {
@ -18,6 +19,56 @@ describe('Figma boolean operation import', () => {
expect(props.booleanOperation).toBe('SUBTRACT')
})
test('imports boolean operation nodes with children', () => {
const graph = importNodeChanges([
{
guid: { sessionID: 0, localID: 0 },
type: 'DOCUMENT',
name: 'Document',
phase: 'CREATED'
},
{
guid: { sessionID: 0, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
type: 'CANVAS',
name: 'Page',
phase: 'CREATED'
},
{
guid: { sessionID: 2, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' },
type: 'BOOLEAN_OPERATION',
name: 'Imported boolean',
booleanOperation: 'INTERSECT',
phase: 'CREATED',
size: { x: 120, y: 80 }
},
{
guid: { sessionID: 2, localID: 2 },
parentIndex: { guid: { sessionID: 2, localID: 1 }, position: '!' },
type: 'RECTANGLE',
name: 'Left operand',
phase: 'CREATED',
size: { x: 80, y: 80 }
},
{
guid: { sessionID: 2, localID: 3 },
parentIndex: { guid: { sessionID: 2, localID: 1 }, position: '"' },
type: 'ELLIPSE',
name: 'Right operand',
phase: 'CREATED',
size: { x: 80, y: 80 }
}
] as NodeChange[])
const booleanNode = graph.getChildren(graph.getPages()[0].id)[0]
const children = graph.getChildren(booleanNode.id)
expect(booleanNode.type).toBe('BOOLEAN_OPERATION')
expect(booleanNode.booleanOperation).toBe('INTERSECT')
expect(children.map((child) => child.type)).toEqual(['RECTANGLE', 'ELLIPSE'])
})
test('defaults missing boolean operations to union', () => {
const props = nodeChangeToProps(
{

View file

@ -10,7 +10,7 @@ describe('Figma font variation import', () => {
type: 'TEXT',
textData: { characters: 'Axis' },
fontVariations: [
{ axisTag: 0x77676874, value: 650 },
{ axisTag: 0x77676874, axisName: 'Weight', value: 650 },
{ axisName: 'wdth', value: 88 }
]
} as NodeChange,

View file

@ -1,9 +1,8 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil/core'
import { parseFigBuffer } from '@open-pencil/core/kiwi/fig/parse/core'
import { guidToString } from '@open-pencil/core/kiwi/fig/node-change/convert'
import { parseFigBuffer } from '@open-pencil/core/kiwi/fig/parse/core'
function decodeExport(bytes: Uint8Array) {
return parseFigBuffer(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))
@ -126,6 +125,31 @@ describe('fig roundtrip source metadata', () => {
}
})
test('clears raw font variation payloads when normalized axes are edited', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const text = graph.createNode('TEXT', page.id, {
name: 'Variable font text',
text: 'Axis',
fontVariations: [{ axis: 'wght', value: 400 }]
})
text.source.format = 'fig'
text.source.id = '4:501'
text.source.fig.rawNodeFields.fontVariations = [{ axisName: 'wght', value: 900 }]
graph.updateNode(text.id, { fontVariations: [{ axis: 'wght', value: 650 }] })
const decoded = decodeExport(await exportFigFile(graph))
const exported = decoded.nodeChanges.find(
(nodeChange) => nodeChange.guid && guidToString(nodeChange.guid) === '4:501'
)
expect(text.source.fig.rawNodeFields.fontVariations).toBeUndefined()
expect(exported?.fontVariations).toEqual([
{ axisTag: 0x77676874, axisName: 'wght', value: 650 }
])
})
test('exports imported raw vector payloads without regenerating vector data', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]