fix(canvas): render Figma text decoration styles

This commit is contained in:
Danila Poyarkov 2026-05-26 12:46:44 +03:00
parent ec31ea1186
commit 80daa0d1e9
13 changed files with 172 additions and 11 deletions

View file

@ -195,6 +195,31 @@ function textDecorationValue(ck: CanvasKit, decoration: string): number {
}
}
export function textDecorationStyleValue<T>(
ck: { DecorationStyle: { Solid: T; Dotted: T; Wavy: T } },
style: SceneNode['textDecorationStyle'] | undefined
): T {
switch (style) {
case 'DOTTED':
return ck.DecorationStyle.Dotted
case 'WAVY':
return ck.DecorationStyle.Wavy
default:
return ck.DecorationStyle.Solid
}
}
function textDecorationColor(
ck: CanvasKit,
fills: SceneNode['textDecorationFills'] | undefined,
fallback: Float32Array
): Float32Array {
const fill = fills?.find((item) => item.visible && item.type === 'SOLID')
if (!fill) return fallback
const color = resolveRGBAForPreview(fill.color).color
return ck.Color4f(color.r, color.g, color.b, color.a * fill.opacity)
}
function styleRunColor(
ck: CanvasKit,
style: SceneNode['styleRuns'][number]['style'],
@ -238,6 +263,17 @@ function pushStyleRun(
fontFeatures: textFontFeatures(style.fontFeatures ?? node.fontFeatures),
letterSpacing: style.letterSpacing ?? (node.letterSpacing || 0),
decoration: textDecorationValue(ck, style.textDecoration ?? node.textDecoration),
decorationStyle: textDecorationStyleValue(
ck,
style.textDecorationStyle ?? node.textDecorationStyle
),
decorationThickness:
style.textDecorationThickness ?? node.textDecorationThickness ?? undefined,
decorationColor: textDecorationColor(
ck,
style.textDecorationFills ?? node.textDecorationFills,
baseColor
),
heightMultiplier: runLineHeight ? runLineHeight / runFontSize : undefined,
halfLeading
})
@ -310,6 +346,9 @@ export function buildParagraph(
fontFeatures: textFontFeatures(node.fontFeatures),
letterSpacing: node.letterSpacing || 0,
decoration: textDecorationValue(ck, node.textDecoration),
decorationStyle: textDecorationStyleValue(ck, node.textDecorationStyle),
decorationThickness: node.textDecorationThickness ?? undefined,
decorationColor: textDecorationColor(ck, node.textDecorationFills, baseColor),
heightMultiplier: node.lineHeight ? node.lineHeight / baseFontSize : undefined,
halfLeading
}

View file

