fix(kiwi): import Figma groups as groups

* fix(kiwi): import Figma groups as GROUP nodes instead of FRAME

- Map FRAME nodes with `resizeToFit === true` to `GROUP` type on import
- Write `resizeToFit = true` when exporting `GROUP` nodes back to Kiwi format
- Document findings and diagnosis in packages/docs/development/group-to-frame-import-issue.md
- Extract resolveNodeType helper to satisfy cyclomatic complexity limits

* test(kiwi): harden Figma group import + add coverage

Follow-up hardening on the group import/export fix:

- convert.ts: reorder so the COMPONENT_SET check runs before the FRAME→GROUP
  reclassification, and guard the group check on the absence of auto-layout
  (`stackMode` unset/NONE). Figma auto-layout "hug" frames use
  stackPrimarySizing/stackCounterSizing, not `resizeToFit`, so this prevents a
  component-set or auto-layout frame that happens to carry `resizeToFit` from being
  misclassified as a group.

- Add tests/engine/io/fig/import/group-reclassify.test.ts: unit coverage for the
  reclassification (FRAME+resizeToFit → GROUP, plain frame stays FRAME, auto-layout
  hug frame stays FRAME), plus a real-file assertion that gold-preview.fig imports
  its groups as GROUP.

- Add tests/engine/io/fig/roundtrip/group.test.ts: a created GROUP survives
  export → re-import as a GROUP (validates the export-side resizeToFit write).

- Update exhaustive.test.ts gold-preview golden: 519 nodes that were misimported as
  FRAME are now correctly GROUP (FRAME 4525→4006, GROUP 519), and export size shifts
  594758→594770 because exported groups now carry resizeToFit.

Note: the pre-existing material3 variables roundtrip failure
("Invalid value EXCLUDE for enum BooleanOperation") is unrelated to groups and
also fails on master — out of scope here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(kiwi): remove temporary group-to-frame analysis doc

It was a scratch diagnostic artifact (and contained a hardcoded local path);
the rationale now lives in the fix's commit messages, code comments, and tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
This commit is contained in:
rcoenen 2026-06-06 06:49:19 -04:00 committed by GitHub
parent a9ee01fc13
commit 922235d2c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 135 additions and 9 deletions

View file

@ -496,17 +496,34 @@ function convertVectorAndStrokeProps(nc: NodeChange, blobs: Uint8Array[]) {
}
}
export function nodeChangeToProps(
nc: NodeChange,
blobs: Uint8Array[]
): Partial<SceneNode> & { 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<SceneNode> & { nodeType: NodeType | 'DOCUMENT' | 'VARIABLE' } {
const nodeType = resolveNodeType(nc)
const vectorAndStrokeProps = convertVectorAndStrokeProps(nc, blobs)

View file

@ -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

View file

@ -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)
})
})

View file

@ -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
}
]

View file

@ -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')
})
})