Emit derivedTextData and textUserLayoutVersion for text nodes

Figma needs derivedTextData (layoutSize, fontMetaData with font digest)
and textUserLayoutVersion to render text in .fig files. Without these
fields, text nodes appear as empty boxes when opened in Figma.

- Add DerivedTextData message to kiwi schema (field 359)
- Add derivedTextData and textUserLayoutVersion to NodeChange codec
- Compute SHA-1 font digests from loaded font binaries at export time
- Emit textData.lines with lineType: PLAIN for each paragraph
- Expose getLoadedFontData() from fonts module for digest computation

Closes #64
This commit is contained in:
Danila Poyarkov 2026-03-08 23:28:46 +03:00
parent 5d259b95c3
commit 4a6dd703c8
8 changed files with 298 additions and 7 deletions

View file

@ -11,6 +11,7 @@
### Fixes
- Serialize variables, collections, and bindings to `.fig` files — previously lost on save (#65)
- Text nodes created via MCP now render in Figma — emit `derivedTextData` with font metadata and layout size (#64)
- Double-click on layer tree no longer toggles expand/collapse — use the chevron instead
- Page rename input matches layer rename styling

View file

@ -1,7 +1,7 @@
import { zipSync, deflateSync } from 'fflate'
import { CANVAS_BG_COLOR, IS_TAURI } from './constants'
import { sceneNodeToKiwi, fractionalPosition, buildFigKiwi } from './kiwi-serialize'
import { sceneNodeToKiwi, fractionalPosition, buildFigKiwi, buildFontDigestMap } from './kiwi-serialize'
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/codec'
import { stringToGuid } from './kiwi/kiwi-convert'
import { renderThumbnail } from './render-image'
@ -82,6 +82,7 @@ export async function exportFigFile(
const blobs: Uint8Array[] = []
const pages = graph.getPages(true)
const nodeIdToGuid = new Map<string, { sessionID: number; localID: number }>()
const fontDigestMap = await buildFontDigestMap(graph)
let internalCanvasGuid: { sessionID: number; localID: number } | null = null
for (let p = 0; p < pages.length; p++) {
@ -113,7 +114,7 @@ export async function exportFigFile(
const children = graph.getChildren(page.id)
for (let i = 0; i < children.length; i++) {
nodeChanges.push(
...sceneNodeToKiwi(children[i], canvasGuid, i, localIdCounter, graph, blobs, nodeIdToGuid)
...sceneNodeToKiwi(children[i], canvasGuid, i, localIdCounter, graph, blobs, nodeIdToGuid, fontDigestMap)
)
}
}

View file

@ -224,6 +224,10 @@ export function isFontLoaded(family: string): boolean {
return [...loadedFamilies.keys()].some((k) => k.startsWith(`${family}|`))
}
export function getLoadedFontData(family: string, style: string): ArrayBuffer | null {
return loadedFamilies.get(`${family}|${style}`) ?? null
}
export function collectFontKeys(graph: SceneGraph, nodeIds: string[]): Array<[string, string]> {
const fontKeys = new Set<string>()
const collect = (id: string) => {

View file

@ -83,6 +83,7 @@ export {
initFontService,
getFontProvider,
isFontLoaded,
getLoadedFontData,
markFontLoaded,
ensureNodeFont,
ensureCJKFallback,

View file

@ -2,13 +2,56 @@ export const FIG_KIWI_VERSION = 106
import { deflateSync, inflateSync } from 'fflate'
import { weightToStyle } from './fonts'
import { weightToStyle, getLoadedFontData, styleToWeight } from './fonts'
import { encodeVectorNetworkBlob } from './vector'
import { stringToGuid, VARIABLE_BINDING_FIELDS } from './kiwi/kiwi-convert'
import type { NodeChange, Paint, VariableConsumptionEntry } from './kiwi/codec'
import type { SceneGraph, SceneNode, CharacterStyleOverride } from './scene-graph'
const fontDigestCache = new Map<string, Uint8Array>()
async function computeFontDigest(data: ArrayBuffer): Promise<Uint8Array> {
if (typeof crypto !== 'undefined' && crypto.subtle) {
const hash = await crypto.subtle.digest('SHA-1', data)
return new Uint8Array(hash)
}
return new Uint8Array(20)
}
async function getFontDigest(family: string, style: string): Promise<Uint8Array | null> {
const key = `${family}|${style}`
if (fontDigestCache.has(key)) return fontDigestCache.get(key)!
const data = getLoadedFontData(family, style)
if (!data) return null
const digest = await computeFontDigest(data)
fontDigestCache.set(key, digest)
return digest
}
export async function buildFontDigestMap(graph: SceneGraph): Promise<Map<string, Uint8Array>> {
const fontKeys = new Set<string>()
for (const node of graph.getAllNodes()) {
if (node.type !== 'TEXT') continue
const baseStyle = weightToStyle(node.fontWeight, node.italic)
fontKeys.add(`${node.fontFamily}|${baseStyle}`)
for (const run of node.styleRuns) {
const family = run.style.fontFamily ?? node.fontFamily
const weight = run.style.fontWeight ?? node.fontWeight
const italic = run.style.italic ?? node.italic
fontKeys.add(`${family}|${weightToStyle(weight, italic)}`)
}
}
const result = new Map<string, Uint8Array>()
for (const key of fontKeys) {
const [family, style] = key.split('|')
const digest = await getFontDigest(family, style)
if (digest) result.set(key, digest)
}
return result
}
type KiwiNodeChange = NodeChange & Record<string, unknown>
export function parseFigKiwiChunks(binary: Uint8Array): Uint8Array[] | null {
@ -111,10 +154,51 @@ export function fractionalPosition(index: number): string {
return String.fromCharCode('!'.charCodeAt(0) + index)
}
function textLines(text: string): NonNullable<NodeChange['textData']>['lines'] {
const lineCount = Math.max(1, text.split('\n').length)
return Array.from({ length: lineCount }, () => ({ lineType: 'PLAIN' }))
}
function buildDerivedTextData(
node: SceneNode,
digestMap: Map<string, Uint8Array>
): NodeChange['derivedTextData'] {
const fontMeta: NonNullable<NodeChange['derivedTextData']>['fontMetaData'] = []
const seen = new Set<string>()
const addFont = (family: string, weight: number, italic: boolean) => {
const style = weightToStyle(weight, italic)
const key = `${family}|${style}`
if (seen.has(key)) return
seen.add(key)
fontMeta!.push({
key: { family, style, postscript: '' },
fontLineHeight: 1.2,
fontDigest: digestMap.get(key),
fontStyle: italic ? 'ITALIC' : 'NORMAL',
fontWeight: weight
})
}
addFont(node.fontFamily, node.fontWeight, node.italic)
for (const run of node.styleRuns) {
addFont(
run.style.fontFamily ?? node.fontFamily,
run.style.fontWeight ?? node.fontWeight,
run.style.italic ?? node.italic
)
}
return {
layoutSize: { x: node.width, y: node.height },
fontMetaData: fontMeta
}
}
function exportTextData(node: SceneNode): NodeChange['textData'] {
const runs = node.styleRuns
if (runs.length === 0) {
return { characters: node.text }
return { characters: node.text, lines: textLines(node.text) }
}
const charIds = new Array<number>(node.text.length).fill(0)
@ -156,6 +240,7 @@ function exportTextData(node: SceneNode): NodeChange['textData'] {
return {
characters: node.text,
lines: textLines(node.text),
characterStyleIDs: charIds,
styleOverrideTable: overrideTable
}
@ -168,7 +253,8 @@ export function sceneNodeToKiwi(
localIdCounter: { value: number },
graph: SceneGraph,
blobs: Uint8Array[],
nodeIdToGuid?: Map<string, { sessionID: number; localID: number }>
nodeIdToGuid?: Map<string, { sessionID: number; localID: number }>,
fontDigestMap?: Map<string, Uint8Array>
): KiwiNodeChange[] {
const localID = localIdCounter.value++
const guid = { sessionID: 1, localID }
@ -270,6 +356,8 @@ export function sceneNodeToKiwi(
nc.textData = exportTextData(node)
nc.textAutoResize = 'WIDTH_AND_HEIGHT'
nc.textAlignHorizontal = node.textAlignHorizontal
nc.textUserLayoutVersion = 3
if (fontDigestMap) nc.derivedTextData = buildDerivedTextData(node, fontDigestMap)
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') {
@ -348,7 +436,7 @@ export function sceneNodeToKiwi(
const result: KiwiNodeChange[] = [nc]
const children = graph.getChildren(node.id)
for (let i = 0; i < children.length; i++) {
result.push(...sceneNodeToKiwi(children[i], guid, i, localIdCounter, graph, blobs, nodeIdToGuid))
result.push(...sceneNodeToKiwi(children[i], guid, i, localIdCounter, graph, blobs, nodeIdToGuid, fontDigestMap))
}
return result

View file

@ -291,10 +291,24 @@ export interface NodeChange {
textAutoResize?: string
textData?: {
characters: string
lines?: unknown[]
lines?: Array<{ lineType?: string; styleId?: number; indentationLevel?: number }>
characterStyleIDs?: number[]
styleOverrideTable?: NodeChange[]
}
derivedTextData?: {
layoutSize?: { x: number; y: number }
fontMetaData?: Array<{
key: { family: string; style: string; postscript?: string }
fontLineHeight: number
fontDigest?: Uint8Array | Record<string, number>
fontStyle?: string
fontWeight?: number
}>
truncationStartIndex?: number
truncatedHeight?: number
}
textUserLayoutVersion?: number
textDecoration?: string
lineHeight?: { value: number; units: string }
letterSpacing?: { value: number; units: string }
// Symbol/Instance

View file

@ -899,6 +899,17 @@ message Decoration {
uint styleID = 2;
}
message DerivedTextData {
Vector layoutSize = 1;
Baseline[] baselines = 2;
Glyph[] glyphs = 3;
Decoration[] decorations = 4;
FontMetaData[] fontMetaData = 6;
HyperlinkBox[] hyperlinkBoxes = 7;
int truncationStartIndex = 8;
float truncatedHeight = 9;
}
message VectorData {
uint vectorNetworkBlob = 1;
Vector normalizedSize = 2;
@ -1487,6 +1498,7 @@ message NodeChange {
VariableResolvedDataType variableResolvedType = 314;
VariableDataValues variableDataValues = 315;
VariableScope[] variableScopes = 353;
DerivedTextData derivedTextData = 359;
uint gridRowCount = 435;
uint gridColumnCount = 436;
float gridRowGap = 437;

View file

@ -852,6 +852,176 @@ describe('edge cases', () => {
})
})
describe('text node export', () => {
test('text nodes have derivedTextData and textUserLayoutVersion', async () => {
await initCodec()
const graph = new SceneGraph()
const page = graph.getPages()[0]
graph.createNode('TEXT', page.id, {
name: 'Greeting',
text: 'Hello World',
width: 120,
height: 24,
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 16
})
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const textNode = [...reimported.getAllNodes()].find((n) => n.name === 'Greeting')!
expect(textNode).toBeDefined()
expect(textNode.type).toBe('TEXT')
expect(textNode.text).toBe('Hello World')
expect(textNode.fontFamily).toBe('Inter')
expect(textNode.fontSize).toBe(16)
})
test('text node has lines in textData', async () => {
await initCodec()
const graph = new SceneGraph()
const page = graph.getPages()[0]
graph.createNode('TEXT', page.id, {
name: 'Multiline',
text: 'Line 1\nLine 2\nLine 3',
width: 100,
height: 60,
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 14
})
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const textNode = [...reimported.getAllNodes()].find((n) => n.name === 'Multiline')!
expect(textNode).toBeDefined()
expect(textNode.text).toBe('Line 1\nLine 2\nLine 3')
})
test('derivedTextData fields present in raw binary', async () => {
await initCodec()
const { unzipSync, inflateSync } = await import('fflate')
const { decodeBinarySchema, compileSchema, ByteBuffer } = await import(
'../../packages/core/src/kiwi/kiwi-schema'
)
const { parseFigKiwiChunks } = await import('@open-pencil/core')
const graph = new SceneGraph()
const page = graph.getPages()[0]
graph.createNode('TEXT', page.id, {
name: 'Raw Test',
text: 'Check binary',
width: 80,
height: 18,
fontFamily: 'Roboto',
fontWeight: 700,
fontSize: 12
})
const exported = await exportFigFile(graph)
const zip = unzipSync(new Uint8Array(exported))
const canvasData = zip['canvas.fig'] ?? zip['canvas']
expect(canvasData).toBeDefined()
const chunks = parseFigKiwiChunks(canvasData)
expect(chunks).not.toBeNull()
expect(chunks!.length).toBeGreaterThanOrEqual(2)
const schemaBytes = inflateSync(chunks![0])
const schema = decodeBinarySchema(new ByteBuffer(schemaBytes))
const compiled = compileSchema(schema) as { decodeMessage(data: Uint8Array): any }
const dataRaw = inflateSync(chunks![1])
const message = compiled.decodeMessage(dataRaw)
const textNc = message.nodeChanges.find((nc: any) => nc.type === 'TEXT')
expect(textNc).toBeDefined()
expect(textNc.textData.characters).toBe('Check binary')
expect(textNc.textData.lines).toBeDefined()
expect(textNc.textData.lines.length).toBeGreaterThanOrEqual(1)
expect(textNc.textData.lines[0].lineType).toBe('PLAIN')
expect(textNc.textUserLayoutVersion).toBe(3)
expect(textNc.derivedTextData).toBeDefined()
expect(textNc.derivedTextData.layoutSize).toBeDefined()
expect(textNc.derivedTextData.layoutSize.x).toBe(80)
expect(textNc.derivedTextData.layoutSize.y).toBe(18)
expect(textNc.derivedTextData.fontMetaData).toBeDefined()
expect(textNc.derivedTextData.fontMetaData.length).toBe(1)
expect(textNc.derivedTextData.fontMetaData[0].key.family).toBe('Roboto')
expect(textNc.derivedTextData.fontMetaData[0].fontWeight).toBe(700)
expect(textNc.derivedTextData.fontMetaData[0].fontStyle).toBe('NORMAL')
})
test('style runs produce multiple fontMetaData entries', async () => {
await initCodec()
const { unzipSync, inflateSync } = await import('fflate')
const { decodeBinarySchema, compileSchema, ByteBuffer } = await import(
'../../packages/core/src/kiwi/kiwi-schema'
)
const { parseFigKiwiChunks } = await import('@open-pencil/core')
const graph = new SceneGraph()
const page = graph.getPages()[0]
graph.createNode('TEXT', page.id, {
name: 'Styled',
text: 'Bold and Normal',
width: 150,
height: 20,
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 16,
styleRuns: [
{ start: 0, length: 4, style: { fontWeight: 700 } },
{ start: 5, length: 10, style: {} }
]
})
const exported = await exportFigFile(graph)
const zip = unzipSync(new Uint8Array(exported))
const canvasData = zip['canvas.fig'] ?? zip['canvas']
const chunks = parseFigKiwiChunks(canvasData)!
const schemaBytes = inflateSync(chunks[0])
const schema = decodeBinarySchema(new ByteBuffer(schemaBytes))
const compiled = compileSchema(schema) as { decodeMessage(data: Uint8Array): any }
const dataRaw = inflateSync(chunks[1])
const message = compiled.decodeMessage(dataRaw)
const textNc = message.nodeChanges.find((nc: any) => nc.type === 'TEXT')
expect(textNc.derivedTextData.fontMetaData.length).toBe(2)
const families = textNc.derivedTextData.fontMetaData.map((m: any) => m.key.style)
expect(families).toContain('Bold')
expect(families).toContain('Medium')
})
test('material3.fig text nodes have derivedTextData after round-trip', async () => {
const buf = readFileSync(resolve(FIXTURES, 'material3.fig'))
const original = await parseFigFile(buf.buffer as ArrayBuffer)
const textNodes = [...original.getAllNodes()].filter((n) => n.type === 'TEXT')
expect(textNodes.length).toBeGreaterThan(0)
const exported = await exportFigFile(original)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const reimportedText = [...reimported.getAllNodes()].filter((n) => n.type === 'TEXT')
expect(reimportedText.length).toBe(textNodes.length)
for (const node of reimportedText.slice(0, 10)) {
expect(node.text.length).toBeGreaterThan(0)
}
})
})
describe('variable roundtrip', () => {
test('variables and collections survive export → re-import', async () => {
await initCodec()