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:
parent
5d259b95c3
commit
4a6dd703c8
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export {
|
|||
initFontService,
|
||||
getFontProvider,
|
||||
isFontLoaded,
|
||||
getLoadedFontData,
|
||||
markFontLoaded,
|
||||
ensureNodeFont,
|
||||
ensureCJKFallback,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue