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( function styleRunColor(
ck: CanvasKit, ck: CanvasKit,
style: SceneNode['styleRuns'][number]['style'], style: SceneNode['styleRuns'][number]['style'],
@ -238,6 +263,17 @@ function pushStyleRun(
fontFeatures: textFontFeatures(style.fontFeatures ?? node.fontFeatures), fontFeatures: textFontFeatures(style.fontFeatures ?? node.fontFeatures),
letterSpacing: style.letterSpacing ?? (node.letterSpacing || 0), letterSpacing: style.letterSpacing ?? (node.letterSpacing || 0),
decoration: textDecorationValue(ck, style.textDecoration ?? node.textDecoration), 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, heightMultiplier: runLineHeight ? runLineHeight / runFontSize : undefined,
halfLeading halfLeading
}) })
@ -310,6 +346,9 @@ export function buildParagraph(
fontFeatures: textFontFeatures(node.fontFeatures), fontFeatures: textFontFeatures(node.fontFeatures),
letterSpacing: node.letterSpacing || 0, letterSpacing: node.letterSpacing || 0,
decoration: textDecorationValue(ck, node.textDecoration), 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, heightMultiplier: node.lineHeight ? node.lineHeight / baseFontSize : undefined,
halfLeading halfLeading
} }

View file

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

View file

