From 617482e3232e6852ba063706a3c9228f35ed49c4 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 28 Jul 2026 02:52:24 +0300 Subject: [PATCH] fix(fig): export stretched auto-layout frames - Translate parent-level cross-axis stretch to Figma child alignment - Preserve explicit and absolute child behavior during export - Cover empty frames, encoded fields, and round-trip semantics --- CHANGELOG.md | 1 + packages/fig/src/node-change/export-node.ts | 1 + packages/fig/src/node-change/serialize.ts | 33 ++++++- .../engine/io/fig/export/auto-layout.test.ts | 86 +++++++++++++++++++ 4 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 tests/engine/io/fig/export/auto-layout.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c2da894d..6b5e5c63d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Save auto-layout frames that stretch their children to `.fig` without failing. (#427) - Reduce large `.fig` page-switch work to the active page and coalesce Layers tree rebuilds. (#420) - Center text glyphs within explicit line-height leading in CanvasKit paragraph rendering. diff --git a/packages/fig/src/node-change/export-node.ts b/packages/fig/src/node-change/export-node.ts index d478002b6..0c0e564b0 100644 --- a/packages/fig/src/node-change/export-node.ts +++ b/packages/fig/src/node-change/export-node.ts @@ -250,6 +250,7 @@ function materializeFigmaBlob( } function normalizeFigmaPayloadValue(key: string, value: unknown): unknown { + if (key === 'stackCounterAlignItems' && value === 'STRETCH') return 'MIN' if ( (key === 'stackJustify' || key === 'stackPrimaryAlignItems' || diff --git a/packages/fig/src/node-change/serialize.ts b/packages/fig/src/node-change/serialize.ts index 78e4e299f..90329804c 100644 --- a/packages/fig/src/node-change/serialize.ts +++ b/packages/fig/src/node-change/serialize.ts @@ -305,6 +305,28 @@ function normalizeStackCounterAlign(value: string | undefined): string | undefin return value === 'SPACE_EVENLY' ? 'SPACE_BETWEEN' : value } +function normalizeStackCounterAlignItems(value: string | undefined): string | undefined { + const normalized = normalizeStackCounterAlign(value) + // Figma models cross-axis stretch on each child, not on counterAxisAlignItems. + return normalized === 'STRETCH' ? 'MIN' : normalized +} + +function serializeInheritedCounterAxisStretch( + node: SceneNode, + nc: KiwiNodeChange, + graph: SceneGraph +): void { + if (!node.parentId || node.layoutAlignSelf !== 'AUTO' || node.layoutPositioning === 'ABSOLUTE') + return + const parent = graph.getNode(node.parentId) + if ( + parent?.counterAxisAlign === 'STRETCH' && + (parent.layoutMode === 'HORIZONTAL' || parent.layoutMode === 'VERTICAL') + ) { + nc.stackChildAlignSelf = 'STRETCH' + } +} + function preserveTrailingPadding( explicitValue: number | undefined, leadingValue: number | undefined, @@ -316,7 +338,7 @@ function preserveTrailingPadding( return normalizedValue !== inheritedValue ? normalizedValue : undefined } -function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void { +function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange, graph: SceneGraph): void { if (!node.source.id) upsertPluginData(node, LAYOUT_DIRECTION_PLUGIN_KEY, node.layoutDirection) const figLayout = node.source.fig.layout if (figLayout) { @@ -337,7 +359,7 @@ function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void { ) nc.stackCounterAlign = normalizeStackCounterAlign(figLayout.stackCounterAlign) nc.stackJustify = normalizeStackJustify(figLayout.stackJustify) - nc.stackCounterAlignItems = normalizeStackCounterAlign(figLayout.stackCounterAlignItems) + nc.stackCounterAlignItems = normalizeStackCounterAlignItems(figLayout.stackCounterAlignItems) nc.stackPrimaryAlignItems = normalizeStackJustify(figLayout.stackPrimaryAlignItems) // For imported nodes, figLayout captures the original kiwi NC values. // Preserve omitted sizing fields instead of materializing schema defaults. @@ -354,6 +376,7 @@ function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void { nc.stackCounterSpacing = figLayout.stackCounterSpacing nc.bordersTakeSpace = figLayout.bordersTakeSpace if (figLayout.stackReverseZIndex) nc.stackReverseZIndex = true + serializeInheritedCounterAxisStretch(node, nc, graph) return } if (node.layoutMode !== 'NONE' && node.layoutMode !== 'GRID') { @@ -366,7 +389,7 @@ function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void { nc.stackPrimarySizing = node.primaryAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED' nc.stackCounterSizing = node.counterAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED' nc.stackPrimaryAlignItems = normalizeStackJustify(node.primaryAxisAlign) - nc.stackCounterAlignItems = normalizeStackCounterAlign(node.counterAxisAlign) + nc.stackCounterAlignItems = normalizeStackCounterAlignItems(node.counterAxisAlign) if (node.layoutWrap === 'WRAP') nc.stackWrap = 'WRAP' if (node.counterAxisSpacing > 0) nc.stackCounterSpacing = node.counterAxisSpacing nc.bordersTakeSpace = node.strokesIncludedInLayout @@ -376,6 +399,8 @@ function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void { if (node.layoutGrow > 0) nc.stackChildPrimaryGrow = node.layoutGrow if (node.layoutAlignSelf !== 'AUTO') { nc.stackChildAlignSelf = node.layoutAlignSelf + } else { + serializeInheritedCounterAxisStretch(node, nc, graph) } } @@ -519,7 +544,7 @@ export function sceneNodeToKiwi( serializeCornerRadii, serializeTextProps: (textNode, nc, textGraph, digests, textBlobs, glyphs) => serializeTextProps(textNode, nc, textGraph, digests, textBlobs, glyphs, runtime), - serializeLayoutProps, + serializeLayoutProps: (layoutNode, nc) => serializeLayoutProps(layoutNode, nc, graph), serializeGeometry, serializeVariableBindings, sceneNodeToKiwi: sceneNodeToKiwiWithContext diff --git a/tests/engine/io/fig/export/auto-layout.test.ts b/tests/engine/io/fig/export/auto-layout.test.ts new file mode 100644 index 000000000..f5b519c80 --- /dev/null +++ b/tests/engine/io/fig/export/auto-layout.test.ts @@ -0,0 +1,86 @@ +import { beforeAll, describe, expect, test } from 'bun:test' + +import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil/core' +import { parseFigBuffer } from '@open-pencil/fig' + +function decodeExport(bytes: Uint8Array) { + return parseFigBuffer(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)) +} + +describe('.fig auto-layout export', () => { + beforeAll(async () => { + await initCodec() + }) + + test('translates inherited counter-axis stretch to Figma child alignment', async () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const frame = graph.createNode('FRAME', page.id, { + name: 'Stretch frame', + width: 200, + height: 200, + layoutMode: 'VERTICAL', + counterAxisAlign: 'STRETCH' + }) + graph.createNode('RECTANGLE', frame.id, { + name: 'Inherited stretch', + width: 50, + height: 50 + }) + graph.createNode('RECTANGLE', frame.id, { + name: 'Explicit center', + width: 50, + height: 50, + layoutAlignSelf: 'CENTER' + }) + graph.createNode('RECTANGLE', frame.id, { + name: 'Absolute child', + width: 50, + height: 50, + layoutPositioning: 'ABSOLUTE' + }) + + const exported = await exportFigFile(graph) + const nodeChanges = decodeExport(exported).nodeChanges + + expect(nodeChanges.find((node) => node.name === frame.name)?.stackCounterAlignItems).toBe('MIN') + expect(nodeChanges.find((node) => node.name === 'Inherited stretch')?.stackChildAlignSelf).toBe( + 'STRETCH' + ) + expect(nodeChanges.find((node) => node.name === 'Explicit center')?.stackChildAlignSelf).toBe( + 'CENTER' + ) + expect( + nodeChanges.find((node) => node.name === 'Absolute child')?.stackChildAlignSelf + ).toBeUndefined() + + const reimported = await parseFigFile( + exported.buffer.slice(exported.byteOffset, exported.byteOffset + exported.byteLength) + ) + const importedNodes = reimported.getAllNodes() + expect(importedNodes.find((node) => node.name === 'Stretch frame')?.counterAxisAlign).toBe( + 'MIN' + ) + expect(importedNodes.find((node) => node.name === 'Inherited stretch')?.layoutAlignSelf).toBe( + 'STRETCH' + ) + }) + + test('exports an empty frame with counter-axis stretch', async () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + graph.createNode('FRAME', page.id, { + name: 'Empty stretch frame', + width: 200, + height: 200, + layoutMode: 'HORIZONTAL', + counterAxisAlign: 'STRETCH' + }) + + const exported = await exportFigFile(graph) + const frame = decodeExport(exported).nodeChanges.find( + (node) => node.name === 'Empty stretch frame' + ) + expect(frame?.stackCounterAlignItems).toBe('MIN') + }) +})