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()
if (!source) return
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
}>
glyphs?: Array<{
commands: Array<string | number>
commandsBlob?: number
position: Vector
fontSize: number
firstCharacter: number

View file

@ -23,7 +23,8 @@ interface SceneNodeToKiwiContext {
node: SceneNode,
nc: KiwiNodeChange,
graph: SceneGraph,
fontDigestMap?: Map<string, Uint8Array>
fontDigestMap: Map<string, Uint8Array> | 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

View file

@ -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<NodeChange['textData']>['lines'] {
function buildDerivedTextData(
node: SceneNode,
digestMap: Map<string, Uint8Array>
digestMap: Map<string, Uint8Array>,
blobs: Uint8Array[]
): NodeChange['derivedTextData'] {
const fontMeta: NonNullable<NodeChange['derivedTextData']>['fontMetaData'] = []
const seen = new Set<string>()
@ -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<string, Uint8Array>
fontDigestMap: Map<string, Uint8Array> | 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
}
}

View file

@ -22,7 +22,8 @@ export interface ShapedClipboardText {
export async function buildDerivedTextDataV4(
node: SceneNode,
digestMap: Map<string, Uint8Array>,
shaped?: ShapedClipboardText | null
shaped?: ShapedClipboardText | null,
blobs?: Uint8Array[]
): Promise<NodeChange['derivedTextData']> {
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

View file

@ -80,26 +80,60 @@ export function measureTextWithOpenType(
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 {
commands: Array<string | number>
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<Array<string | number>> | null {
return getGlyphOutlineMetricsSync(family, style, text, fontSize)?.map((glyph) => glyph.commands) ?? null
}
export async function probeGlyphOutlineCommands(
family: string,
style: string,

View file

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

View file

@ -158,6 +158,49 @@ describe('text node export', () => {
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 () => {
await initCodec()