fix(clipboard): include text glyph blobs

This commit is contained in:
Danila Poyarkov 2026-05-16 11:46:14 +03:00
parent 992be9b713
commit 91f516ecaa
8 changed files with 125 additions and 52 deletions

View file

@ -335,7 +335,7 @@ export async function buildFigmaClipboardHTML(
const source = textNodeQueue.shift() const source = textNodeQueue.shift()
if (!source) return if (!source) return
change.textUserLayoutVersion = 4 change.textUserLayoutVersion = 4
change.derivedTextData = await buildDerivedTextDataV4(source, fontDigestMap) change.derivedTextData = await buildDerivedTextDataV4(source, fontDigestMap, null, blobs)
}) })
) )

View file

@ -331,7 +331,7 @@ export interface NodeChange {
lineAscent: number lineAscent: number
}> }>
glyphs?: Array<{ glyphs?: Array<{
commands: Array<string | number> commandsBlob?: number
position: Vector position: Vector
fontSize: number fontSize: number
firstCharacter: number firstCharacter: number

View file

@ -23,7 +23,8 @@ interface SceneNodeToKiwiContext {
node: SceneNode, node: SceneNode,
nc: KiwiNodeChange, nc: KiwiNodeChange,
graph: SceneGraph, graph: SceneGraph,
fontDigestMap?: Map<string, Uint8Array> fontDigestMap: Map<string, Uint8Array> | undefined,
blobs: Uint8Array[]
) => void ) => void
serializeLayoutProps: (node: SceneNode, nc: KiwiNodeChange) => void serializeLayoutProps: (node: SceneNode, nc: KiwiNodeChange) => void
serializeGeometry: (node: SceneNode, nc: KiwiNodeChange, blobs: Uint8Array[]) => void serializeGeometry: (node: SceneNode, nc: KiwiNodeChange, blobs: Uint8Array[]) => void
@ -128,7 +129,7 @@ function applyNodeVisualProps(
} }
if (node.type === 'TEXT') { 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 nc.frameMaskDisabled = !node.clipsContent

View file

@ -1,6 +1,6 @@
import { buildDerivedTextData as buildSharedDerivedTextData } from '#core/text/derived-text-data' import { buildDerivedTextData as buildSharedDerivedTextData } from '#core/text/derived-text-data'
import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from '#core/text/fonts' 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' import { encodeVectorNetworkBlob, buildStyleOverrideTable } from '#core/vector'
export { export {
buildFigKiwi, buildFigKiwi,
@ -73,7 +73,8 @@ function textLines(text: string): NonNullable<NodeChange['textData']>['lines'] {
function buildDerivedTextData( function buildDerivedTextData(
node: SceneNode, node: SceneNode,
digestMap: Map<string, Uint8Array> digestMap: Map<string, Uint8Array>,
blobs: Uint8Array[]
): NodeChange['derivedTextData'] { ): NodeChange['derivedTextData'] {
const fontMeta: NonNullable<NodeChange['derivedTextData']>['fontMetaData'] = [] const fontMeta: NonNullable<NodeChange['derivedTextData']>['fontMetaData'] = []
const seen = new Set<string>() const seen = new Set<string>()
@ -103,17 +104,16 @@ function buildDerivedTextData(
} }
const style = weightToStyle(node.fontWeight, node.italic) const style = weightToStyle(node.fontWeight, node.italic)
const glyphCommandLists = const glyphMetrics = getGlyphOutlineMetricsSync(node.fontFamily, style, node.text, node.fontSize) ?? []
getGlyphOutlineCommandsSync(node.fontFamily, style, node.text, node.fontSize) ?? []
const lineHeight = node.lineHeight ?? Math.ceil(node.fontSize * 1.2) 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 glyphAdvance = node.text.length > 0 ? node.width / Math.max(node.text.length, 1) : 0
const glyphs = glyphCommandLists.map((commands, index) => ({ const glyphs = glyphMetrics.map((glyph, index) => ({
commands, commandsBlob: blobs.push(glyph.commandsBlob) - 1,
position: { x: index * glyphAdvance, y: lineHeight }, position: { x: glyph.x || index * glyphAdvance, y: lineHeight },
fontSize: node.fontSize, fontSize: node.fontSize,
firstCharacter: index, firstCharacter: index,
advance: glyphAdvance, advance: glyph.advance || glyphAdvance,
rotation: 0 rotation: 0
})) }))
@ -233,7 +233,6 @@ function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void {
} }
function resolveTextAutoResize(node: SceneNode, graph: SceneGraph): SceneNode['textAutoResize'] { function resolveTextAutoResize(node: SceneNode, graph: SceneGraph): SceneNode['textAutoResize'] {
if (node.textAutoResize === 'NONE') return 'NONE'
const parent = node.parentId ? graph.getNode(node.parentId) : undefined const parent = node.parentId ? graph.getNode(node.parentId) : undefined
if ( if (
parent && parent &&
@ -241,7 +240,7 @@ function resolveTextAutoResize(node: SceneNode, graph: SceneGraph): SceneNode['t
parent.layoutMode !== 'GRID' && parent.layoutMode !== 'GRID' &&
node.layoutPositioning !== 'ABSOLUTE' node.layoutPositioning !== 'ABSOLUTE'
) { ) {
return 'NONE' return 'HEIGHT'
} }
return node.textAutoResize return node.textAutoResize
} }
@ -250,7 +249,8 @@ function serializeTextProps(
node: SceneNode, node: SceneNode,
nc: KiwiNodeChange, nc: KiwiNodeChange,
graph: SceneGraph, graph: SceneGraph,
fontDigestMap?: Map<string, Uint8Array> fontDigestMap: Map<string, Uint8Array> | undefined,
blobs: Uint8Array[]
): void { ): void {
upsertPluginData(node, TEXT_DIRECTION_PLUGIN_KEY, node.textDirection) upsertPluginData(node, TEXT_DIRECTION_PLUGIN_KEY, node.textDirection)
nc.fontSize = node.fontSize nc.fontSize = node.fontSize
@ -264,11 +264,8 @@ function serializeTextProps(
if (autoResize !== 'NONE') nc.textAutoResize = autoResize if (autoResize !== 'NONE') nc.textAutoResize = autoResize
nc.textAlignHorizontal = node.textAlignHorizontal nc.textAlignHorizontal = node.textAlignHorizontal
nc.textUserLayoutVersion = 4 nc.textUserLayoutVersion = 4
if (fontDigestMap) nc.derivedTextData = buildDerivedTextData(node, fontDigestMap) if (fontDigestMap) nc.derivedTextData = buildDerivedTextData(node, fontDigestMap, blobs)
// Figma needs explicit lineHeight to compute text bounding boxes. if (node.lineHeight != null) nc.lineHeight = { value: node.lineHeight, units: 'PIXELS' }
// 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 (node.letterSpacing !== 0) nc.letterSpacing = { value: node.letterSpacing, units: 'PIXELS' } if (node.letterSpacing !== 0) nc.letterSpacing = { value: node.letterSpacing, units: 'PIXELS' }
if (node.textDecoration !== 'NONE') { if (node.textDecoration !== 'NONE') {
nc.textDecoration = node.textDecoration === 'UNDERLINE' ? 'UNDERLINE' : 'STRIKETHROUGH' 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.layoutPositioning === 'ABSOLUTE') nc.stackPositioning = 'ABSOLUTE'
if (node.layoutGrow > 0) nc.stackChildPrimaryGrow = node.layoutGrow if (node.layoutGrow > 0) nc.stackChildPrimaryGrow = node.layoutGrow
if (node.layoutAlignSelf !== 'AUTO') { if (node.type !== 'TEXT' && node.layoutAlignSelf !== 'AUTO') {
nc.stackChildAlignSelf = node.layoutAlignSelf nc.stackChildAlignSelf = node.layoutAlignSelf
} }
} }

View file

@ -22,7 +22,8 @@ export interface ShapedClipboardText {
export async function buildDerivedTextDataV4( export async function buildDerivedTextDataV4(
node: SceneNode, node: SceneNode,
digestMap: Map<string, Uint8Array>, digestMap: Map<string, Uint8Array>,
shaped?: ShapedClipboardText | null shaped?: ShapedClipboardText | null,
blobs?: Uint8Array[]
): Promise<NodeChange['derivedTextData']> { ): Promise<NodeChange['derivedTextData']> {
const style = weightToStyle(node.fontWeight, node.italic) const style = weightToStyle(node.fontWeight, node.italic)
const normalizedFamily = normalizeFontFamily(node.fontFamily) const normalizedFamily = normalizeFontFamily(node.fontFamily)
@ -35,8 +36,9 @@ export async function buildDerivedTextDataV4(
const shapedGlyph = shaped?.glyphs[index] const shapedGlyph = shaped?.glyphs[index]
const fallbackX = glyph.x || index * fallbackAdvance const fallbackX = glyph.x || index * fallbackAdvance
const fallbackGlyphAdvance = glyph.advance || fallbackAdvance const fallbackGlyphAdvance = glyph.advance || fallbackAdvance
const commandsBlob = blobs ? blobs.push(glyph.commandsBlob) - 1 : undefined
return { return {
commands: glyph.commands, commandsBlob,
position: { position: {
x: shapedGlyph?.x ?? fallbackX, x: shapedGlyph?.x ?? fallbackX,
y: shapedGlyph?.y ?? shaped?.baseline ?? lineHeightFallback y: shapedGlyph?.y ?? shaped?.baseline ?? lineHeightFallback

View file

@ -80,26 +80,60 @@ export function measureTextWithOpenType(
return { width: Math.ceil(singleLineWidth), height: lineH } return { width: Math.ceil(singleLineWidth), height: lineH }
} }
function commandsToFigmaNumbers(commands: OutlineCommand[]): Array<string | number> {
const result: Array<string | number> = []
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 { export interface GlyphOutlineMetrics {
commands: Array<string | number> commandsBlob: Uint8Array
x: number x: number
advance: 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( export function getGlyphOutlineMetricsSync(
family: string, family: string,
style: string, style: string,
@ -113,23 +147,14 @@ export function getGlyphOutlineMetricsSync(
let x = 0 let x = 0
const scale = fontSize / font.unitsPerEm const scale = fontSize / font.unitsPerEm
return glyphs.map((glyph) => { 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 advance = (glyph.advanceWidth ?? 0) * scale
const metrics = { commands, x, advance } const metrics = { commandsBlob, x, advance }
x += advance x += advance
return metrics return metrics
}) })
} }
export function getGlyphOutlineCommandsSync(
family: string,
style: string,
text: string,
fontSize: number
): Array<Array<string | number>> | null {
return getGlyphOutlineMetricsSync(family, style, text, fontSize)?.map((glyph) => glyph.commands) ?? null
}
export async function probeGlyphOutlineCommands( export async function probeGlyphOutlineCommands(
family: string, family: string,
style: string, style: string,

View file

@ -33,6 +33,7 @@ describe('clipboard derived text export', () => {
}) })
const fontDigestMap = await buildFontDigestMap(graph) const fontDigestMap = await buildFontDigestMap(graph)
const blobs: Uint8Array[] = []
const derived = await buildDerivedTextDataV4(text, fontDigestMap, { const derived = await buildDerivedTextDataV4(text, fontDigestMap, {
lineHeight: 20, lineHeight: 20,
lineAscent: 15, lineAscent: 15,
@ -46,7 +47,7 @@ describe('clipboard derived text export', () => {
{ firstCharacter: 4, x: 32, y: 16, advance: 10 } { firstCharacter: 4, x: 32, y: 16, advance: 10 }
], ],
logicalIndexToCharacterOffsetMap: [0, 8, 16, 24, 32, 42] logicalIndexToCharacterOffsetMap: [0, 8, 16, 24, 32, 42]
}) }, blobs)
const derivedTextData = expectDefined(derived, 'derived text data') const derivedTextData = expectDefined(derived, 'derived text data')
const firstGlyph = expectDefined(derivedTextData.glyphs[0], 'first glyph') const firstGlyph = expectDefined(derivedTextData.glyphs[0], 'first glyph')
@ -63,7 +64,8 @@ describe('clipboard derived text export', () => {
expect(line.directionality).toBe('LTR') expect(line.directionality).toBe('LTR')
expect(derivedTextData.truncationStartIndex).toBe(-1) expect(derivedTextData.truncationStartIndex).toBe(-1)
expect(derivedTextData.truncatedHeight).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(firstGlyph.position.x).toBe(0)
expect(lastGlyph.position.x).toBe(32) expect(lastGlyph.position.x).toBe(32)
expect(baseline.lineHeight).toBe(20) expect(baseline.lineHeight).toBe(20)
@ -90,9 +92,12 @@ describe('clipboard derived text export', () => {
}) })
const fontDigestMap = await buildFontDigestMap(graph) 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.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].position.x).toBeGreaterThan(0)
expect(derived.glyphs[1].advance).toBeGreaterThan(0) expect(derived.glyphs[1].advance).toBeGreaterThan(0)
expect(derived.logicalIndexToCharacterOffsetMap[1]).toBeGreaterThan(0) expect(derived.logicalIndexToCharacterOffsetMap[1]).toBeGreaterThan(0)

View file

@ -158,6 +158,49 @@ describe('text node export', () => {
expect((fontMetaData[0].key as Record<string, unknown>).style).toBe('Semi Bold') expect((fontMetaData[0].key as Record<string, unknown>).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<string, unknown>
}
const message = compiled.decodeMessage(inflateSync(chunks?.[1] ?? new Uint8Array()))
const nodeChanges = message.nodeChanges as Array<Record<string, unknown>>
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 () => { test('style runs produce multiple fontMetaData entries', async () => {
await initCodec() await initCodec()