fix(canvas): render Figma text decoration styles
This commit is contained in:
parent
ec31ea1186
commit
80daa0d1e9
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'] {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@ export function createDefaultNode(
|
|||
textAutoResize: 'NONE',
|
||||
textCase: 'ORIGINAL',
|
||||
textDecoration: 'NONE',
|
||||
textDecorationStyle: 'SOLID',
|
||||
textDecorationThickness: null,
|
||||
textDecorationFills: [],
|
||||
maxLines: null,
|
||||
styleRuns: [],
|
||||
fontVariations: [],
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ const RAW_NODE_FIELD_KEYS = new Set([
|
|||
'textAutoResize',
|
||||
'textCase',
|
||||
'textDecoration',
|
||||
'textDecorationStyle',
|
||||
'textDecorationThickness',
|
||||
'textDecorationFills',
|
||||
'lineHeight',
|
||||
'letterSpacing',
|
||||
'maxLines',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
Loading…
Reference in a new issue