From 80daa0d1e95829498b5da26c26196d499408ba2e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 26 May 2026 12:46:44 +0300 Subject: [PATCH] fix(canvas): render Figma text decoration styles --- packages/core/src/canvas/text.ts | 39 +++++++++++++++++++ .../core/src/kiwi/fig/node-change/convert.ts | 26 ++++++++++--- .../src/kiwi/fig/node-change/serialize.ts | 7 ++++ .../src/kiwi/fig/node-change/style-runs.ts | 9 +++++ .../kiwi/fig/node-change/text-data-export.ts | 7 ++++ packages/core/src/scene-graph/copy.ts | 3 ++ .../core/src/scene-graph/node-defaults.ts | 3 ++ .../core/src/scene-graph/source-metadata.ts | 3 ++ packages/core/src/scene-graph/types.ts | 7 ++++ packages/docs/development/roadmap.md | 4 +- .../io/fig/export/font-variations.test.ts | 25 +++++++++++- .../io/fig/import/font-variations.test.ts | 33 +++++++++++++++- .../canvas/text-font-variations.test.ts | 17 +++++++- 13 files changed, 172 insertions(+), 11 deletions(-) diff --git a/packages/core/src/canvas/text.ts b/packages/core/src/canvas/text.ts index 2d80f41cb..3a43f1bdd 100644 --- a/packages/core/src/canvas/text.ts +++ b/packages/core/src/canvas/text.ts @@ -195,6 +195,31 @@ function textDecorationValue(ck: CanvasKit, decoration: string): number { } } +export function textDecorationStyleValue( + 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 } diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index 37e58974b..4ba182bfd 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -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, diff --git a/packages/core/src/kiwi/fig/node-change/serialize.ts b/packages/core/src/kiwi/fig/node-change/serialize.ts index df2cd6d92..f729f56b6 100644 --- a/packages/core/src/kiwi/fig/node-change/serialize.ts +++ b/packages/core/src/kiwi/fig/node-change/serialize.ts @@ -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'] { diff --git a/packages/core/src/kiwi/fig/node-change/style-runs.ts b/packages/core/src/kiwi/fig/node-change/style-runs.ts index ac5d86733..1ef720737 100644 --- a/packages/core/src/kiwi/fig/node-change/style-runs.ts +++ b/packages/core/src/kiwi/fig/node-change/style-runs.ts @@ -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 diff --git a/packages/core/src/kiwi/fig/node-change/text-data-export.ts b/packages/core/src/kiwi/fig/node-change/text-data-export.ts index b045cbfdc..6e1e9953b 100644 --- a/packages/core/src/kiwi/fig/node-change/text-data-export.ts +++ b/packages/core/src/kiwi/fig/node-change/text-data-export.ts @@ -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) } diff --git a/packages/core/src/scene-graph/copy.ts b/packages/core/src/scene-graph/copy.ts index 42afb8696..97ec02d79 100644 --- a/packages/core/src/scene-graph/copy.ts +++ b/packages/core/src/scene-graph/copy.ts @@ -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, diff --git a/packages/core/src/scene-graph/node-defaults.ts b/packages/core/src/scene-graph/node-defaults.ts index ba947ee5d..f33a09c83 100644 --- a/packages/core/src/scene-graph/node-defaults.ts +++ b/packages/core/src/scene-graph/node-defaults.ts @@ -84,6 +84,9 @@ export function createDefaultNode( textAutoResize: 'NONE', textCase: 'ORIGINAL', textDecoration: 'NONE', + textDecorationStyle: 'SOLID', + textDecorationThickness: null, + textDecorationFills: [], maxLines: null, styleRuns: [], fontVariations: [], diff --git a/packages/core/src/scene-graph/source-metadata.ts b/packages/core/src/scene-graph/source-metadata.ts index 9eea3332a..c60e940cd 100644 --- a/packages/core/src/scene-graph/source-metadata.ts +++ b/packages/core/src/scene-graph/source-metadata.ts @@ -28,6 +28,9 @@ const RAW_NODE_FIELD_KEYS = new Set([ 'textAutoResize', 'textCase', 'textDecoration', + 'textDecorationStyle', + 'textDecorationThickness', + 'textDecorationFills', 'lineHeight', 'letterSpacing', 'maxLines', diff --git a/packages/core/src/scene-graph/types.ts b/packages/core/src/scene-graph/types.ts index 2ee5b4f14..a1d5e19fc 100644 --- a/packages/core/src/scene-graph/types.ts +++ b/packages/core/src/scene-graph/types.ts @@ -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 diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index 269420db9..e2c8fe81a 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -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. | diff --git a/tests/engine/io/fig/export/font-variations.test.ts b/tests/engine/io/fig/export/font-variations.test.ts index eabb642b9..530e770bb 100644 --- a/tests/engine/io/fig/export/font-variations.test.ts +++ b/tests/engine/io/fig/export/font-variations.test.ts @@ -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' + }) }) }) diff --git a/tests/engine/io/fig/import/font-variations.test.ts b/tests/engine/io/fig/import/font-variations.test.ts index 10d43d6ea..3198ddd1f 100644 --- a/tests/engine/io/fig/import/font-variations.test.ts +++ b/tests/engine/io/fig/import/font-variations.test.ts @@ -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 } } ]) diff --git a/tests/engine/render/canvas/text-font-variations.test.ts b/tests/engine/render/canvas/text-font-variations.test.ts index 00d6816e8..38d361900 100644 --- a/tests/engine/render/canvas/text-font-variations.test.ts +++ b/tests/engine/render/canvas/text-font-variations.test.ts @@ -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([