@ -317,6 +317,13 @@ function serializeTextProps(
if (node.textDecoration !== 'NONE') { if (node.textDecoration !== 'NONE') {
nc.textDecoration = node.textDecoration === 'UNDERLINE' ? 'UNDERLINE' : 'STRIKETHROUGH' 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'] { function normalizeStackMode(value: string | undefined): KiwiNodeChange['stackMode'] {

View file

@ -34,6 +34,15 @@ function convertStyleOverride(
} }
const deco = override.textDecoration const deco = override.textDecoration
if (deco) style.textDecoration = mapTextDecoration(deco) 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) { if (override.fillPaints) {
const fills = convertFills(override.fillPaints) const fills = convertFills(override.fillPaints)
if (fills.length > 0) style.fills = fills if (fills.length > 0) style.fills = fills

View file

@ -40,6 +40,13 @@ function textStyleOverrideToKiwi(
override.lineHeight = { value: style.lineHeight, units: 'PIXELS' } override.lineHeight = { value: style.lineHeight, units: 'PIXELS' }
} }
if (style.textDecoration) override.textDecoration = style.textDecoration 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) { if (style.fills && style.fills.length > 0) {
override.fillPaints = style.fills.map(fillToKiwiPaint) override.fillPaints = style.fills.map(fillToKiwiPaint)
} }

View file

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

View file

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

View file

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

View file

@ -187,6 +187,7 @@ export type TextAutoResize = 'NONE' | 'HEIGHT' | 'WIDTH_AND_HEIGHT' | 'TRUNCATE'
export type TextAlignVertical = 'TOP' | 'CENTER' | 'BOTTOM' export type TextAlignVertical = 'TOP' | 'CENTER' | 'BOTTOM'
export type TextCase = 'ORIGINAL' | 'UPPER' | 'LOWER' | 'TITLE' export type TextCase = 'ORIGINAL' | 'UPPER' | 'LOWER' | 'TITLE'
export type TextDecoration = 'NONE' | 'UNDERLINE' | 'STRIKETHROUGH' export type TextDecoration = 'NONE' | 'UNDERLINE' | 'STRIKETHROUGH'
export type TextDecorationStyle = 'SOLID' | 'DOTTED' | 'WAVY'
export type TextDirection = 'AUTO' | 'LTR' | 'RTL' export type TextDirection = 'AUTO' | 'LTR' | 'RTL'
export type LayoutDirection = 'AUTO' | 'LTR' | 'RTL' export type LayoutDirection = 'AUTO' | 'LTR' | 'RTL'
@ -204,6 +205,9 @@ export interface CharacterStyleOverride {
fontWeight?: number fontWeight?: number
italic?: boolean italic?: boolean
textDecoration?: TextDecoration textDecoration?: TextDecoration
textDecorationStyle?: TextDecorationStyle
textDecorationThickness?: number | null
textDecorationFills?: Fill[]
fontSize?: number fontSize?: number
fontFamily?: string fontFamily?: string
letterSpacing?: number letterSpacing?: number
@ -350,6 +354,9 @@ export interface SceneNode {
textAutoResize: TextAutoResize textAutoResize: TextAutoResize
textCase: TextCase textCase: TextCase
textDecoration: TextDecoration textDecoration: TextDecoration
textDecorationStyle: TextDecorationStyle
textDecorationThickness: number | null
textDecorationFills: Fill[]
lineHeight: number | null lineHeight: number | null
letterSpacing: number letterSpacing: number
maxLines: number | null 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. | | Reverse z-index / align-content | ✅ | ◐ | — | ✅ | ✅ | Modeled and exported; UI is limited. |
| Constraints | ✅ | ◐ | — | ✅ | ✅ | Tools/API expose constraints; main UI is limited. | | Constraints | ✅ | ◐ | — | ✅ | ✅ | Tools/API expose constraints; main UI is limited. |
| Layout grids / guides | ↩ | — | — | ↩ | — | `styleIdForGrid` and `guides` are preserved only. | | 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. | | 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 auto resize | ✅ | ✅ | ◐ | ✅ | ✅ | Used by renderer/layout; UI does not expose every mode. |
| Text truncation / max lines | ✅ | ✅ | — | ✅ | ✅ | Renderer supports ending truncation; no inspector control. | | 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. | | 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. | | 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. | | 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. | | `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. | | 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, { const text = graph.createNode('TEXT', page.id, {
text: 'Axis', text: 'Axis',
fontVariations: [{ axis: 'wght', value: 650 }], 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: [ fontFeatures: [
{ tag: 'LIGA', enabled: false }, { tag: 'LIGA', enabled: false },
{ tag: 'DLIG', enabled: true }, { tag: 'DLIG', enabled: true },
@ -24,7 +36,10 @@ describe('Figma font variation export', () => {
fontFeatures: [ fontFeatures: [
{ tag: 'CALT', enabled: false }, { tag: 'CALT', enabled: false },
{ tag: 'SS01', enabled: true } { tag: 'SS01', enabled: true }
] ],
textDecoration: 'UNDERLINE',
textDecorationStyle: 'DOTTED',
textDecorationThickness: 2
} }
} }
] ]
@ -36,6 +51,9 @@ describe('Figma font variation export', () => {
expect(nodeChange.fontVariations).toEqual([ expect(nodeChange.fontVariations).toEqual([
{ axisTag: 0x77676874, axisName: 'wght', value: 650 } { 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.fontVariantCommonLigatures).toBe(false)
expect(nodeChange.fontVariantContextualLigatures).toBe(true) expect(nodeChange.fontVariantContextualLigatures).toBe(true)
expect(nodeChange.toggledOnOTFeatures).toEqual(['DLIG']) 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]?.fontVariantContextualLigatures).toBe(false)
expect(nodeChange.textData?.styleOverrideTable?.[0]?.toggledOnOTFeatures).toEqual(['SS01']) 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', () => { test('imports styled-run variable font axes and OpenType feature toggles', () => {
const runs = importStyleRuns({ const runs = importStyleRuns({
type: 'TEXT', type: 'TEXT',
@ -56,7 +79,10 @@ describe('Figma font variation import', () => {
styleID: 1, styleID: 1,
fontVariations: [{ axisName: 'GRAD', value: -50 }], fontVariations: [{ axisName: 'GRAD', value: -50 }],
fontVariantCommonLigatures: false, fontVariantCommonLigatures: false,
toggledOnOTFeatures: ['SS01'] toggledOnOTFeatures: ['SS01'],
textDecoration: 'UNDERLINE',
textDecorationStyle: 'DOTTED',
textDecorationThickness: { value: 2, units: 'PIXELS' }
} as NodeChange } as NodeChange
] ]
} }
@ -71,7 +97,10 @@ describe('Figma font variation import', () => {
fontFeatures: [ fontFeatures: [
{ tag: 'LIGA', enabled: false }, { tag: 'LIGA', enabled: false },
{ tag: 'SS01', enabled: true } { tag: 'SS01', enabled: true }
] ],
textDecoration: 'UNDERLINE',
textDecorationStyle: 'DOTTED',
textDecorationThickness: 2
} }
} }
]) ])

View file

@ -1,8 +1,23 @@
import { describe, expect, test } from 'bun:test' 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', () => { 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', () => { test('passes imported variable font axes to CanvasKit text styles', () => {
expect( expect(
textFontVariations([ textFontVariations([