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:
parent
a9ee01fc13
commit
922235d2c8
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
66
tests/engine/io/fig/import/group-reclassify.test.ts
Normal file
66
tests/engine/io/fig/import/group-reclassify.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
39
tests/engine/io/fig/roundtrip/group.test.ts
Normal file
39
tests/engine/io/fig/roundtrip/group.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue