fix(layout): shape generated FIG text

- Reconcile stale generated text widths with CanvasKit measurements

- Propagate effective widths through HUG containers while preserving fixed geometry

- Cover component-lineage and stretched-text safeguards
This commit is contained in:
Danila Poyarkov 2026-08-03 20:08:56 +03:00
parent bbeffc54cb
commit 2ec58ae3eb
5 changed files with 486 additions and 9 deletions

View file

@ -49,7 +49,7 @@
### Fixed
- Match Figma auto-layout spacing, padding, min/max constraints, scalar variable bindings, imported text bounds, and nested instance geometry more closely.
- Match Figma auto-layout spacing, padding, min/max constraints, scalar variable bindings, CanvasKit-shaped generated text, imported text bounds, and nested instance geometry more closely.
- Match Figma Plugin API vector path and network editing, including bounds, winding rules, region fills, validation, and handle mirroring. (#444)
- Let AI and MCP tools create arbitrary vectors from SVG path data, validating input without leaving blank layers behind. (#440)
- Improve AI design accuracy by exposing every supported shape, including visible stroke colors and weights in visual descriptions, and accepting supported inline SVG attributes without false warnings. (#445, #447, #448)

View file

@ -13,6 +13,7 @@ import {
import { applyYogaLayout } from './layout/apply'
import { usesDetachedDerivedLayout } from './layout/derived'
import { applyEffectiveGeneratedTextLayout } from './layout/effective-generated-text'
import { buildGridTree, createGridChildNode } from './layout/grid'
import { resolveNodeLayoutDirection } from './text/direction'
export {
@ -40,19 +41,15 @@ export function computeLayout(graph: SceneGraph, frameId: string): void {
if (!frame || frame.layoutMode === 'NONE') return
const rootDirection = resolveComputedLayoutDirection(graph, frame)
const yogaDirection = rootDirection === 'RTL' ? Direction.RTL : Direction.LTR
const yogaRoot =
frame.layoutMode === 'GRID'
? buildGridTree(graph, frame, rootDirection)
: buildYogaTree(graph, frame, rootDirection)
yogaRoot.calculateLayout(
undefined,
undefined,
rootDirection === 'RTL' ? Direction.RTL : Direction.LTR
)
yogaRoot.calculateLayout(undefined, undefined, yogaDirection)
applyYogaLayout(graph, frame, yogaRoot, computeLayout)
freeYogaTree(yogaRoot)
}
function resolveComputedLayoutDirection(
graph: SceneGraph,
node: Pick<SceneNode, 'layoutDirection' | 'parentId'>
@ -63,8 +60,10 @@ function resolveComputedLayoutDirection(
}
export function computeAllLayouts(graph: SceneGraph, scopeId?: string): void {
const rootId = scopeId ?? graph.rootId
const visited = new Set<string>()
computeLayoutsBottomUp(graph, scopeId ?? graph.rootId, visited)
computeLayoutsBottomUp(graph, rootId, visited)
applyEffectiveGeneratedTextLayout(graph, rootId)
}
function computeLayoutsBottomUp(graph: SceneGraph, nodeId: string, visited: Set<string>): void {

View file

@ -0,0 +1,275 @@
import type { SceneGraph, SceneNode, Size } from '@open-pencil/scene-graph'
import { getTextMeasurer } from './text-measurement'
type LayoutAxis = 'width' | 'height'
const MIN_EFFECTIVE_TEXT_WIDTH_CHANGE = 1.5
const MAX_STRETCHED_TEXT_WIDTH_CHANGE = 8
const MAX_COMPONENT_LINEAGE_DEPTH = 20
function axisSizing(node: SceneNode, axis: LayoutAxis): SceneNode['primaryAxisSizing'] {
const isPrimary =
(node.layoutMode === 'HORIZONTAL' && axis === 'width') ||
(node.layoutMode === 'VERTICAL' && axis === 'height')
return isPrimary ? node.primaryAxisSizing : node.counterAxisSizing
}
function canResizeIntrinsicAxis(node: SceneNode, axis: LayoutAxis): boolean {
return (
axisSizing(node, axis) === 'HUG' ||
(node.source.format === 'fig' && node.figmaDerivedLayout?.[axis] === undefined)
)
}
function terminalTextSource(graph: SceneGraph, node: SceneNode): SceneNode | undefined {
let current = node
for (let depth = 0; current.componentId && depth < MAX_COMPONENT_LINEAGE_DEPTH; depth++) {
const source = graph.getNode(current.componentId)
if (!source) break
current = source
}
return current.type === 'TEXT' ? current : undefined
}
function parentHugsWidth(graph: SceneGraph, node: SceneNode): boolean {
const parent = node.parentId ? graph.getNode(node.parentId) : undefined
return parent !== undefined && axisSizing(parent, 'width') === 'HUG'
}
function hasFixedWidthTextAncestor(graph: SceneGraph, node: SceneNode): boolean {
let current = node
for (let depth = 0; current.componentId && depth < MAX_COMPONENT_LINEAGE_DEPTH; depth++) {
const source = graph.getNode(current.componentId)
if (!source) break
if (source.type === 'TEXT' && source.textAutoResize === 'HEIGHT') return true
current = source
}
return false
}
function canShapeGeneratedText(graph: SceneGraph, node: SceneNode): boolean {
if (
node.type !== 'TEXT' ||
node.source.format === 'fig' ||
!node.componentId ||
!node.figmaDerivedLayout ||
node.figmaDerivedLayout.width !== node.width ||
node.figmaDerivedLayout.height !== node.height
) {
return false
}
const sourceText = terminalTextSource(graph, node)
if (sourceText?.source.format !== 'fig') return false
if (node.textAutoResize === 'WIDTH_AND_HEIGHT') {
return !hasFixedWidthTextAncestor(graph, node)
}
return (
node.textAutoResize === 'HEIGHT' &&
node.layoutAlignSelf === 'STRETCH' &&
parentHugsWidth(graph, node) &&
sourceText.text === node.text
)
}
function intrinsicSize(
graph: SceneGraph,
node: SceneNode,
sizes: ReadonlyMap<string, Size>
): Size | null {
if (node.layoutMode !== 'HORIZONTAL' && node.layoutMode !== 'VERTICAL') return null
const children = graph
.getChildren(node.id)
.filter((child) => child.visible && child.layoutPositioning !== 'ABSOLUTE')
if (children.length === 0) return null
const childSizes = children.map((child) => sizes.get(child.id) ?? child)
const gap =
node.primaryAxisAlign === 'SPACE_BETWEEN'
? 0
: node.itemSpacing * Math.max(0, children.length - 1)
if (node.layoutMode === 'HORIZONTAL') {
return {
width:
node.paddingLeft +
node.paddingRight +
childSizes.reduce((sum, child) => sum + child.width, gap),
height:
node.paddingTop + node.paddingBottom + Math.max(...childSizes.map((child) => child.height))
}
}
return {
width:
node.paddingLeft + node.paddingRight + Math.max(...childSizes.map((child) => child.width)),
height:
node.paddingTop +
node.paddingBottom +
childSizes.reduce((sum, child) => sum + child.height, gap)
}
}
function intrinsicSizeWithEffectiveStretch(
graph: SceneGraph,
node: SceneNode,
sizes: ReadonlyMap<string, Size>,
affected: ReadonlySet<string>
): Size | null {
const intrinsic = intrinsicSize(graph, node, sizes)
if (!intrinsic || node.layoutMode !== 'VERTICAL' || axisSizing(node, 'width') !== 'HUG') {
return intrinsic
}
const children = graph
.getChildren(node.id)
.filter((child) => child.visible && child.layoutPositioning !== 'ABSOLUTE')
if (!children.some((child) => affected.has(child.id))) return intrinsic
const widthCandidates = children.filter(
(child) => affected.has(child.id) || child.layoutAlignSelf !== 'STRETCH'
)
if (widthCandidates.length === 0) return intrinsic
return {
...intrinsic,
width:
node.paddingLeft +
node.paddingRight +
Math.max(...widthCandidates.map((child) => (sizes.get(child.id) ?? child).width))
}
}
function stretchChildrenToEffectiveWidth(
graph: SceneGraph,
node: SceneNode,
oldIntrinsicWidth: number,
nextWidth: number,
currentSizes: Map<string, Size>,
affected: Set<string>
): void {
const oldContentWidth = oldIntrinsicWidth - node.paddingLeft - node.paddingRight
const nextContentWidth = nextWidth - node.paddingLeft - node.paddingRight
for (const child of graph.getChildren(node.id)) {
if (child.layoutAlignSelf !== 'STRETCH' || Math.abs(child.width - oldContentWidth) >= 0.001) {
continue
}
const updates: Partial<SceneNode> = { width: nextContentWidth }
if (child.figmaDerivedLayout) {
updates.figmaDerivedLayout = { ...child.figmaDerivedLayout, width: nextContentWidth }
}
graph.updateNode(child.id, updates)
currentSizes.set(child.id, { width: nextContentWidth, height: child.height })
affected.add(child.id)
}
}
function collectPostorder(graph: SceneGraph, rootId: string): SceneNode[] {
const result: SceneNode[] = []
const visit = (nodeId: string): void => {
const node = graph.getNode(nodeId)
if (!node) return
for (const childId of node.childIds) visit(childId)
result.push(node)
}
visit(rootId)
return result
}
function updateGeneratedTextWidths(
graph: SceneGraph,
nodes: SceneNode[],
currentSizes: Map<string, Size>,
affected: Set<string>
): void {
const measure = getTextMeasurer()
if (!measure) return
for (const node of nodes) {
if (!canShapeGeneratedText(graph, node)) continue
const measured = measure(node)
if (!measured || measured.width <= 0) continue
const widthChange = node.width - measured.width
if (
widthChange < MIN_EFFECTIVE_TEXT_WIDTH_CHANGE ||
(node.textAutoResize === 'HEIGHT' && widthChange > MAX_STRETCHED_TEXT_WIDTH_CHANGE)
) {
continue
}
graph.updateNode(node.id, {
width: measured.width,
figmaDerivedLayout: { ...node.figmaDerivedLayout, width: measured.width }
})
currentSizes.set(node.id, { width: measured.width, height: node.height })
affected.add(node.id)
}
}
function propagateIntrinsicSizes(
graph: SceneGraph,
nodes: SceneNode[],
originalSizes: ReadonlyMap<string, Size>,
currentSizes: Map<string, Size>,
affected: Set<string>
): void {
for (const node of nodes) {
if (node.type === 'TEXT') continue
const children = graph.getChildren(node.id)
if (!children.some((child) => affected.has(child.id))) continue
const oldIntrinsic = intrinsicSize(graph, node, originalSizes)
const nextIntrinsic = intrinsicSizeWithEffectiveStretch(graph, node, currentSizes, affected)
const oldSize = originalSizes.get(node.id)
if (!oldIntrinsic || !nextIntrinsic || !oldSize) continue
const updates: Partial<SceneNode> = {}
let nextWidth = oldSize.width
let nextHeight = oldSize.height
if (
canResizeIntrinsicAxis(node, 'width') &&
Math.abs(oldSize.width - oldIntrinsic.width) < 0.001
) {
nextWidth = nextIntrinsic.width
updates.width = nextWidth
}
if (
canResizeIntrinsicAxis(node, 'height') &&
Math.abs(oldSize.height - oldIntrinsic.height) < 0.001
) {
nextHeight = nextIntrinsic.height
updates.height = nextHeight
}
if (Object.keys(updates).length === 0) continue
if (node.figmaDerivedLayout) {
updates.figmaDerivedLayout = {
...node.figmaDerivedLayout,
...(updates.width === undefined ? {} : { width: nextWidth }),
...(updates.height === undefined ? {} : { height: nextHeight })
}
}
if (updates.width !== undefined) {
stretchChildrenToEffectiveWidth(
graph,
node,
oldIntrinsic.width,
nextWidth,
currentSizes,
affected
)
}
graph.updateNode(node.id, updates)
currentSizes.set(node.id, { width: nextWidth, height: nextHeight })
affected.add(node.id)
}
}
export function applyEffectiveGeneratedTextLayout(graph: SceneGraph, rootId: string): void {
const nodes = collectPostorder(graph, rootId)
const originalSizes = new Map(
nodes.map((node) => [node.id, { width: node.width, height: node.height }])
)
const currentSizes = new Map(originalSizes)
const affected = new Set<string>()
updateGeneratedTextWidths(graph, nodes, currentSizes, affected)
if (affected.size === 0) return
propagateIntrinsicSizes(graph, nodes, originalSizes, currentSizes, affected)
}

View file

@ -28,7 +28,7 @@ import { TEXT_PICTURE_KEYS } from './text-picture'
import * as Variables from './variables'
import { normalizeVectorNetwork } from './vector-network'
export type { GUID, Color, Vector } from './primitives'
export type { GUID, Color, Vector, Size } from './primitives'
export * from './types'
import type { Emitter } from 'nanoevents'

View file

@ -0,0 +1,203 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { computeAllLayouts, SceneGraph, setTextMeasurer } from '@open-pencil/core'
function importedText(graph: SceneGraph, text: string, width: number, height: number, id: string) {
const page = graph.getPages()[0]
const node = graph.createNode('TEXT', page.id, {
width,
height,
text,
textAutoResize: 'WIDTH_AND_HEIGHT'
})
graph.updateNode(node.id, { source: { ...node.source, format: 'fig', id } })
return node
}
afterEach(() => setTextMeasurer(null))
describe('effective generated FIG text layout', () => {
test('shapes generated text and recomputes its HUG width lineage', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const source = importedText(graph, 'Effective instance text', 342, 20, '1:1')
const checkbox = graph.createNode('INSTANCE', page.id, {
width: 366,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
componentId: 'component'
})
graph.updateNode(checkbox.id, {
source: { ...checkbox.source, format: 'fig', id: '1:5' }
})
graph.createNode('INSTANCE', checkbox.id, { width: 16, height: 16 })
const textWrapper = graph.createNode('FRAME', checkbox.id, {
width: 350,
height: 40,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
paddingLeft: 8,
componentId: 'text-wrapper',
figmaDerivedLayout: { width: 350, height: 40 }
})
const generatedText = graph.createNode('TEXT', textWrapper.id, {
width: 342,
height: 20,
text: source.text,
textAutoResize: 'WIDTH_AND_HEIGHT',
componentId: source.id,
figmaDerivedLayout: { width: 342, height: 20 }
})
setTextMeasurer(() => ({ width: 336, height: 20 }))
computeAllLayouts(graph)
expect(graph.getNode(generatedText.id)).toMatchObject({
width: 336,
height: 20,
figmaDerivedLayout: { width: 336, height: 20 }
})
expect(graph.getNode(textWrapper.id)).toMatchObject({
width: 344,
figmaDerivedLayout: { width: 344, height: 40 }
})
expect(graph.getNode(checkbox.id)).toMatchObject({ width: 360, height: 40 })
expect(graph.getNode(source.id)).toMatchObject({ width: 342, height: 20 })
})
test('preserves fixed generated ancestors after shaping their text', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const source = importedText(graph, 'Choose file', 76, 20, '1:2')
const input = graph.createNode('FRAME', page.id, {
width: 108,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
paddingLeft: 16,
paddingRight: 16,
componentId: 'input',
figmaDerivedLayout: { width: 108, height: 40 }
})
const generatedText = graph.createNode('TEXT', input.id, {
width: 76,
height: 20,
text: source.text,
textAutoResize: 'WIDTH_AND_HEIGHT',
componentId: source.id,
figmaDerivedLayout: { width: 76, height: 20 }
})
setTextMeasurer(() => ({ width: 74, height: 20 }))
computeAllLayouts(graph)
expect(graph.getNode(generatedText.id)).toMatchObject({ width: 74, height: 20 })
expect(graph.getNode(input.id)).toMatchObject({
width: 108,
height: 40,
figmaDerivedLayout: { width: 108, height: 40 }
})
})
test('reshapes stretched generated source text only inside HUG-width parents', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const source = importedText(graph, 'Bar Chart - Interactive', 333, 30, '1:3')
graph.updateNode(source.id, { textAutoResize: 'HEIGHT' })
const cardHeader = graph.createNode('FRAME', page.id, {
width: 381,
height: 102,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
paddingLeft: 24,
paddingRight: 24,
componentId: 'card-header',
figmaDerivedLayout: { width: 381, height: 102 }
})
const generatedText = graph.createNode('TEXT', cardHeader.id, {
width: 333,
height: 30,
text: source.text,
textAutoResize: 'HEIGHT',
layoutAlignSelf: 'STRETCH',
componentId: source.id,
figmaDerivedLayout: { width: 333, height: 30 }
})
setTextMeasurer(() => ({ width: 329, height: 37 }))
computeAllLayouts(graph)
expect(graph.getNode(generatedText.id)).toMatchObject({ width: 329, height: 30 })
expect(graph.getNode(cardHeader.id)).toMatchObject({
width: 377,
height: 102,
figmaDerivedLayout: { width: 377, height: 102 }
})
})
test('preserves fixed-width text inherited through the component lineage', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const source = importedText(graph, 'Label', 38, 14, '1:6')
graph.updateNode(source.id, { textAutoResize: 'WIDTH_AND_HEIGHT' })
const fixedIntermediate = graph.createNode('TEXT', page.id, {
width: 280,
height: 14,
text: 'Email',
textAutoResize: 'HEIGHT',
componentId: source.id,
figmaDerivedLayout: { width: 280, height: 14 }
})
const generatedText = graph.createNode('TEXT', page.id, {
width: 302,
height: 14,
text: 'Name',
textAutoResize: 'WIDTH_AND_HEIGHT',
componentId: fixedIntermediate.id,
figmaDerivedLayout: { width: 302, height: 14 }
})
setTextMeasurer(() => ({ width: 36, height: 17 }))
computeAllLayouts(graph)
expect(graph.getNode(generatedText.id)).toMatchObject({ width: 302, height: 14 })
})
test('preserves stretched override text and direct imported text bounds', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const source = importedText(graph, 'Source copy', 333, 20, '1:4')
graph.updateNode(source.id, { textAutoResize: 'HEIGHT' })
const parent = graph.createNode('FRAME', page.id, {
width: 381,
height: 68,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
paddingLeft: 24,
paddingRight: 24,
componentId: 'parent',
figmaDerivedLayout: { width: 381, height: 68 }
})
const overrideText = graph.createNode('TEXT', parent.id, {
width: 333,
height: 20,
text: 'A different instance override',
textAutoResize: 'HEIGHT',
layoutAlignSelf: 'STRETCH',
componentId: source.id,
figmaDerivedLayout: { width: 333, height: 20 }
})
setTextMeasurer(() => ({ width: 329, height: 20 }))
computeAllLayouts(graph)
expect(graph.getNode(overrideText.id)).toMatchObject({ width: 333, height: 20 })
expect(graph.getNode(source.id)).toMatchObject({ width: 333, height: 20 })
})
})