diff --git a/packages/core/src/kiwi/fig/node-change/font-variations.ts b/packages/core/src/kiwi/fig/node-change/font-variations.ts index 35901948e..8cfbfa9d3 100644 --- a/packages/core/src/kiwi/fig/node-change/font-variations.ts +++ b/packages/core/src/kiwi/fig/node-change/font-variations.ts @@ -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 diff --git a/packages/core/src/kiwi/fig/node-change/serialize.ts b/packages/core/src/kiwi/fig/node-change/serialize.ts index 9d974f6b0..968a2729c 100644 --- a/packages/core/src/kiwi/fig/node-change/serialize.ts +++ b/packages/core/src/kiwi/fig/node-change/serialize.ts @@ -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'] { diff --git a/packages/core/src/scene-graph/source-metadata.ts b/packages/core/src/scene-graph/source-metadata.ts index 99f8d7695..aaa6f8c72 100644 --- a/packages/core/src/scene-graph/source-metadata.ts +++ b/packages/core/src/scene-graph/source-metadata.ts @@ -32,6 +32,7 @@ const RAW_NODE_FIELD_KEYS = new Set([ 'letterSpacing', 'maxLines', 'styleRuns', + 'fontVariations', 'textTruncation', 'layoutMode', 'itemSpacing', diff --git a/tests/engine/io/fig/export/boolean-operation.test.ts b/tests/engine/io/fig/export/boolean-operation.test.ts index 090d01b95..b9921b98d 100644 --- a/tests/engine/io/fig/export/boolean-operation.test.ts +++ b/tests/engine/io/fig/export/boolean-operation.test.ts @@ -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(['!', '!', '"']) + }) }) diff --git a/tests/engine/io/fig/export/font-variations.test.ts b/tests/engine/io/fig/export/font-variations.test.ts index 04f554397..b1707adfc 100644 --- a/tests/engine/io/fig/export/font-variations.test.ts +++ b/tests/engine/io/fig/export/font-variations.test.ts @@ -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 } ]) }) }) diff --git a/tests/engine/io/fig/import/boolean-operation.test.ts b/tests/engine/io/fig/import/boolean-operation.test.ts index 13fe28393..47323b8e1 100644 --- a/tests/engine/io/fig/import/boolean-operation.test.ts +++ b/tests/engine/io/fig/import/boolean-operation.test.ts @@ -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( { diff --git a/tests/engine/io/fig/import/font-variations.test.ts b/tests/engine/io/fig/import/font-variations.test.ts index 704091053..4cfa1e2b8 100644 --- a/tests/engine/io/fig/import/font-variations.test.ts +++ b/tests/engine/io/fig/import/font-variations.test.ts @@ -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, diff --git a/tests/engine/io/fig/roundtrip/source-metadata.test.ts b/tests/engine/io/fig/roundtrip/source-metadata.test.ts index d0a1400cc..20a40b6de 100644 --- a/tests/engine/io/fig/roundtrip/source-metadata.test.ts +++ b/tests/engine/io/fig/roundtrip/source-metadata.test.ts @@ -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]