diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index b7f5cd42f..ce75b8b2f 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -496,17 +496,34 @@ function convertVectorAndStrokeProps(nc: NodeChange, blobs: Uint8Array[]) { } } -export function nodeChangeToProps( - nc: NodeChange, - blobs: Uint8Array[] -): Partial & { nodeType: NodeType | 'DOCUMENT' | 'VARIABLE' } { - let nodeType = mapNodeType(nc.type) +function resolveNodeType(nc: NodeChange): NodeType | 'DOCUMENT' | 'VARIABLE' { + const nodeType = mapNodeType(nc.type) if ( (nodeType === 'FRAME' && isComponentSet(nc)) || getOpenPencilPluginValue(nc, NODE_TYPE_PLUGIN_KEY) === 'COMPONENT_SET' ) { - nodeType = 'COMPONENT_SET' + return 'COMPONENT_SET' } + // Figma stores plain groups as FRAME node-changes flagged with resizeToFit. + // Auto-layout "hug" frames instead use stackPrimarySizing/stackCounterSizing and + // always carry a stackMode, so guard on the absence of auto-layout — a real group + // never has one. This keeps component-sets and auto-layout frames from being + // misclassified as groups. + if ( + nodeType === 'FRAME' && + nc.resizeToFit === true && + (nc.stackMode === undefined || nc.stackMode === 'NONE') + ) { + return 'GROUP' + } + return nodeType +} + +export function nodeChangeToProps( + nc: NodeChange, + blobs: Uint8Array[] +): Partial & { nodeType: NodeType | 'DOCUMENT' | 'VARIABLE' } { + const nodeType = resolveNodeType(nc) const vectorAndStrokeProps = convertVectorAndStrokeProps(nc, blobs) diff --git a/packages/core/src/kiwi/fig/node-change/export-node.ts b/packages/core/src/kiwi/fig/node-change/export-node.ts index ece309d6f..cc375e0db 100644 --- a/packages/core/src/kiwi/fig/node-change/export-node.ts +++ b/packages/core/src/kiwi/fig/node-change/export-node.ts @@ -665,6 +665,9 @@ export function sceneNodeToKiwiWithContext( size: exportNodeSize(node), transform: exportNodeTransform(context, node) } + if (node.type === 'GROUP') { + nc.resizeToFit = true + } // Only set strokeWeight/strokeAlign when the node has strokes in the scene // model. For imported nodes without strokes but with raw strokeWeight data // (e.g. text nodes, instance children with scaled strokes), the raw value diff --git a/tests/engine/io/fig/import/group-reclassify.test.ts b/tests/engine/io/fig/import/group-reclassify.test.ts new file mode 100644 index 000000000..584d58696 --- /dev/null +++ b/tests/engine/io/fig/import/group-reclassify.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' + +import type { NodeChange } from '#core/kiwi/fig/codec' +import { nodeChangeToProps } from '#core/kiwi/fig/node-change/convert' + +import { parseFixture } from '#tests/helpers/fig-fixtures' +import { collectAllNodes } from '#tests/helpers/fig-traversal' + +describe('Figma group reclassification on import', () => { + test('FRAME with resizeToFit imports as GROUP', () => { + const props = nodeChangeToProps( + { type: 'FRAME', name: 'Group 1', resizeToFit: true } as NodeChange, + [] + ) + expect(props.nodeType).toBe('GROUP') + // groups never clip their children + expect(props.clipsContent).toBe(false) + }) + + test('plain FRAME stays FRAME', () => { + const props = nodeChangeToProps( + { type: 'FRAME', name: 'Frame', resizeToFit: false } as NodeChange, + [] + ) + expect(props.nodeType).toBe('FRAME') + }) + + test('FRAME with no resizeToFit flag stays FRAME', () => { + const props = nodeChangeToProps({ type: 'FRAME', name: 'Frame' } as NodeChange, []) + expect(props.nodeType).toBe('FRAME') + }) + + test('auto-layout hug frame stays FRAME (not GROUP)', () => { + const props = nodeChangeToProps( + { + type: 'FRAME', + name: 'AutoLayout', + stackMode: 'VERTICAL', + stackPrimarySizing: 'RESIZE_TO_FIT' + } as NodeChange, + [] + ) + expect(props.nodeType).toBe('FRAME') + }) + + test('auto-layout frame that also carries resizeToFit stays FRAME', () => { + const props = nodeChangeToProps( + { + type: 'FRAME', + name: 'AutoLayout2', + stackMode: 'HORIZONTAL', + resizeToFit: true + } as NodeChange, + [] + ) + expect(props.nodeType).toBe('FRAME') + }) + + test('gold-preview.fig fixture imports its groups as GROUP nodes', async () => { + const graph = await parseFixture('gold-preview.fig') + const groups = collectAllNodes(graph).filter((n) => n.type === 'GROUP') + // gold-preview.fig contains real Figma groups (FRAME + resizeToFit) that must + // import as GROUP, not FRAME. + expect(groups.length).toBeGreaterThan(0) + }) +}) diff --git a/tests/engine/io/fig/roundtrip/exhaustive.test.ts b/tests/engine/io/fig/roundtrip/exhaustive.test.ts index 45ce4535b..7ff894d22 100644 --- a/tests/engine/io/fig/roundtrip/exhaustive.test.ts +++ b/tests/engine/io/fig/roundtrip/exhaustive.test.ts @@ -47,7 +47,8 @@ const SPECS: FixtureSpec[] = [ fileSize: 550091, nodeCount: 38323, nodeTypes: { - FRAME: 4525, + FRAME: 4006, + GROUP: 519, ROUNDED_RECTANGLE: 3752, VECTOR: 14221, ELLIPSE: 24, @@ -63,8 +64,8 @@ const SPECS: FixtureSpec[] = [ thumbnailHeight: 239, imageCount: 3, figKiwiVersion: 101, - g1ExportSize: 594758, - g2ExportSize: 594758 + g1ExportSize: 594770, + g2ExportSize: 594770 } ] diff --git a/tests/engine/io/fig/roundtrip/group.test.ts b/tests/engine/io/fig/roundtrip/group.test.ts new file mode 100644 index 000000000..1f0281454 --- /dev/null +++ b/tests/engine/io/fig/roundtrip/group.test.ts @@ -0,0 +1,39 @@ +import { beforeAll, describe, expect, setDefaultTimeout, test } from 'bun:test' + +import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil/core' + +import { collectAllNodes } from '#tests/helpers/fig-traversal' + +setDefaultTimeout(60_000) + +describe('roundtrip: GROUP survives export → re-import', () => { + beforeAll(async () => { + await initCodec() + }) + + test('a GROUP exported to .fig re-imports as a GROUP', async () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const group = graph.createNode('GROUP', page.id, { + name: 'My Group', + x: 0, + y: 0, + width: 100, + height: 100 + }) + graph.createNode('RECTANGLE', group.id, { + name: 'child', + x: 0, + y: 0, + width: 50, + height: 50 + }) + + const bytes = await exportFigFile(graph) + const reImported = await parseFigFile(bytes) + const nodes = collectAllNodes(reImported) + + const roundTripped = nodes.find((n) => n.name === 'My Group') + expect(roundTripped?.type).toBe('GROUP') + }) +})