From 142a97319318b1879da02fad562d3af76a73a008 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 18 May 2026 01:13:39 +0300 Subject: [PATCH] fix(fig): render imported text from derived glyphs --- packages/core/src/canvas/scene.ts | 5 +++ packages/core/src/canvas/text-derived.ts | 25 +++++++++++ packages/core/src/kiwi/node-change/convert.ts | 24 +++++++++-- packages/core/src/scene-graph/index.ts | 10 ++--- .../core/src/scene-graph/node-defaults.ts | 1 + packages/core/src/scene-graph/types.ts | 8 ++++ .../engine/io/fig/import/legacy/text.test.ts | 43 +++++++++++++++++++ 7 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/canvas/text-derived.ts diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 75da3d56a..37b251a69 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -8,6 +8,7 @@ import { vectorNetworkToCenterlinePath } from '#core/vector' import { renderBooleanOperation } from './boolean' import { nodeHasRadius } from './shapes' import type { SkiaRenderer, RenderOverlays } from './renderer' +import { drawFigmaDerivedText } from './text-derived' function drawVisibleFills( r: SkiaRenderer, @@ -575,6 +576,10 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fil return } } + if (drawFigmaDerivedText(r, canvas, node)) { + canvas.restore() + return + } if (r.fontsLoaded && r.fontProvider) { const paragraph = r.buildParagraph(node, r.fillPaint.getColor()) canvas.drawParagraph(paragraph, 0, paragraphY) diff --git a/packages/core/src/canvas/text-derived.ts b/packages/core/src/canvas/text-derived.ts new file mode 100644 index 000000000..267862b34 --- /dev/null +++ b/packages/core/src/canvas/text-derived.ts @@ -0,0 +1,25 @@ +import type { Canvas } from 'canvaskit-wasm' + +import type { SceneNode } from '#core/scene-graph' +import { geometryBlobToPath } from '#core/vector' + +import type { SkiaRenderer } from './renderer' + +export function drawFigmaDerivedText( + r: SkiaRenderer, + canvas: Canvas, + node: SceneNode +): boolean { + if (!node.figmaDerivedTextGlyphs?.length) return false + + for (const glyph of node.figmaDerivedTextGlyphs) { + const path = geometryBlobToPath(r.ck, glyph.commandsBlob, 'NONZERO') + canvas.save() + canvas.translate(glyph.x, glyph.y) + canvas.scale(glyph.fontSize, -glyph.fontSize) + canvas.drawPath(path, r.fillPaint) + canvas.restore() + path.delete() + } + return true +} diff --git a/packages/core/src/kiwi/node-change/convert.ts b/packages/core/src/kiwi/node-change/convert.ts index 76efe79cb..0e8d715dc 100644 --- a/packages/core/src/kiwi/node-change/convert.ts +++ b/packages/core/src/kiwi/node-change/convert.ts @@ -258,8 +258,24 @@ function importedTextLineHeight(nc: NodeChange): number | null { return convertLineHeight(nc.lineHeight, nc.fontSize) } +function convertFigmaDerivedTextGlyphs(nc: NodeChange, blobs: Uint8Array[]) { + return (nc.derivedTextData?.glyphs ?? []) + .map((glyph) => { + const blob = glyph.commandsBlob === undefined ? undefined : blobs[glyph.commandsBlob] + if (!blob) return null + return { + commandsBlob: blob, + x: glyph.position.x, + y: glyph.position.y, + fontSize: glyph.fontSize + } + }) + .filter((glyph): glyph is NonNullable => !!glyph) +} + function convertTextProps( - nc: NodeChange + nc: NodeChange, + blobs: Uint8Array[] ): Pick< SceneNode, | 'text' @@ -278,6 +294,7 @@ function convertTextProps( | 'styleRuns' | 'textTruncation' | 'textDirection' + | 'figmaDerivedTextGlyphs' > { return { text: nc.textData?.characters ?? '', @@ -302,7 +319,8 @@ function convertTextProps( textDirection: (getOpenPencilPluginValue(nc, TEXT_DIRECTION_PLUGIN_KEY) as | SceneNode['textDirection'] - | null) || 'AUTO' + | null) || 'AUTO', + figmaDerivedTextGlyphs: convertFigmaDerivedTextGlyphs(nc, blobs) } } @@ -426,7 +444,7 @@ export function nodeChangeToProps( ), effects: convertEffects(nc.effects), ...convertCornerProps(nc), - ...convertTextProps(nc), + ...convertTextProps(nc, blobs), horizontalConstraint: mapConstraint(nc.horizontalConstraint as string), verticalConstraint: mapConstraint(nc.verticalConstraint as string), ...convertLayoutProps(nc), diff --git a/packages/core/src/scene-graph/index.ts b/packages/core/src/scene-graph/index.ts index 1054233a0..a5eb53ae3 100644 --- a/packages/core/src/scene-graph/index.ts +++ b/packages/core/src/scene-graph/index.ts @@ -397,12 +397,10 @@ export class SceneGraph { set.add(id) } } - if ( - node.type === 'TEXT' && - node.textPicture && - Object.keys(changes).some((k) => SceneGraph.TEXT_PICTURE_KEYS.has(k)) - ) { - node.textPicture = null + if (node.type === 'TEXT') { + const textChanged = Object.keys(changes).some((k) => SceneGraph.TEXT_PICTURE_KEYS.has(k)) + if (node.textPicture && textChanged) node.textPicture = null + if (node.figmaDerivedTextGlyphs && 'text' in changes) node.figmaDerivedTextGlyphs = null } if (changes.vectorNetwork) { changes = { ...changes, vectorNetwork: normalizeVectorNetwork(changes.vectorNetwork) } diff --git a/packages/core/src/scene-graph/node-defaults.ts b/packages/core/src/scene-graph/node-defaults.ts index 590194476..ea8a5a480 100644 --- a/packages/core/src/scene-graph/node-defaults.ts +++ b/packages/core/src/scene-graph/node-defaults.ts @@ -124,6 +124,7 @@ export function createDefaultNode( flipX: false, flipY: false, textPicture: null, + figmaDerivedTextGlyphs: null, ...overrides } } diff --git a/packages/core/src/scene-graph/types.ts b/packages/core/src/scene-graph/types.ts index 84530ded5..2f2142c41 100644 --- a/packages/core/src/scene-graph/types.ts +++ b/packages/core/src/scene-graph/types.ts @@ -210,6 +210,13 @@ export interface PluginRelaunchDataEntry { isDeleted: boolean } +export interface FigmaDerivedTextGlyph { + commandsBlob: Uint8Array + x: number + y: number + fontSize: number +} + export interface SymbolLink { uri: string displayName?: string @@ -363,6 +370,7 @@ export interface SceneNode { flipY: boolean textPicture: Uint8Array | null + figmaDerivedTextGlyphs: FigmaDerivedTextGlyph[] | null } export type ComponentPropertyType = 'VARIANT' | 'TEXT' | 'BOOLEAN' | 'INSTANCE_SWAP' diff --git a/tests/engine/io/fig/import/legacy/text.test.ts b/tests/engine/io/fig/import/legacy/text.test.ts index 10420df00..410236bf7 100644 --- a/tests/engine/io/fig/import/legacy/text.test.ts +++ b/tests/engine/io/fig/import/legacy/text.test.ts @@ -21,6 +21,49 @@ describe('fig-import: text properties', () => { expect(n.textAlignHorizontal).toBe('CENTER') }) + test('imports derived glyph geometry for Figma text rendering', () => { + const glyphBlob = new Uint8Array([0]) + const graph = importNodeChanges( + [ + doc(), + canvas(), + node('TEXT', 10, 1, { + textData: { characters: 'A' }, + fontSize: 14, + derivedTextData: { + layoutSize: { x: 10, y: 10 }, + glyphs: [ + { + commandsBlob: 0, + position: { x: 2, y: 8 }, + fontSize: 14, + firstCharacter: 0, + advance: 1, + rotation: 0 + } + ] + } + } as Partial) + ], + [glyphBlob] + ) + const n = graph.getChildren(graph.getPages()[0].id)[0] + expect(n.figmaDerivedTextGlyphs).toEqual([ + { + commandsBlob: glyphBlob, + x: 2, + y: 8, + fontSize: 14 + } + ]) + + graph.updateNode(n.id, { opacity: 0.5 }) + expect(graph.getNode(n.id)?.figmaDerivedTextGlyphs).toHaveLength(1) + + graph.updateNode(n.id, { text: 'B' }) + expect(graph.getNode(n.id)?.figmaDerivedTextGlyphs).toBeNull() + }) + test('uses derived line metrics for imported Figma text rendering', () => { const graph = importNodeChanges([ doc(),