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
This commit is contained in:
Danila Poyarkov 2026-07-28 02:52:24 +03:00
parent e10ca1af1b
commit 617482e323
4 changed files with 117 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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