diff --git a/CHANGELOG.md b/CHANGELOG.md index f64472c60..47eb74cbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/core/src/fig-export.ts b/packages/core/src/fig-export.ts index 6b505c256..af5267858 100644 --- a/packages/core/src/fig-export.ts +++ b/packages/core/src/fig-export.ts @@ -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() + 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) ) } } diff --git a/packages/core/src/fonts.ts b/packages/core/src/fonts.ts index f1dbd91a8..186317927 100644 --- a/packages/core/src/fonts.ts +++ b/packages/core/src/fonts.ts @@ -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() const collect = (id: string) => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e4563f72d..b5ac99ff3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -83,6 +83,7 @@ export { initFontService, getFontProvider, isFontLoaded, + getLoadedFontData, markFontLoaded, ensureNodeFont, ensureCJKFallback, diff --git a/packages/core/src/kiwi-serialize.ts b/packages/core/src/kiwi-serialize.ts index 73ca0bec3..a29b98b68 100644 --- a/packages/core/src/kiwi-serialize.ts +++ b/packages/core/src/kiwi-serialize.ts @@ -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() + +async function computeFontDigest(data: ArrayBuffer): Promise { + 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 { + 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> { + const fontKeys = new Set() + 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() + 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 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['lines'] { + const lineCount = Math.max(1, text.split('\n').length) + return Array.from({ length: lineCount }, () => ({ lineType: 'PLAIN' })) +} + +function buildDerivedTextData( + node: SceneNode, + digestMap: Map +): NodeChange['derivedTextData'] { + const fontMeta: NonNullable['fontMetaData'] = [] + const seen = new Set() + + 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(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 + nodeIdToGuid?: Map, + fontDigestMap?: Map ): 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 diff --git a/packages/core/src/kiwi/codec.ts b/packages/core/src/kiwi/codec.ts index 55a9c8c90..22ba5403e 100644 --- a/packages/core/src/kiwi/codec.ts +++ b/packages/core/src/kiwi/codec.ts @@ -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 + fontStyle?: string + fontWeight?: number + }> + truncationStartIndex?: number + truncatedHeight?: number + } + textUserLayoutVersion?: number + textDecoration?: string lineHeight?: { value: number; units: string } letterSpacing?: { value: number; units: string } // Symbol/Instance diff --git a/packages/core/src/kiwi/schema.ts b/packages/core/src/kiwi/schema.ts index d1b9558c1..b0330e28b 100644 --- a/packages/core/src/kiwi/schema.ts +++ b/packages/core/src/kiwi/schema.ts @@ -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; diff --git a/tests/engine/fig-roundtrip.test.ts b/tests/engine/fig-roundtrip.test.ts index 2e7c152c2..544e8f5f7 100644 --- a/tests/engine/fig-roundtrip.test.ts +++ b/tests/engine/fig-roundtrip.test.ts @@ -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()