@ -275,10 +275,7 @@ function importedTextLineHeight(nc: NodeChange): number | null {
return convertLineHeight(nc.lineHeight, nc.fontSize)
}
function convertTextProps(
nc: NodeChange,
blobs: Uint8Array[]
): Pick<
type TextProps = Pick<
SceneNode,
| 'text'
| 'fontSize'
@ -290,6 +287,9 @@ function convertTextProps(
| 'textAutoResize'
| 'textCase'
| 'textDecoration'
| 'textDecorationStyle'
| 'textDecorationThickness'
| 'textDecorationFills'
| 'lineHeight'
| 'letterSpacing'
| 'maxLines'
@ -300,7 +300,23 @@ function convertTextProps(
| 'textDirection'
| 'figmaDerivedLayout'
| 'figmaDerivedTextGlyphs'
>
function convertTextDecorationProps(
nc: NodeChange
): Pick<
SceneNode,
'textDecoration' | 'textDecorationStyle' | 'textDecorationThickness' | 'textDecorationFills'
> {
return {
textDecoration: mapTextDecoration(nc.textDecoration as string),
textDecorationStyle: (nc.textDecorationStyle ?? 'SOLID') as SceneNode['textDecorationStyle'],
textDecorationThickness: nc.textDecorationThickness?.value ?? null,
textDecorationFills: convertFills(nc.textDecorationFillPaints)
}
}
function convertTextProps(nc: NodeChange, blobs: Uint8Array[]): TextProps {
return {
text: nc.textData?.characters ?? '',
fontSize: nc.fontSize ?? 14,
@ -315,7 +331,7 @@ function convertTextProps(
textAlignVertical: (nc.textAlignVertical ?? 'TOP') as TextAlignVertical,
textAutoResize: (nc.textAutoResize ?? 'NONE') as TextAutoResize,
textCase: (nc.textCase ?? 'ORIGINAL') as TextCase,
textDecoration: mapTextDecoration(nc.textDecoration as string),
...convertTextDecorationProps(nc),
lineHeight: importedTextLineHeight(nc),
letterSpacing: convertLetterSpacing(nc.letterSpacing, nc.fontSize),
maxLines: (nc.maxLines ?? null) as number | null,

View file

@ -317,6 +317,13 @@ function serializeTextProps(
if (node.textDecoration !== 'NONE') {
nc.textDecoration = node.textDecoration === 'UNDERLINE' ? 'UNDERLINE' : 'STRIKETHROUGH'
}
if (node.textDecorationStyle !== 'SOLID') nc.textDecorationStyle = node.textDecorationStyle
if (node.textDecorationThickness != null) {
nc.textDecorationThickness = { value: node.textDecorationThickness, units: 'PIXELS' }
}
if (node.textDecorationFills.length > 0) {
nc.textDecorationFillPaints = node.textDecorationFills.map(fillToKiwiPaint)
}
}
function normalizeStackMode(value: string | undefined): KiwiNodeChange['stackMode'] {

View file

@ -34,6 +34,15 @@ function convertStyleOverride(
}
const deco = override.textDecoration
if (deco) style.textDecoration = mapTextDecoration(deco)
if (override.textDecorationStyle)
style.textDecorationStyle =
override.textDecorationStyle as CharacterStyleOverride['textDecorationStyle']
if (override.textDecorationThickness)
style.textDecorationThickness = override.textDecorationThickness.value ?? null
if (override.textDecorationFillPaints) {
const decorationFills = convertFills(override.textDecorationFillPaints)
if (decorationFills.length > 0) style.textDecorationFills = decorationFills
}
if (override.fillPaints) {
const fills = convertFills(override.fillPaints)
if (fills.length > 0) style.fills = fills

View file

@ -40,6 +40,13 @@ function textStyleOverrideToKiwi(
override.lineHeight = { value: style.lineHeight, units: 'PIXELS' }
}
if (style.textDecoration) override.textDecoration = style.textDecoration
if (style.textDecorationStyle) override.textDecorationStyle = style.textDecorationStyle
if (style.textDecorationThickness != null) {
override.textDecorationThickness = { value: style.textDecorationThickness, units: 'PIXELS' }
}
if (style.textDecorationFills && style.textDecorationFills.length > 0) {
override.textDecorationFillPaints = style.textDecorationFills.map(fillToKiwiPaint)
}
if (style.fills && style.fills.length > 0) {
override.fillPaints = style.fills.map(fillToKiwiPaint)
}

View file

@ -42,6 +42,9 @@ export function copyStyleRun(r: StyleRun): StyleRun {
style: {
...r.style,
fills: r.style.fills ? r.style.fills.map(copyFill) : undefined,
textDecorationFills: r.style.textDecorationFills
? r.style.textDecorationFills.map(copyFill)
: undefined,
fontVariations: r.style.fontVariations
? r.style.fontVariations.map((v) => ({ ...v }))
: undefined,

View file

@ -84,6 +84,9 @@ export function createDefaultNode(
textAutoResize: 'NONE',
textCase: 'ORIGINAL',
textDecoration: 'NONE',
textDecorationStyle: 'SOLID',
textDecorationThickness: null,
textDecorationFills: [],
maxLines: null,
styleRuns: [],
fontVariations: [],

View file

@ -28,6 +28,9 @@ const RAW_NODE_FIELD_KEYS = new Set([
'textAutoResize',
'textCase',
'textDecoration',
'textDecorationStyle',
'textDecorationThickness',
'textDecorationFills',
'lineHeight',
'letterSpacing',
'maxLines',

View file

@ -187,6 +187,7 @@ export type TextAutoResize = 'NONE' | 'HEIGHT' | 'WIDTH_AND_HEIGHT' | 'TRUNCATE'
export type TextAlignVertical = 'TOP' | 'CENTER' | 'BOTTOM'
export type TextCase = 'ORIGINAL' | 'UPPER' | 'LOWER' | 'TITLE'
export type TextDecoration = 'NONE' | 'UNDERLINE' | 'STRIKETHROUGH'
export type TextDecorationStyle = 'SOLID' | 'DOTTED' | 'WAVY'
export type TextDirection = 'AUTO' | 'LTR' | 'RTL'
export type LayoutDirection = 'AUTO' | 'LTR' | 'RTL'
@ -204,6 +205,9 @@ export interface CharacterStyleOverride {
fontWeight?: number
italic?: boolean
textDecoration?: TextDecoration
textDecorationStyle?: TextDecorationStyle
textDecorationThickness?: number | null
textDecorationFills?: Fill[]
fontSize?: number
fontFamily?: string
letterSpacing?: number
@ -350,6 +354,9 @@ export interface SceneNode {
textAutoResize: TextAutoResize
textCase: TextCase
textDecoration: TextDecoration
textDecorationStyle: TextDecorationStyle
textDecorationThickness: number | null
textDecorationFills: Fill[]
lineHeight: number | null
letterSpacing: number
maxLines: number | null

View file

@ -150,7 +150,7 @@ Figma's design documentation groups features into these areas:
| Reverse z-index / align-content | ✅ | ◐ | — | ✅ | ✅ | Modeled and exported; UI is limited. |
| Constraints | ✅ | ◐ | — | ✅ | ✅ | Tools/API expose constraints; main UI is limited. |
| Layout grids / guides | ↩ | — | — | ↩ | — | `styleIdForGrid` and `guides` are preserved only. |
| Text styles | ↩ | ◐ | — | ↩ | — | Style IDs round-trip; no style management UI. Rich schema metadata such as derived text data, leading trim, decoration style, and semantic font style/weight is preserved for round-trip. |
| Text styles | ↩ | ◐ | — | ↩ | — | Style IDs round-trip; no style management UI. Rich schema metadata such as derived text data, leading trim, decoration style/thickness/fill, and semantic font style/weight is preserved for round-trip. |
| Rich style runs | ✅ | ✅ | ◐ | ✅ | ✅ | Import/render/export support; editing mixed runs is partial. |
| Text auto resize | ✅ | ✅ | ◐ | ✅ | ✅ | Used by renderer/layout; UI does not expose every mode. |
| Text truncation / max lines | ✅ | ✅ | — | ✅ | ✅ | Renderer supports ending truncation; no inspector control. |
@ -195,7 +195,7 @@ OpenPencil deliberately preserves many Figma/Kiwi fields even when they are not
| Version/sort/publish/library metadata | ↩ | — | ◐ | Assets UI shows a subset; publish/update workflow is missing. |
| Variable and parameter consumption maps | ✅ | ◐ | ◐ | Filtered/preserved for safe round-trip; normalized bindings cover common cases. |
| Page fields: background, page type, guides | ↩ | ◐ | — | Background/page type/guides mostly round-trip. Guides are not rendered/editable. |
| Text internals: `textData`, layout versions, font version, derived data | ✅ | ✅ | — | Important for text fidelity; most internals are not editable. Imported derived text data, leading trim, decoration style, underline decoration paint/offset/thickness, semantic font metadata, and raw OpenType feature toggles are preserved for round-trip when safe. |
| Text internals: `textData`, layout versions, font version, derived data | ✅ | ✅ | — | Important for text fidelity; most internals are not editable. Imported derived text data, leading trim, decoration style, underline decoration paint/offset/thickness, semantic font metadata, and raw OpenType feature toggles are preserved for round-trip when safe; decoration style/thickness/color now render through CanvasKit.
| `fontVariations` | ✅ | ✅ | — | Variable font axes are imported, rendered, and exported for text nodes and style runs. |
| Raw paint/effect/vector/geometry payloads | ✅ | ✅ | ◐ | Converted fields render; raw payloads preserve Figma import/export details, including mask, background paint, layout grid, export setting, and prototype interaction metadata where safe. |

View file

@ -10,6 +10,18 @@ describe('Figma font variation export', () => {
const text = graph.createNode('TEXT', page.id, {
text: 'Axis',
fontVariations: [{ axis: 'wght', value: 650 }],
textDecoration: 'UNDERLINE',
textDecorationStyle: 'WAVY',
textDecorationThickness: 1.5,
textDecorationFills: [
{
type: 'SOLID',
color: { r: 1, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true,
blendMode: 'NORMAL'
}
],
fontFeatures: [
{ tag: 'LIGA', enabled: false },
{ tag: 'DLIG', enabled: true },
@ -24,7 +36,10 @@ describe('Figma font variation export', () => {
fontFeatures: [
{ tag: 'CALT', enabled: false },
{ tag: 'SS01', enabled: true }
]
],
textDecoration: 'UNDERLINE',
textDecorationStyle: 'DOTTED',
textDecorationThickness: 2
}
}
]
@ -36,6 +51,9 @@ describe('Figma font variation export', () => {
expect(nodeChange.fontVariations).toEqual([
{ axisTag: 0x77676874, axisName: 'wght', value: 650 }
])
expect(nodeChange.textDecorationStyle).toBe('WAVY')
expect(nodeChange.textDecorationThickness).toEqual({ value: 1.5, units: 'PIXELS' })
expect(nodeChange.textDecorationFillPaints?.[0]?.type).toBe('SOLID')
expect(nodeChange.fontVariantCommonLigatures).toBe(false)
expect(nodeChange.fontVariantContextualLigatures).toBe(true)
expect(nodeChange.toggledOnOTFeatures).toEqual(['DLIG'])
@ -45,5 +63,10 @@ describe('Figma font variation export', () => {
])
expect(nodeChange.textData?.styleOverrideTable?.[0]?.fontVariantContextualLigatures).toBe(false)
expect(nodeChange.textData?.styleOverrideTable?.[0]?.toggledOnOTFeatures).toEqual(['SS01'])
expect(nodeChange.textData?.styleOverrideTable?.[0]?.textDecorationStyle).toBe('DOTTED')
expect(nodeChange.textData?.styleOverrideTable?.[0]?.textDecorationThickness).toEqual({
value: 2,
units: 'PIXELS'
})
})
})

View file

@ -44,6 +44,29 @@ describe('Figma font variation import', () => {
])
})
test('imports text decoration style metadata', () => {
const props = nodeChangeToProps(
{
type: 'TEXT',
textData: { characters: 'Decorated' },
textDecoration: 'UNDERLINE',
textDecorationStyle: 'WAVY',
textDecorationThickness: { value: 1.5, units: 'PIXELS' },
textDecorationFillPaints: [
{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 0.75 }
]
} as NodeChange,
[]
)
expect(props).toMatchObject({
textDecoration: 'UNDERLINE',
textDecorationStyle: 'WAVY',
textDecorationThickness: 1.5,
textDecorationFills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 0.75 }]
})
})
test('imports styled-run variable font axes and OpenType feature toggles', () => {
const runs = importStyleRuns({
type: 'TEXT',
@ -56,7 +79,10 @@ describe('Figma font variation import', () => {
styleID: 1,
fontVariations: [{ axisName: 'GRAD', value: -50 }],
fontVariantCommonLigatures: false,
toggledOnOTFeatures: ['SS01']
toggledOnOTFeatures: ['SS01'],
textDecoration: 'UNDERLINE',
textDecorationStyle: 'DOTTED',
textDecorationThickness: { value: 2, units: 'PIXELS' }
} as NodeChange
]
}
@ -71,7 +97,10 @@ describe('Figma font variation import', () => {
fontFeatures: [
{ tag: 'LIGA', enabled: false },
{ tag: 'SS01', enabled: true }
]
],
textDecoration: 'UNDERLINE',
textDecorationStyle: 'DOTTED',
textDecorationThickness: 2
}
}
])

View file

@ -1,8 +1,23 @@
import { describe, expect, test } from 'bun:test'
import { textFontFeatures, textFontVariations } from '#core/canvas/text'
import { textDecorationStyleValue, textFontFeatures, textFontVariations } from '#core/canvas/text'
describe('canvas text font variations', () => {
test('maps imported text decoration styles to CanvasKit', () => {
const ck = {
DecorationStyle: {
Solid: 'solid',
Dotted: 'dotted',
Wavy: 'wavy'
}
}
expect(textDecorationStyleValue(ck, 'SOLID')).toBe('solid')
expect(textDecorationStyleValue(ck, 'DOTTED')).toBe('dotted')
expect(textDecorationStyleValue(ck, 'WAVY')).toBe('wavy')
expect(textDecorationStyleValue(ck, undefined)).toBe('solid')
})
test('passes imported variable font axes to CanvasKit text styles', () => {
expect(
textFontVariations([