diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index 5ed1447b2..438a792b6 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -335,7 +335,7 @@ export async function buildFigmaClipboardHTML( const source = textNodeQueue.shift() if (!source) return change.textUserLayoutVersion = 4 - change.derivedTextData = await buildDerivedTextDataV4(source, fontDigestMap) + change.derivedTextData = await buildDerivedTextDataV4(source, fontDigestMap, null, blobs) }) ) diff --git a/packages/core/src/kiwi/binary/codec.ts b/packages/core/src/kiwi/binary/codec.ts index e7ddb0010..7922ab37a 100644 --- a/packages/core/src/kiwi/binary/codec.ts +++ b/packages/core/src/kiwi/binary/codec.ts @@ -331,7 +331,7 @@ export interface NodeChange { lineAscent: number }> glyphs?: Array<{ - commands: Array + commandsBlob?: number position: Vector fontSize: number firstCharacter: number diff --git a/packages/core/src/kiwi/node-change/export-node.ts b/packages/core/src/kiwi/node-change/export-node.ts index f07bb8a85..b4ddc1f60 100644 --- a/packages/core/src/kiwi/node-change/export-node.ts +++ b/packages/core/src/kiwi/node-change/export-node.ts @@ -23,7 +23,8 @@ interface SceneNodeToKiwiContext { node: SceneNode, nc: KiwiNodeChange, graph: SceneGraph, - fontDigestMap?: Map + fontDigestMap: Map | undefined, + blobs: Uint8Array[] ) => void serializeLayoutProps: (node: SceneNode, nc: KiwiNodeChange) => void serializeGeometry: (node: SceneNode, nc: KiwiNodeChange, blobs: Uint8Array[]) => void @@ -128,7 +129,7 @@ function applyNodeVisualProps( } if (node.type === 'TEXT') { - context.serializeTextProps(node, nc, context.graph, context.fontDigestMap) + context.serializeTextProps(node, nc, context.graph, context.fontDigestMap, context.blobs) } nc.frameMaskDisabled = !node.clipsContent diff --git a/packages/core/src/kiwi/node-change/serialize.ts b/packages/core/src/kiwi/node-change/serialize.ts index f66f9170c..07ad50a06 100644 --- a/packages/core/src/kiwi/node-change/serialize.ts +++ b/packages/core/src/kiwi/node-change/serialize.ts @@ -1,6 +1,6 @@ import { buildDerivedTextData as buildSharedDerivedTextData } from '#core/text/derived-text-data' import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from '#core/text/fonts' -import { getGlyphOutlineCommandsSync } from '#core/text/opentype' +import { getGlyphOutlineMetricsSync } from '#core/text/opentype' import { encodeVectorNetworkBlob, buildStyleOverrideTable } from '#core/vector' export { buildFigKiwi, @@ -73,7 +73,8 @@ function textLines(text: string): NonNullable['lines'] { function buildDerivedTextData( node: SceneNode, - digestMap: Map + digestMap: Map, + blobs: Uint8Array[] ): NodeChange['derivedTextData'] { const fontMeta: NonNullable['fontMetaData'] = [] const seen = new Set() @@ -103,17 +104,16 @@ function buildDerivedTextData( } const style = weightToStyle(node.fontWeight, node.italic) - const glyphCommandLists = - getGlyphOutlineCommandsSync(node.fontFamily, style, node.text, node.fontSize) ?? [] + const glyphMetrics = getGlyphOutlineMetricsSync(node.fontFamily, style, node.text, node.fontSize) ?? [] const lineHeight = node.lineHeight ?? Math.ceil(node.fontSize * 1.2) const glyphAdvance = node.text.length > 0 ? node.width / Math.max(node.text.length, 1) : 0 - const glyphs = glyphCommandLists.map((commands, index) => ({ - commands, - position: { x: index * glyphAdvance, y: lineHeight }, + const glyphs = glyphMetrics.map((glyph, index) => ({ + commandsBlob: blobs.push(glyph.commandsBlob) - 1, + position: { x: glyph.x || index * glyphAdvance, y: lineHeight }, fontSize: node.fontSize, firstCharacter: index, - advance: glyphAdvance, + advance: glyph.advance || glyphAdvance, rotation: 0 })) @@ -233,7 +233,6 @@ function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void { } function resolveTextAutoResize(node: SceneNode, graph: SceneGraph): SceneNode['textAutoResize'] { - if (node.textAutoResize === 'NONE') return 'NONE' const parent = node.parentId ? graph.getNode(node.parentId) : undefined if ( parent && @@ -241,7 +240,7 @@ function resolveTextAutoResize(node: SceneNode, graph: SceneGraph): SceneNode['t parent.layoutMode !== 'GRID' && node.layoutPositioning !== 'ABSOLUTE' ) { - return 'NONE' + return 'HEIGHT' } return node.textAutoResize } @@ -250,7 +249,8 @@ function serializeTextProps( node: SceneNode, nc: KiwiNodeChange, graph: SceneGraph, - fontDigestMap?: Map + fontDigestMap: Map | undefined, + blobs: Uint8Array[] ): void { upsertPluginData(node, TEXT_DIRECTION_PLUGIN_KEY, node.textDirection) nc.fontSize = node.fontSize @@ -264,11 +264,8 @@ function serializeTextProps( if (autoResize !== 'NONE') nc.textAutoResize = autoResize nc.textAlignHorizontal = node.textAlignHorizontal nc.textUserLayoutVersion = 4 - if (fontDigestMap) nc.derivedTextData = buildDerivedTextData(node, fontDigestMap) - // Figma needs explicit lineHeight to compute text bounding boxes. - // Without it (and without baselines/glyphs data), text gets 0 height. - const lh = node.lineHeight != null ? node.lineHeight : Math.ceil(node.fontSize * 1.2) - nc.lineHeight = { value: lh, units: 'PIXELS' } + if (fontDigestMap) nc.derivedTextData = buildDerivedTextData(node, fontDigestMap, blobs) + if (node.lineHeight != null) nc.lineHeight = { value: node.lineHeight, units: 'PIXELS' } if (node.letterSpacing !== 0) nc.letterSpacing = { value: node.letterSpacing, units: 'PIXELS' } if (node.textDecoration !== 'NONE') { nc.textDecoration = node.textDecoration === 'UNDERLINE' ? 'UNDERLINE' : 'STRIKETHROUGH' @@ -294,7 +291,7 @@ function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void { } if (node.layoutPositioning === 'ABSOLUTE') nc.stackPositioning = 'ABSOLUTE' if (node.layoutGrow > 0) nc.stackChildPrimaryGrow = node.layoutGrow - if (node.layoutAlignSelf !== 'AUTO') { + if (node.type !== 'TEXT' && node.layoutAlignSelf !== 'AUTO') { nc.stackChildAlignSelf = node.layoutAlignSelf } } diff --git a/packages/core/src/text/clipboard-derived-text.ts b/packages/core/src/text/clipboard-derived-text.ts index bf0e72386..3977c9c1a 100644 --- a/packages/core/src/text/clipboard-derived-text.ts +++ b/packages/core/src/text/clipboard-derived-text.ts @@ -22,7 +22,8 @@ export interface ShapedClipboardText { export async function buildDerivedTextDataV4( node: SceneNode, digestMap: Map, - shaped?: ShapedClipboardText | null + shaped?: ShapedClipboardText | null, + blobs?: Uint8Array[] ): Promise { const style = weightToStyle(node.fontWeight, node.italic) const normalizedFamily = normalizeFontFamily(node.fontFamily) @@ -35,8 +36,9 @@ export async function buildDerivedTextDataV4( const shapedGlyph = shaped?.glyphs[index] const fallbackX = glyph.x || index * fallbackAdvance const fallbackGlyphAdvance = glyph.advance || fallbackAdvance + const commandsBlob = blobs ? blobs.push(glyph.commandsBlob) - 1 : undefined return { - commands: glyph.commands, + commandsBlob, position: { x: shapedGlyph?.x ?? fallbackX, y: shapedGlyph?.y ?? shaped?.baseline ?? lineHeightFallback diff --git a/packages/core/src/text/opentype.ts b/packages/core/src/text/opentype.ts index 8e3d8331d..02ba6f8cb 100644 --- a/packages/core/src/text/opentype.ts +++ b/packages/core/src/text/opentype.ts @@ -80,26 +80,60 @@ export function measureTextWithOpenType( return { width: Math.ceil(singleLineWidth), height: lineH } } -function commandsToFigmaNumbers(commands: OutlineCommand[]): Array { - const result: Array = [] - for (const command of commands) { - result.push(command.type) - if (command.x1 !== undefined) result.push(command.x1) - if (command.y1 !== undefined) result.push(command.y1) - if (command.x2 !== undefined) result.push(command.x2) - if (command.y2 !== undefined) result.push(command.y2) - if (command.x !== undefined) result.push(command.x) - if (command.y !== undefined) result.push(command.y) - } - return result -} - export interface GlyphOutlineMetrics { - commands: Array + commandsBlob: Uint8Array x: number advance: number } +const CMD_CLOSE = 0 +const CMD_MOVE_TO = 1 +const CMD_LINE_TO = 2 +const CMD_CUBIC_TO = 4 + +function commandsToBlob(commands: OutlineCommand[], fontSize: number): Uint8Array { + const bytes: number[] = [] + const pushFloat = (value: number | undefined) => { + const buf = new ArrayBuffer(4) + new DataView(buf).setFloat32(0, (value ?? 0) / fontSize, true) + bytes.push(...new Uint8Array(buf)) + } + + for (const command of commands) { + switch (command.type) { + case 'M': + bytes.push(CMD_MOVE_TO) + pushFloat(command.x) + pushFloat(command.y) + break + case 'L': + bytes.push(CMD_LINE_TO) + pushFloat(command.x) + pushFloat(command.y) + break + case 'C': + bytes.push(CMD_CUBIC_TO) + pushFloat(command.x1) + pushFloat(command.y1) + pushFloat(command.x2) + pushFloat(command.y2) + pushFloat(command.x) + pushFloat(command.y) + break + case 'Q': + bytes.push(CMD_LINE_TO) + pushFloat(command.x) + pushFloat(command.y) + break + case 'Z': + bytes.push(CMD_CLOSE) + break + } + } + + return new Uint8Array(bytes) +} + export function getGlyphOutlineMetricsSync( family: string, style: string, @@ -113,23 +147,14 @@ export function getGlyphOutlineMetricsSync( let x = 0 const scale = fontSize / font.unitsPerEm return glyphs.map((glyph) => { - const commands = commandsToFigmaNumbers(glyph.getPath(0, 0, fontSize).commands) + const commandsBlob = commandsToBlob(glyph.getPath(0, 0, fontSize).commands, fontSize) const advance = (glyph.advanceWidth ?? 0) * scale - const metrics = { commands, x, advance } + const metrics = { commandsBlob, x, advance } x += advance return metrics }) } -export function getGlyphOutlineCommandsSync( - family: string, - style: string, - text: string, - fontSize: number -): Array> | null { - return getGlyphOutlineMetricsSync(family, style, text, fontSize)?.map((glyph) => glyph.commands) ?? null -} - export async function probeGlyphOutlineCommands( family: string, style: string, diff --git a/tests/engine/editor/clipboard/derived-text.test.ts b/tests/engine/editor/clipboard/derived-text.test.ts index 5d4556991..0ebd81a0a 100644 --- a/tests/engine/editor/clipboard/derived-text.test.ts +++ b/tests/engine/editor/clipboard/derived-text.test.ts @@ -33,6 +33,7 @@ describe('clipboard derived text export', () => { }) const fontDigestMap = await buildFontDigestMap(graph) + const blobs: Uint8Array[] = [] const derived = await buildDerivedTextDataV4(text, fontDigestMap, { lineHeight: 20, lineAscent: 15, @@ -46,7 +47,7 @@ describe('clipboard derived text export', () => { { firstCharacter: 4, x: 32, y: 16, advance: 10 } ], logicalIndexToCharacterOffsetMap: [0, 8, 16, 24, 32, 42] - }) + }, blobs) const derivedTextData = expectDefined(derived, 'derived text data') const firstGlyph = expectDefined(derivedTextData.glyphs[0], 'first glyph') @@ -63,7 +64,8 @@ describe('clipboard derived text export', () => { expect(line.directionality).toBe('LTR') expect(derivedTextData.truncationStartIndex).toBe(-1) expect(derivedTextData.truncatedHeight).toBe(-1) - expect(firstGlyph.commands.length).toBeGreaterThan(0) + expect(firstGlyph.commandsBlob).toBe(0) + expect(blobs[0].length).toBeGreaterThan(0) expect(firstGlyph.position.x).toBe(0) expect(lastGlyph.position.x).toBe(32) expect(baseline.lineHeight).toBe(20) @@ -90,9 +92,12 @@ describe('clipboard derived text export', () => { }) const fontDigestMap = await buildFontDigestMap(graph) - const derived = expectDefined(await buildDerivedTextDataV4(text, fontDigestMap), 'derived text') + const blobs: Uint8Array[] = [] + const derived = expectDefined(await buildDerivedTextDataV4(text, fontDigestMap, null, blobs), 'derived text') expect(derived.fontMetaData[0].key.style).toBe('Semi Bold') + expect(derived.glyphs[0].commandsBlob).toBe(0) + expect(blobs[0].length).toBeGreaterThan(0) expect(derived.glyphs[1].position.x).toBeGreaterThan(0) expect(derived.glyphs[1].advance).toBeGreaterThan(0) expect(derived.logicalIndexToCharacterOffsetMap[1]).toBeGreaterThan(0) diff --git a/tests/engine/fig/export/text.test.ts b/tests/engine/fig/export/text.test.ts index 87fc2fb42..fd1874114 100644 --- a/tests/engine/fig/export/text.test.ts +++ b/tests/engine/fig/export/text.test.ts @@ -158,6 +158,49 @@ describe('text node export', () => { expect((fontMetaData[0].key as Record).style).toBe('Semi Bold') }) + test('auto-layout text children export height auto-resize for Figma rendering', async () => { + await initCodec() + + const { unzipSync, inflateSync } = await import('fflate') + const { decodeBinarySchema, compileSchema, ByteBuffer } = await import('#core/kiwi/kiwi-schema') + const { parseFigKiwiChunks } = await import('@open-pencil/core') + + const graph = new SceneGraph() + const page = graph.getPages()[0] + const frame = graph.createNode('FRAME', page.id, { + name: 'Card', + layoutMode: 'VERTICAL', + width: 280, + height: 160 + }) + graph.createNode('TEXT', frame.id, { + name: 'Body', + text: 'Track your key metrics and performance indicators in real time.', + width: 240, + height: 36, + fontFamily: 'Inter', + fontSize: 13, + fontWeight: 400, + textAutoResize: 'NONE' + }) + + const exported = await exportFigFile(graph) + const zip = unzipSync(new Uint8Array(exported)) + const chunks = parseFigKiwiChunks(zip['canvas.fig'] ?? zip['canvas']) + const schemaBytes = inflateSync(chunks?.[0] ?? new Uint8Array()) + const schema = decodeBinarySchema(new ByteBuffer(schemaBytes)) + const compiled = compileSchema(schema) as { + decodeMessage(data: Uint8Array): Record + } + const message = compiled.decodeMessage(inflateSync(chunks?.[1] ?? new Uint8Array())) + const nodeChanges = message.nodeChanges as Array> + const textNc = expectDefined(nodeChanges.find((nc) => nc.type === 'TEXT'), 'text node change') + + expect(textNc.textAutoResize).toBe('HEIGHT') + expect(textNc.lineHeight).toBeUndefined() + expect(textNc.stackChildAlignSelf).toBeUndefined() + }) + test('style runs produce multiple fontMetaData entries', async () => { await initCodec()