fix(fig): render imported text from derived glyphs

This commit is contained in:
Danila Poyarkov 2026-05-18 01:13:39 +03:00
parent 7e86d57378
commit 142a973193
7 changed files with 107 additions and 9 deletions

View file

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

View file

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

View file

@ -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<typeof glyph> => !!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),

View file

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

View file

@ -124,6 +124,7 @@ export function createDefaultNode(
flipX: false,
flipY: false,
textPicture: null,
figmaDerivedTextGlyphs: null,
...overrides
}
}

View file

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

View file

@ -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<NodeChange>)
],
[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(),