fix(canvas): improve Figma mask and text fidelity
This commit is contained in:
parent
5b12b1597f
commit
8c85d2b953
|
|
@ -14,9 +14,11 @@
|
||||||
- Fix MCP startup in the browser.
|
- Fix MCP startup in the browser.
|
||||||
- Fix CanvasKit loading outside the browser when project paths contain spaces.
|
- Fix CanvasKit loading outside the browser when project paths contain spaces.
|
||||||
- Render imported Figma layer and fill blend modes such as multiply, screen, overlay, difference, hue, saturation, color, and luminosity.
|
- Render imported Figma layer and fill blend modes such as multiply, screen, overlay, difference, hue, saturation, color, and luminosity.
|
||||||
- Render common imported Figma mask stacks so visible layers above alpha, vector, or luminance masks are clipped by the mask shape.
|
- Render common imported Figma mask stacks so visible layers above alpha, vector, or luminance masks are clipped by the mask shape, including consecutive mask layers.
|
||||||
- Render Figma-style smoothed rectangle corners, including independent corner radii, and effect blend modes from imported Figma files.
|
- Render Figma-style smoothed rectangle corners, including independent corner radii, and effect blend modes from imported Figma files.
|
||||||
- Improve imported tiled image fills by applying Figma image transforms when repeating image patterns.
|
- Improve imported tiled image fills by applying Figma image transforms when repeating image patterns.
|
||||||
|
- Keep imported Figma boolean operations editable as boolean-operation nodes instead of flattening them to vectors.
|
||||||
|
- Apply imported variable font axes from Figma `fontVariations` when rendering text.
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,18 +20,32 @@ export function renderMaskedChildIds(
|
||||||
): void {
|
): void {
|
||||||
for (let index = 0; index < childIds.length; index++) {
|
for (let index = 0; index < childIds.length; index++) {
|
||||||
const childId = childIds[index]
|
const childId = childIds[index]
|
||||||
const maskType = getVisibleMaskType(childId)
|
const firstMaskType = getVisibleMaskType(childId)
|
||||||
if (!maskType) {
|
if (!firstMaskType) {
|
||||||
renderChild(childId)
|
renderChild(childId)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = index + 1
|
const masks: Array<{ id: string; type: MaskType }> = []
|
||||||
|
let maskIndex = index
|
||||||
|
while (maskIndex < childIds.length) {
|
||||||
|
const maskType = getVisibleMaskType(childIds[maskIndex])
|
||||||
|
if (!maskType) break
|
||||||
|
masks.push({ id: childIds[maskIndex], type: maskType })
|
||||||
|
maskIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = maskIndex
|
||||||
let end = start
|
let end = start
|
||||||
while (end < childIds.length && !getVisibleMaskType(childIds[end])) end++
|
while (end < childIds.length && !getVisibleMaskType(childIds[end])) end++
|
||||||
if (start === end) continue
|
if (start === end) {
|
||||||
|
index = maskIndex - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const lumaFilter = maskType === 'LUMINANCE' ? r.ck.ColorFilter.MakeLuma() : null
|
const lumaFilter = masks.some((mask) => mask.type === 'LUMINANCE')
|
||||||
|
? r.ck.ColorFilter.MakeLuma()
|
||||||
|
: null
|
||||||
try {
|
try {
|
||||||
resetMaskPaint(r)
|
resetMaskPaint(r)
|
||||||
canvas.save()
|
canvas.save()
|
||||||
|
|
@ -41,9 +55,18 @@ export function renderMaskedChildIds(
|
||||||
|
|
||||||
resetMaskPaint(r)
|
resetMaskPaint(r)
|
||||||
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.DstIn)
|
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.DstIn)
|
||||||
if (lumaFilter) r.effectLayerPaint.setColorFilter(lumaFilter)
|
|
||||||
canvas.saveLayer(r.effectLayerPaint)
|
canvas.saveLayer(r.effectLayerPaint)
|
||||||
renderMask(childId)
|
for (const mask of masks) {
|
||||||
|
if (mask.type === 'LUMINANCE' && lumaFilter) {
|
||||||
|
resetMaskPaint(r)
|
||||||
|
r.effectLayerPaint.setColorFilter(lumaFilter)
|
||||||
|
canvas.saveLayer(r.effectLayerPaint)
|
||||||
|
renderMask(mask.id)
|
||||||
|
canvas.restore()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
renderMask(mask.id)
|
||||||
|
}
|
||||||
canvas.restore()
|
canvas.restore()
|
||||||
|
|
||||||
canvas.restore()
|
canvas.restore()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import type { CanvasKit, FontWeight, Paragraph, TypefaceFontProvider } from 'canvaskit-wasm'
|
import type {
|
||||||
|
CanvasKit,
|
||||||
|
FontWeight,
|
||||||
|
Paragraph,
|
||||||
|
TextFontVariations,
|
||||||
|
TypefaceFontProvider
|
||||||
|
} from 'canvaskit-wasm'
|
||||||
import { uniq } from 'es-toolkit/array'
|
import { uniq } from 'es-toolkit/array'
|
||||||
|
|
||||||
import { getCanvasKit } from '#core/canvaskit'
|
import { getCanvasKit } from '#core/canvaskit'
|
||||||
|
|
@ -160,6 +166,13 @@ function getParagraphTextAlign(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function textFontVariations(
|
||||||
|
variations: SceneNode['fontVariations'] | undefined
|
||||||
|
): TextFontVariations[] | undefined {
|
||||||
|
if (!variations || variations.length === 0) return undefined
|
||||||
|
return variations.map((variation) => ({ axis: variation.axis, value: variation.value }))
|
||||||
|
}
|
||||||
|
|
||||||
function textDecorationValue(ck: CanvasKit, decoration: string): number {
|
function textDecorationValue(ck: CanvasKit, decoration: string): number {
|
||||||
switch (decoration) {
|
switch (decoration) {
|
||||||
case 'UNDERLINE':
|
case 'UNDERLINE':
|
||||||
|
|
@ -214,6 +227,7 @@ function addStyledRuns(
|
||||||
weight: { value: s.fontWeight ?? node.fontWeight } as FontWeight,
|
weight: { value: s.fontWeight ?? node.fontWeight } as FontWeight,
|
||||||
slant: (s.italic ?? node.italic) ? ck.FontSlant.Italic : ck.FontSlant.Upright
|
slant: (s.italic ?? node.italic) ? ck.FontSlant.Italic : ck.FontSlant.Upright
|
||||||
},
|
},
|
||||||
|
fontVariations: textFontVariations(s.fontVariations ?? node.fontVariations),
|
||||||
letterSpacing: s.letterSpacing ?? (node.letterSpacing || 0),
|
letterSpacing: s.letterSpacing ?? (node.letterSpacing || 0),
|
||||||
decoration: textDecorationValue(ck, s.textDecoration ?? node.textDecoration),
|
decoration: textDecorationValue(ck, s.textDecoration ?? node.textDecoration),
|
||||||
heightMultiplier: runLineHeight ? runLineHeight / runFontSize : undefined,
|
heightMultiplier: runLineHeight ? runLineHeight / runFontSize : undefined,
|
||||||
|
|
@ -269,6 +283,7 @@ export function buildParagraph(
|
||||||
weight: { value: node.fontWeight } as FontWeight,
|
weight: { value: node.fontWeight } as FontWeight,
|
||||||
slant: node.italic ? ck.FontSlant.Italic : ck.FontSlant.Upright
|
slant: node.italic ? ck.FontSlant.Italic : ck.FontSlant.Upright
|
||||||
},
|
},
|
||||||
|
fontVariations: textFontVariations(node.fontVariations),
|
||||||
letterSpacing: node.letterSpacing || 0,
|
letterSpacing: node.letterSpacing || 0,
|
||||||
decoration: textDecorationValue(ck, node.textDecoration),
|
decoration: textDecorationValue(ck, node.textDecoration),
|
||||||
heightMultiplier: node.lineHeight ? node.lineHeight / baseFontSize : undefined,
|
heightMultiplier: node.lineHeight ? node.lineHeight / baseFontSize : undefined,
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,9 @@ export function encodeMessage(message: FigmaMessage): Uint8Array {
|
||||||
const ncHex = Buffer.from(ncBytes).toString('hex')
|
const ncHex = Buffer.from(ncBytes).toString('hex')
|
||||||
const finalHex = beforeArray + ncHex + afterArray
|
const finalHex = beforeArray + ncHex + afterArray
|
||||||
|
|
||||||
const finalBytes = new Uint8Array(finalHex.match(/.{2}/g)?.map((b) => Number.parseInt(b, 16)) ?? [])
|
const finalBytes = new Uint8Array(
|
||||||
|
finalHex.match(/.{2}/g)?.map((b) => Number.parseInt(b, 16)) ?? []
|
||||||
|
)
|
||||||
return compress(finalBytes)
|
return compress(finalBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,6 +308,7 @@ export interface NodeChange {
|
||||||
frameMaskDisabled?: boolean
|
frameMaskDisabled?: boolean
|
||||||
resizeToFit?: boolean
|
resizeToFit?: boolean
|
||||||
// Vector
|
// Vector
|
||||||
|
booleanOperation?: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE'
|
||||||
vectorData?: unknown
|
vectorData?: unknown
|
||||||
fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
|
fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
|
||||||
strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
|
strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
|
||||||
|
|
@ -362,6 +365,7 @@ export interface NodeChange {
|
||||||
textBidiVersion?: number
|
textBidiVersion?: number
|
||||||
textDecoration?: string
|
textDecoration?: string
|
||||||
textDecorationSkipInk?: boolean
|
textDecorationSkipInk?: boolean
|
||||||
|
fontVariations?: Array<{ axisTag?: number; axisName?: string; value?: number }>
|
||||||
fontVariantCommonLigatures?: boolean
|
fontVariantCommonLigatures?: boolean
|
||||||
fontVariantContextualLigatures?: boolean
|
fontVariantContextualLigatures?: boolean
|
||||||
fontVersion?: string
|
fontVersion?: string
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { convertEffects, convertFills, convertStrokes } from './paint'
|
||||||
import { importStyleRuns } from './style-runs'
|
import { importStyleRuns } from './style-runs'
|
||||||
export { importStyleRuns } from './style-runs'
|
export { importStyleRuns } from './style-runs'
|
||||||
import { convertFigmaDerivedTextGlyphs } from './derived-text-glyphs'
|
import { convertFigmaDerivedTextGlyphs } from './derived-text-glyphs'
|
||||||
|
import { convertFontVariations } from './font-variations'
|
||||||
import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
|
import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
|
||||||
export { convertEffects, convertFills, convertStrokes, setVariableColorResolver } from './paint'
|
export { convertEffects, convertFills, convertStrokes, setVariableColorResolver } from './paint'
|
||||||
export { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
|
export { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
|
||||||
|
|
@ -89,7 +90,7 @@ const NODE_TYPE_MAP: Record<string, NodeType | 'DOCUMENT' | 'VARIABLE'> = {
|
||||||
STAR: 'STAR',
|
STAR: 'STAR',
|
||||||
REGULAR_POLYGON: 'POLYGON',
|
REGULAR_POLYGON: 'POLYGON',
|
||||||
VECTOR: 'VECTOR',
|
VECTOR: 'VECTOR',
|
||||||
BOOLEAN_OPERATION: 'VECTOR',
|
BOOLEAN_OPERATION: 'BOOLEAN_OPERATION',
|
||||||
GROUP: 'GROUP',
|
GROUP: 'GROUP',
|
||||||
SECTION: 'SECTION',
|
SECTION: 'SECTION',
|
||||||
COMPONENT: 'COMPONENT',
|
COMPONENT: 'COMPONENT',
|
||||||
|
|
@ -105,6 +106,18 @@ function mapNodeType(type?: string): NodeType | 'DOCUMENT' | 'VARIABLE' {
|
||||||
return 'RECTANGLE'
|
return 'RECTANGLE'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapBooleanOperation(nc: NodeChange): SceneNode['booleanOperation'] {
|
||||||
|
if (nc.type !== 'BOOLEAN_OPERATION') return undefined
|
||||||
|
switch (nc.booleanOperation) {
|
||||||
|
case 'SUBTRACT':
|
||||||
|
case 'INTERSECT':
|
||||||
|
case 'EXCLUDE':
|
||||||
|
return nc.booleanOperation
|
||||||
|
default:
|
||||||
|
return 'UNION'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function mapStackMode(mode?: string): LayoutMode {
|
function mapStackMode(mode?: string): LayoutMode {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'HORIZONTAL':
|
case 'HORIZONTAL':
|
||||||
|
|
@ -280,6 +293,7 @@ function convertTextProps(
|
||||||
| 'letterSpacing'
|
| 'letterSpacing'
|
||||||
| 'maxLines'
|
| 'maxLines'
|
||||||
| 'styleRuns'
|
| 'styleRuns'
|
||||||
|
| 'fontVariations'
|
||||||
| 'textTruncation'
|
| 'textTruncation'
|
||||||
| 'textDirection'
|
| 'textDirection'
|
||||||
| 'figmaDerivedLayout'
|
| 'figmaDerivedLayout'
|
||||||
|
|
@ -304,6 +318,7 @@ function convertTextProps(
|
||||||
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,
|
||||||
styleRuns: importStyleRuns(nc),
|
styleRuns: importStyleRuns(nc),
|
||||||
|
fontVariations: convertFontVariations(nc),
|
||||||
textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED',
|
textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED',
|
||||||
textDirection:
|
textDirection:
|
||||||
(getOpenPencilPluginValue(nc, TEXT_DIRECTION_PLUGIN_KEY) as
|
(getOpenPencilPluginValue(nc, TEXT_DIRECTION_PLUGIN_KEY) as
|
||||||
|
|
@ -466,6 +481,7 @@ export function nodeChangeToProps(
|
||||||
visible: nc.visible ?? true,
|
visible: nc.visible ?? true,
|
||||||
locked: nc.locked ?? false,
|
locked: nc.locked ?? false,
|
||||||
blendMode: (nc.blendMode as Fill['blendMode']) ?? 'PASS_THROUGH',
|
blendMode: (nc.blendMode as Fill['blendMode']) ?? 'PASS_THROUGH',
|
||||||
|
booleanOperation: mapBooleanOperation(nc),
|
||||||
fills: convertFills(nc.fillPaints),
|
fills: convertFills(nc.fillPaints),
|
||||||
strokes: convertStrokes(
|
strokes: convertStrokes(
|
||||||
nc.strokePaints,
|
nc.strokePaints,
|
||||||
|
|
@ -707,7 +723,9 @@ function preserveFigmaPayloadBlobs(value: unknown, blobs: Uint8Array[]): unknown
|
||||||
} else {
|
} else {
|
||||||
result[key] = {
|
result[key] = {
|
||||||
__openPencilFigmaBlob:
|
__openPencilFigmaBlob:
|
||||||
blob instanceof Uint8Array ? blob : new Uint8Array(Object.values(blob as Record<string, number>))
|
blob instanceof Uint8Array
|
||||||
|
? blob
|
||||||
|
: new Uint8Array(Object.values(blob as Record<string, number>))
|
||||||
} satisfies PreservedFigmaBlob
|
} satisfies PreservedFigmaBlob
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -798,10 +816,15 @@ function extractFigmaSymbolMetadata(
|
||||||
| undefined
|
| undefined
|
||||||
return {
|
return {
|
||||||
symbolOverrides: preserveFigmaPayloadBlobs(sd?.symbolOverrides ?? [], blobs) as unknown[],
|
symbolOverrides: preserveFigmaPayloadBlobs(sd?.symbolOverrides ?? [], blobs) as unknown[],
|
||||||
componentPropAssignments: preserveFigmaPayloadBlobs(nc.componentPropAssignments ?? [], blobs) as unknown[],
|
componentPropAssignments: preserveFigmaPayloadBlobs(
|
||||||
|
nc.componentPropAssignments ?? [],
|
||||||
|
blobs
|
||||||
|
) as unknown[],
|
||||||
derivedSymbolData: preserveFigmaPayloadBlobs(nc.derivedSymbolData ?? [], blobs) as unknown[],
|
derivedSymbolData: preserveFigmaPayloadBlobs(nc.derivedSymbolData ?? [], blobs) as unknown[],
|
||||||
derivedSymbolDataLayoutVersion:
|
derivedSymbolDataLayoutVersion:
|
||||||
typeof nc.derivedSymbolDataLayoutVersion === 'number' ? nc.derivedSymbolDataLayoutVersion : null,
|
typeof nc.derivedSymbolDataLayoutVersion === 'number'
|
||||||
|
? nc.derivedSymbolDataLayoutVersion
|
||||||
|
: null,
|
||||||
uniformScaleFactor: typeof sd?.uniformScaleFactor === 'number' ? sd.uniformScaleFactor : null
|
uniformScaleFactor: typeof sd?.uniformScaleFactor === 'number' ? sd.uniformScaleFactor : null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,10 @@ function parseGuidOrNull(value: string) {
|
||||||
return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null
|
return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS = new Set(['variableConsumptionMap', 'parameterConsumptionMap'])
|
const FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS = new Set([
|
||||||
|
'variableConsumptionMap',
|
||||||
|
'parameterConsumptionMap'
|
||||||
|
])
|
||||||
const FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS = new Set(['colorVar', 'opacityVar'])
|
const FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS = new Set(['colorVar', 'opacityVar'])
|
||||||
|
|
||||||
const SUPPORTED_VARIABLE_DATA_TYPES = new Set([
|
const SUPPORTED_VARIABLE_DATA_TYPES = new Set([
|
||||||
|
|
@ -118,14 +121,21 @@ const SUPPORTED_VARIABLE_DATA_TYPES = new Set([
|
||||||
|
|
||||||
function isSupportedVariableMapEntry(value: unknown): boolean {
|
function isSupportedVariableMapEntry(value: unknown): boolean {
|
||||||
if (!value || typeof value !== 'object') return false
|
if (!value || typeof value !== 'object') return false
|
||||||
const entry = value as { variableData?: { dataType?: string; value?: { propRefValue?: unknown } } }
|
const entry = value as {
|
||||||
|
variableData?: { dataType?: string; value?: { propRefValue?: unknown } }
|
||||||
|
}
|
||||||
const dataType = entry.variableData?.dataType
|
const dataType = entry.variableData?.dataType
|
||||||
return (typeof dataType === 'string' && SUPPORTED_VARIABLE_DATA_TYPES.has(dataType)) || !!entry.variableData?.value?.propRefValue
|
return (
|
||||||
|
(typeof dataType === 'string' && SUPPORTED_VARIABLE_DATA_TYPES.has(dataType)) ||
|
||||||
|
!!entry.variableData?.value?.propRefValue
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPropRefVariableMapEntry(value: unknown): boolean {
|
function isPropRefVariableMapEntry(value: unknown): boolean {
|
||||||
if (!value || typeof value !== 'object') return false
|
if (!value || typeof value !== 'object') return false
|
||||||
const entry = value as { variableData?: { dataType?: string; value?: { propRefValue?: unknown } } }
|
const entry = value as {
|
||||||
|
variableData?: { dataType?: string; value?: { propRefValue?: unknown } }
|
||||||
|
}
|
||||||
return entry.variableData?.dataType === 'PROP_REF' || !!entry.variableData?.value?.propRefValue
|
return entry.variableData?.dataType === 'PROP_REF' || !!entry.variableData?.value?.propRefValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,8 +153,9 @@ function materializeSafeVariableMap(
|
||||||
|
|
||||||
function paintVariableKey(value: unknown): string | null {
|
function paintVariableKey(value: unknown): string | null {
|
||||||
if (!value || typeof value !== 'object') return null
|
if (!value || typeof value !== 'object') return null
|
||||||
const assetRef = (value as { value?: { alias?: { assetRef?: { key?: unknown; version?: unknown } } } })
|
const assetRef = (
|
||||||
.value?.alias?.assetRef
|
value as { value?: { alias?: { assetRef?: { key?: unknown; version?: unknown } } } }
|
||||||
|
).value?.alias?.assetRef
|
||||||
return typeof assetRef?.key === 'string'
|
return typeof assetRef?.key === 'string'
|
||||||
? `${assetRef.key}:${typeof assetRef.version === 'string' ? assetRef.version : ''}`
|
? `${assetRef.key}:${typeof assetRef.version === 'string' ? assetRef.version : ''}`
|
||||||
: null
|
: null
|
||||||
|
|
@ -192,7 +203,8 @@ function materializeFigmaPayload(
|
||||||
options: MaterializeFigmaPayloadOptions = {}
|
options: MaterializeFigmaPayloadOptions = {}
|
||||||
): unknown {
|
): unknown {
|
||||||
if (value instanceof Uint8Array) return value
|
if (value instanceof Uint8Array) return value
|
||||||
if (Array.isArray(value)) return value.map((item) => materializeFigmaPayload(item, blobs, options))
|
if (Array.isArray(value))
|
||||||
|
return value.map((item) => materializeFigmaPayload(item, blobs, options))
|
||||||
if (!value || typeof value !== 'object') return value
|
if (!value || typeof value !== 'object') return value
|
||||||
if ('__openPencilFigmaBlob' in value) {
|
if ('__openPencilFigmaBlob' in value) {
|
||||||
return materializeFigmaBlob(
|
return materializeFigmaBlob(
|
||||||
|
|
@ -326,11 +338,15 @@ function applyInstancePayload(
|
||||||
if (symbolID) {
|
if (symbolID) {
|
||||||
const symbolData: Record<string, unknown> = { symbolID }
|
const symbolData: Record<string, unknown> = { symbolID }
|
||||||
if (node.source.fig.symbolOverrides.length > 0) {
|
if (node.source.fig.symbolOverrides.length > 0) {
|
||||||
symbolData.symbolOverrides = materializeFigmaPayload(node.source.fig.symbolOverrides, context.blobs, {
|
symbolData.symbolOverrides = materializeFigmaPayload(
|
||||||
|
node.source.fig.symbolOverrides,
|
||||||
|
context.blobs,
|
||||||
|
{
|
||||||
blobIndexByHex: context.blobIndexByHex,
|
blobIndexByHex: context.blobIndexByHex,
|
||||||
includeVariableMaps: true,
|
includeVariableMaps: true,
|
||||||
paintVariableColorMap: context.paintVariableColorMap
|
paintVariableColorMap: context.paintVariableColorMap
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (node.source.fig.uniformScaleFactor != null) {
|
if (node.source.fig.uniformScaleFactor != null) {
|
||||||
symbolData.uniformScaleFactor = node.source.fig.uniformScaleFactor
|
symbolData.uniformScaleFactor = node.source.fig.uniformScaleFactor
|
||||||
|
|
@ -349,11 +365,15 @@ function applyInstancePayload(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (node.source.fig.derivedSymbolData.length > 0) {
|
if (node.source.fig.derivedSymbolData.length > 0) {
|
||||||
nc.derivedSymbolData = materializeFigmaPayload(node.source.fig.derivedSymbolData, context.blobs, {
|
nc.derivedSymbolData = materializeFigmaPayload(
|
||||||
|
node.source.fig.derivedSymbolData,
|
||||||
|
context.blobs,
|
||||||
|
{
|
||||||
blobIndexByHex: context.blobIndexByHex,
|
blobIndexByHex: context.blobIndexByHex,
|
||||||
includeVariableMaps: true,
|
includeVariableMaps: true,
|
||||||
paintVariableColorMap: context.paintVariableColorMap
|
paintVariableColorMap: context.paintVariableColorMap
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (node.source.fig.derivedSymbolDataLayoutVersion != null) {
|
if (node.source.fig.derivedSymbolDataLayoutVersion != null) {
|
||||||
nc.derivedSymbolDataLayoutVersion = node.source.fig.derivedSymbolDataLayoutVersion
|
nc.derivedSymbolDataLayoutVersion = node.source.fig.derivedSymbolDataLayoutVersion
|
||||||
|
|
@ -400,15 +420,22 @@ function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportNodeSize(node: SceneNode): Vector {
|
function exportNodeSize(node: SceneNode): Vector {
|
||||||
return node.source.fig.rawSize ? { ...node.source.fig.rawSize } : { x: node.width, y: node.height }
|
return node.source.fig.rawSize
|
||||||
|
? { ...node.source.fig.rawSize }
|
||||||
|
: { x: node.width, y: node.height }
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportNodeTransform(context: SceneNodeToKiwiContext, node: SceneNode): Matrix {
|
function exportNodeTransform(context: SceneNodeToKiwiContext, node: SceneNode): Matrix {
|
||||||
return node.source.fig.rawTransform ? { ...node.source.fig.rawTransform } : context.computeExportTransform(node)
|
return node.source.fig.rawTransform
|
||||||
|
? { ...node.source.fig.rawTransform }
|
||||||
|
: context.computeExportTransform(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasRawGeometryPayload(node: SceneNode): boolean {
|
function hasRawGeometryPayload(node: SceneNode): boolean {
|
||||||
return 'fillGeometry' in node.source.fig.rawNodeFields || 'strokeGeometry' in node.source.fig.rawNodeFields
|
return (
|
||||||
|
'fillGeometry' in node.source.fig.rawNodeFields ||
|
||||||
|
'strokeGeometry' in node.source.fig.rawNodeFields
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasRawVectorPayload(node: SceneNode): boolean {
|
function hasRawVectorPayload(node: SceneNode): boolean {
|
||||||
|
|
@ -527,6 +554,7 @@ export function sceneNodeToKiwiWithContext(
|
||||||
applyInstancePayload(context, node, nc, localIdCounter)
|
applyInstancePayload(context, node, nc, localIdCounter)
|
||||||
if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type)
|
if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type)
|
||||||
if (nc.type === 'CANVAS') nc.pageType = 'DESIGN'
|
if (nc.type === 'CANVAS') nc.pageType = 'DESIGN'
|
||||||
|
if (node.type === 'BOOLEAN_OPERATION') nc.booleanOperation = node.booleanOperation ?? 'UNION'
|
||||||
if (strokePaints.length > 0) nc.strokePaints = strokePaints
|
if (strokePaints.length > 0) nc.strokePaints = strokePaints
|
||||||
|
|
||||||
context.serializeLayoutProps(node, nc)
|
context.serializeLayoutProps(node, nc)
|
||||||
|
|
|
||||||
23
packages/core/src/kiwi/fig/node-change/font-variations.ts
Normal file
23
packages/core/src/kiwi/fig/node-change/font-variations.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type { NodeChange } from '#core/kiwi/fig/codec'
|
||||||
|
import type { FontVariation } from '#core/scene-graph'
|
||||||
|
|
||||||
|
function figmaAxisTagToString(axisTag: number): string {
|
||||||
|
return String.fromCharCode(
|
||||||
|
(axisTag >> 24) & 0xff,
|
||||||
|
(axisTag >> 16) & 0xff,
|
||||||
|
(axisTag >> 8) & 0xff,
|
||||||
|
axisTag & 0xff
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertFontVariations(nc: NodeChange): FontVariation[] {
|
||||||
|
const result: FontVariation[] = []
|
||||||
|
for (const variation of nc.fontVariations ?? []) {
|
||||||
|
if (typeof variation.value !== 'number') continue
|
||||||
|
const axis =
|
||||||
|
variation.axisName ||
|
||||||
|
(typeof variation.axisTag === 'number' ? figmaAxisTagToString(variation.axisTag) : '')
|
||||||
|
if (axis) result.push({ axis, value: variation.value })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,8 @@ export function mapToFigmaType(type: SceneNode['type']): string {
|
||||||
return 'REGULAR_POLYGON'
|
return 'REGULAR_POLYGON'
|
||||||
case 'VECTOR':
|
case 'VECTOR':
|
||||||
return 'VECTOR'
|
return 'VECTOR'
|
||||||
|
case 'BOOLEAN_OPERATION':
|
||||||
|
return 'BOOLEAN_OPERATION'
|
||||||
case 'GROUP':
|
case 'GROUP':
|
||||||
return 'FRAME'
|
return 'FRAME'
|
||||||
case 'SECTION':
|
case 'SECTION':
|
||||||
|
|
@ -137,7 +139,8 @@ function buildDerivedTextData(
|
||||||
: glyphAdvance,
|
: glyphAdvance,
|
||||||
rotation: 0
|
rotation: 0
|
||||||
}))
|
}))
|
||||||
: (getGlyphOutlineMetricsSync(
|
: (
|
||||||
|
getGlyphOutlineMetricsSync(
|
||||||
node.fontFamily,
|
node.fontFamily,
|
||||||
weightToStyle(node.fontWeight, node.italic),
|
weightToStyle(node.fontWeight, node.italic),
|
||||||
node.text,
|
node.text,
|
||||||
|
|
@ -173,6 +176,10 @@ function buildDerivedTextData(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fontVariationToKiwi(variation: SceneNode['fontVariations'][number]) {
|
||||||
|
return { axisName: variation.axis, value: variation.value }
|
||||||
|
}
|
||||||
|
|
||||||
function exportTextData(node: SceneNode): NodeChange['textData'] {
|
function exportTextData(node: SceneNode): NodeChange['textData'] {
|
||||||
const runs = node.styleRuns
|
const runs = node.styleRuns
|
||||||
if (runs.length === 0) {
|
if (runs.length === 0) {
|
||||||
|
|
@ -206,6 +213,9 @@ function exportTextData(node: SceneNode): NodeChange['textData'] {
|
||||||
postscript: ''
|
postscript: ''
|
||||||
}
|
}
|
||||||
if (style.fontSize !== undefined) override.fontSize = style.fontSize
|
if (style.fontSize !== undefined) override.fontSize = style.fontSize
|
||||||
|
if (style.fontVariations && style.fontVariations.length > 0) {
|
||||||
|
override.fontVariations = style.fontVariations.map(fontVariationToKiwi)
|
||||||
|
}
|
||||||
if (style.letterSpacing !== undefined) {
|
if (style.letterSpacing !== undefined) {
|
||||||
override.letterSpacing = { value: style.letterSpacing, units: 'PIXELS' }
|
override.letterSpacing = { value: style.letterSpacing, units: 'PIXELS' }
|
||||||
}
|
}
|
||||||
|
|
@ -300,6 +310,9 @@ function serializeTextProps(
|
||||||
postscript: ''
|
postscript: ''
|
||||||
}
|
}
|
||||||
nc.textData = exportTextData(node)
|
nc.textData = exportTextData(node)
|
||||||
|
if (node.fontVariations.length > 0) {
|
||||||
|
nc.fontVariations = node.fontVariations.map(fontVariationToKiwi)
|
||||||
|
}
|
||||||
const autoResize = resolveTextAutoResize(node, graph)
|
const autoResize = resolveTextAutoResize(node, graph)
|
||||||
nc.textAutoResize = autoResize
|
nc.textAutoResize = autoResize
|
||||||
nc.textAlignHorizontal = node.textAlignHorizontal
|
nc.textAlignHorizontal = node.textAlignHorizontal
|
||||||
|
|
@ -322,16 +335,14 @@ function serializeTextProps(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStackMode(
|
function normalizeStackMode(value: string | undefined): KiwiNodeChange['stackMode'] {
|
||||||
value: string | undefined
|
|
||||||
): KiwiNodeChange['stackMode'] {
|
|
||||||
return value === 'HORIZONTAL' || value === 'VERTICAL' || value === 'NONE' ? value : undefined
|
return value === 'HORIZONTAL' || value === 'VERTICAL' || value === 'NONE' ? value : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStackSizing(
|
function normalizeStackSizing(value: string | undefined): KiwiNodeChange['stackPrimarySizing'] {
|
||||||
value: string | undefined
|
return value === 'FIXED' ||
|
||||||
): KiwiNodeChange['stackPrimarySizing'] {
|
value === 'RESIZE_TO_FIT' ||
|
||||||
return value === 'FIXED' || value === 'RESIZE_TO_FIT' || value === 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE'
|
value === 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE'
|
||||||
? value
|
? value
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { NodeChange } from '#core/kiwi/fig/codec'
|
||||||
import type { CharacterStyleOverride, StyleRun } from '#core/scene-graph'
|
import type { CharacterStyleOverride, StyleRun } from '#core/scene-graph'
|
||||||
import { styleToWeight } from '#core/text/fonts'
|
import { styleToWeight } from '#core/text/fonts'
|
||||||
|
|
||||||
|
import { convertFontVariations } from './font-variations'
|
||||||
import { convertFills } from './paint'
|
import { convertFills } from './paint'
|
||||||
import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
|
import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
|
||||||
|
|
||||||
|
|
@ -16,6 +17,8 @@ function convertStyleOverride(
|
||||||
style.italic = override.fontName.style.toLowerCase().includes('italic')
|
style.italic = override.fontName.style.toLowerCase().includes('italic')
|
||||||
}
|
}
|
||||||
if (override.fontSize !== undefined) style.fontSize = override.fontSize
|
if (override.fontSize !== undefined) style.fontSize = override.fontSize
|
||||||
|
const fontVariations = convertFontVariations(override)
|
||||||
|
if (fontVariations.length > 0) style.fontVariations = fontVariations
|
||||||
if (override.letterSpacing) {
|
if (override.letterSpacing) {
|
||||||
style.letterSpacing = convertLetterSpacing(
|
style.letterSpacing = convertLetterSpacing(
|
||||||
override.letterSpacing,
|
override.letterSpacing,
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,7 @@ export function createDefaultNode(
|
||||||
},
|
},
|
||||||
figmaDerivedLayout: null,
|
figmaDerivedLayout: null,
|
||||||
fills:
|
fills:
|
||||||
type === 'TEXT'
|
type === 'TEXT' ? [{ type: 'SOLID' as const, color: BLACK, opacity: 1, visible: true }] : [],
|
||||||
? [{ type: 'SOLID' as const, color: BLACK, opacity: 1, visible: true }]
|
|
||||||
: [],
|
|
||||||
strokes: [],
|
strokes: [],
|
||||||
effects: [],
|
effects: [],
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
|
|
@ -88,6 +86,7 @@ export function createDefaultNode(
|
||||||
textDecoration: 'NONE',
|
textDecoration: 'NONE',
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
styleRuns: [],
|
styleRuns: [],
|
||||||
|
fontVariations: [],
|
||||||
horizontalConstraint: 'MIN',
|
horizontalConstraint: 'MIN',
|
||||||
verticalConstraint: 'MIN',
|
verticalConstraint: 'MIN',
|
||||||
strokeCap: 'NONE',
|
strokeCap: 'NONE',
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,11 @@ export type TextDecoration = 'NONE' | 'UNDERLINE' | 'STRIKETHROUGH'
|
||||||
export type TextDirection = 'AUTO' | 'LTR' | 'RTL'
|
export type TextDirection = 'AUTO' | 'LTR' | 'RTL'
|
||||||
export type LayoutDirection = 'AUTO' | 'LTR' | 'RTL'
|
export type LayoutDirection = 'AUTO' | 'LTR' | 'RTL'
|
||||||
|
|
||||||
|
export interface FontVariation {
|
||||||
|
axis: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface CharacterStyleOverride {
|
export interface CharacterStyleOverride {
|
||||||
fontWeight?: number
|
fontWeight?: number
|
||||||
italic?: boolean
|
italic?: boolean
|
||||||
|
|
@ -182,6 +187,7 @@ export interface CharacterStyleOverride {
|
||||||
letterSpacing?: number
|
letterSpacing?: number
|
||||||
lineHeight?: number | null
|
lineHeight?: number | null
|
||||||
fills?: Fill[]
|
fills?: Fill[]
|
||||||
|
fontVariations?: FontVariation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StyleRun {
|
export interface StyleRun {
|
||||||
|
|
@ -326,6 +332,7 @@ export interface SceneNode {
|
||||||
maxLines: number | null
|
maxLines: number | null
|
||||||
|
|
||||||
styleRuns: StyleRun[]
|
styleRuns: StyleRun[]
|
||||||
|
fontVariations: FontVariation[]
|
||||||
|
|
||||||
horizontalConstraint: ConstraintType
|
horizontalConstraint: ConstraintType
|
||||||
verticalConstraint: ConstraintType
|
verticalConstraint: ConstraintType
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ OpenPencil is moving toward production-grade Figma compatibility while keeping d
|
||||||
|
|
||||||
- Preserve and round-trip more Figma metadata safely.
|
- Preserve and round-trip more Figma metadata safely.
|
||||||
- Add visual regression coverage for full multi-page `.fig` documents.
|
- Add visual regression coverage for full multi-page `.fig` documents.
|
||||||
- Close high-impact renderer gaps: masks, blend modes, corner smoothing, pattern fills, and variable font axes.
|
- Close high-impact renderer gaps: remaining mask edge cases, blend isolation, pattern fills, and broader variable-font fixtures.
|
||||||
- Improve boolean operation import so Figma `BOOLEAN_OPERATION` nodes remain editable where possible.
|
- Improve boolean operation editing/export now that imported Figma `BOOLEAN_OPERATION` nodes remain boolean operations.
|
||||||
|
|
||||||
### Editor depth
|
### Editor depth
|
||||||
|
|
||||||
|
|
@ -117,7 +117,7 @@ Figma's design documentation groups features into these areas:
|
||||||
| Polygons / stars | ✅ | ✅ | ◐ | ✅ | ✅ | `pointCount` and `starInnerRadius` modeled. |
|
| Polygons / stars | ✅ | ✅ | ◐ | ✅ | ✅ | `pointCount` and `starInnerRadius` modeled. |
|
||||||
| Text | ✅ | ✅ | ◐ | ✅ | ✅ | Derived Figma glyphs improve fidelity; advanced typography is partial. |
|
| Text | ✅ | ✅ | ◐ | ✅ | ✅ | Derived Figma glyphs improve fidelity; advanced typography is partial. |
|
||||||
| Vectors / vector networks | ✅ | ✅ | ◐ | ✅ | ✅ | Vector edit support exists; Figma Draw tools are not fully replicated. |
|
| Vectors / vector networks | ✅ | ✅ | ◐ | ✅ | ✅ | Vector edit support exists; Figma Draw tools are not fully replicated. |
|
||||||
| Boolean operations | ◐ | ✅ | ◐ | ◐ | ✅ | Engine/renderer support exists, but `.fig` import currently maps Figma `BOOLEAN_OPERATION` nodes to `VECTOR`. |
|
| Boolean operations | ✅ | ✅ | ◐ | ✅ | ✅ | Figma `BOOLEAN_OPERATION` nodes import/export as boolean operations; inspector editing remains limited. |
|
||||||
| Components | ✅ | ✅ | ◐ | ✅ | ✅ | Component metadata, descriptions, links, and publish fields mostly round-trip. |
|
| Components | ✅ | ✅ | ◐ | ✅ | ✅ | Component metadata, descriptions, links, and publish fields mostly round-trip. |
|
||||||
| Component sets / variants | ✅ | ✅ | ◐ | ✅ | ✅ | Variant values are usable; full component property authoring is incomplete. |
|
| Component sets / variants | ✅ | ✅ | ◐ | ✅ | ✅ | Variant values are usable; full component property authoring is incomplete. |
|
||||||
| Instances / overrides | ✅ | ✅ | ◐ | ✅ | ✅ | Raw symbol overrides and derived symbol data are preserved for fidelity. |
|
| Instances / overrides | ✅ | ✅ | ◐ | ✅ | ✅ | Raw symbol overrides and derived symbol data are preserved for fidelity. |
|
||||||
|
|
@ -139,7 +139,7 @@ Figma's design documentation groups features into these areas:
|
||||||
| Effect styles | ↩ | — | — | ↩ | — | Style IDs round-trip; no style manager. |
|
| Effect styles | ↩ | — | — | ↩ | — | Style IDs round-trip; no style manager. |
|
||||||
| Corner radius | ✅ | ✅ | ✅ | ✅ | ✅ | Uniform and independent radii supported. |
|
| Corner radius | ✅ | ✅ | ✅ | ✅ | ✅ | Uniform and independent radii supported. |
|
||||||
| Corner smoothing | ✅ | ✅ | — | ✅ | ✅ | Figma-style smoothed corners render for common uniform and independent-radius rectangles; exact parity still needs broader fixture tuning. |
|
| Corner smoothing | ✅ | ✅ | — | ✅ | ✅ | Figma-style smoothed corners render for common uniform and independent-radius rectangles; exact parity still needs broader fixture tuning. |
|
||||||
| Masks | ✅ | ◐ | — | ✅ | ✅ | Common sibling alpha/vector/luminance mask stacks render; UI controls and edge-case Figma semantics remain incomplete. |
|
| Masks | ✅ | ◐ | — | ✅ | ✅ | Common sibling alpha/vector/luminance mask stacks render, including consecutive mask layers; UI controls and deeper Figma edge cases remain incomplete. |
|
||||||
| Auto layout: vertical/horizontal | ✅ | ✅ | ✅ | ✅ | ✅ | Yoga-backed layout. |
|
| Auto layout: vertical/horizontal | ✅ | ✅ | ✅ | ✅ | ✅ | Yoga-backed layout. |
|
||||||
| Auto layout: wrap | ✅ | ✅ | ✅ | ✅ | ✅ | UI toggle exists. |
|
| Auto layout: wrap | ✅ | ✅ | ✅ | ✅ | ✅ | UI toggle exists. |
|
||||||
| Auto layout: grid | ✅ | ◐ | ◐ | ✅ | ✅ | CSS-grid-like support is partial. |
|
| Auto layout: grid | ✅ | ◐ | ◐ | ✅ | ✅ | CSS-grid-like support is partial. |
|
||||||
|
|
@ -157,7 +157,7 @@ Figma's design documentation groups features into these areas:
|
||||||
| Text case | ✅ | ◐ | — | ✅ | ✅ | Model/export/JSX support; UI missing. |
|
| Text case | ✅ | ◐ | — | ✅ | ✅ | Model/export/JSX support; UI missing. |
|
||||||
| Vertical text alignment | ✅ | ◐ | — | ✅ | ✅ | Modeled; UI/render parity needs more coverage. |
|
| Vertical text alignment | ✅ | ◐ | — | ✅ | ✅ | Modeled; UI/render parity needs more coverage. |
|
||||||
| Justified text | ✅ | ◐ | — | ✅ | ✅ | Modeled; UI does not expose it. |
|
| Justified text | ✅ | ◐ | — | ✅ | ✅ | Modeled; UI does not expose it. |
|
||||||
| Font variations / OpenType features | ↩ | — | — | ↩ | — | `fontVariations` are preserved only. |
|
| Font variations / OpenType features | ✅ | ✅ | — | ✅ | — | Imported `fontVariations` are applied to CanvasKit text styles and exported; OpenType feature controls are not exposed. |
|
||||||
| Variables: collections/modes/aliases | ✅ | ◐ | ◐ | ✅ | ✅ | Color/number/string/boolean model exists; inspector coverage is still incomplete. |
|
| Variables: collections/modes/aliases | ✅ | ◐ | ◐ | ✅ | ✅ | Color/number/string/boolean model exists; inspector coverage is still incomplete. |
|
||||||
| Variables bound to fills/strokes | ✅ | ✅ | ✅ | ✅ | ✅ | Common color bindings render and edit. |
|
| Variables bound to fills/strokes | ✅ | ✅ | ✅ | ✅ | ✅ | Common color bindings render and edit. |
|
||||||
| Variables bound to text/layout/visibility/effects | ◐ | ◐ | ◐ | ◐ | ✅ | Some bindings exist; not full Figma property coverage. |
|
| Variables bound to text/layout/visibility/effects | ◐ | ◐ | ◐ | ◐ | ✅ | Some bindings exist; not full Figma property coverage. |
|
||||||
|
|
@ -196,18 +196,18 @@ OpenPencil deliberately preserves many Figma/Kiwi fields even when they are not
|
||||||
| 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. |
|
| Text internals: `textData`, layout versions, font version, derived data | ✅ | ✅ | — | Important for text fidelity; most internals are not editable. |
|
||||||
| `fontVariations` | ↩ | — | — | Variable font data is preserved, not rendered. |
|
| `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. |
|
| Raw paint/effect/vector/geometry payloads | ✅ | ✅ | ◐ | Converted fields render; raw payloads preserve Figma import/export details. |
|
||||||
|
|
||||||
## Highest-priority visual gaps
|
## Highest-priority visual gaps
|
||||||
|
|
||||||
These are parsed or visible in Figma docs and most likely to cause visible differences in real design files:
|
These are parsed or visible in Figma docs and most likely to cause visible differences in real design files:
|
||||||
|
|
||||||
1. **Masks** — tune multi-mask stacks and exact Figma edge cases beyond the common alpha/vector/luminance path.
|
1. **Masks** — tune remaining exact Figma stack semantics beyond common alpha/vector/luminance and consecutive-mask paths.
|
||||||
2. **Corner smoothing** — expand Figma fixture comparisons and tune remaining stroke/effect edge cases.
|
2. **Corner smoothing** — expand Figma fixture comparisons and tune remaining stroke/effect edge cases.
|
||||||
3. **Pattern fills/strokes** — support Figma pattern paint objects and transforms beyond image tile fills.
|
3. **Pattern fills/strokes** — support Figma pattern paint objects and transforms beyond image tile fills.
|
||||||
4. **Font variations** — apply variable-font axes from imported Figma metadata.
|
4. **Variable-font fixtures** — broaden real-file coverage for variable axes and OpenType feature metadata.
|
||||||
5. **Boolean operation import** — keep Figma `BOOLEAN_OPERATION` nodes as boolean operations where possible instead of importing them as vectors.
|
5. **Boolean operation editing** — improve inspector/tooling workflows for imported boolean-operation nodes.
|
||||||
6. **Layout grids and guides** — render/edit page guides and Figma layout grids, or clearly keep them round-trip-only.
|
6. **Layout grids and guides** — render/edit page guides and Figma layout grids, or clearly keep them round-trip-only.
|
||||||
7. **Full component property and slot workflows** — support authoring, not just preserving imported payloads.
|
7. **Full component property and slot workflows** — support authoring, not just preserving imported payloads.
|
||||||
8. **Prototype metadata** — start by preserving prototype flows/connections even before building playback.
|
8. **Prototype metadata** — start by preserving prototype flows/connections even before building playback.
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,9 @@ test('gradients and image fill modes', async () => {
|
||||||
width: 700,
|
width: 700,
|
||||||
height: 250,
|
height: 250,
|
||||||
cornerRadius: 22,
|
cornerRadius: 22,
|
||||||
fills: [{ type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.97, a: 1 }, visible: true, opacity: 1 }]
|
fills: [
|
||||||
|
{ type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.97, a: 1 }, visible: true, opacity: 1 }
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const gradientStops = [
|
const gradientStops = [
|
||||||
|
|
@ -112,7 +114,12 @@ test('gradients and image fill modes', async () => {
|
||||||
{ color: { r: 0.58, g: 0.27, b: 0.95, a: 1 }, position: 0.55 },
|
{ color: { r: 0.58, g: 0.27, b: 0.95, a: 1 }, position: 0.55 },
|
||||||
{ color: { r: 0.08, g: 0.73, b: 0.73, a: 1 }, position: 1 }
|
{ color: { r: 0.08, g: 0.73, b: 0.73, a: 1 }, position: 1 }
|
||||||
]
|
]
|
||||||
const gradientTypes = ['GRADIENT_LINEAR', 'GRADIENT_RADIAL', 'GRADIENT_ANGULAR', 'GRADIENT_DIAMOND'] as const
|
const gradientTypes = [
|
||||||
|
'GRADIENT_LINEAR',
|
||||||
|
'GRADIENT_RADIAL',
|
||||||
|
'GRADIENT_ANGULAR',
|
||||||
|
'GRADIENT_DIAMOND'
|
||||||
|
] as const
|
||||||
for (const [index, type] of gradientTypes.entries()) {
|
for (const [index, type] of gradientTypes.entries()) {
|
||||||
store.graph.createNode('RECTANGLE', pageId, {
|
store.graph.createNode('RECTANGLE', pageId, {
|
||||||
name: `${type} visual`,
|
name: `${type} visual`,
|
||||||
|
|
@ -121,7 +128,9 @@ test('gradients and image fill modes', async () => {
|
||||||
width: 92,
|
width: 92,
|
||||||
height: 72,
|
height: 72,
|
||||||
cornerRadius: 16,
|
cornerRadius: 16,
|
||||||
fills: [{ type, color: { r: 0, g: 0, b: 0, a: 1 }, visible: true, opacity: 1, gradientStops }]
|
fills: [
|
||||||
|
{ type, color: { r: 0, g: 0, b: 0, a: 1 }, visible: true, opacity: 1, gradientStops }
|
||||||
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -233,7 +242,9 @@ test('luminance masks and transformed tile fills', async () => {
|
||||||
width: 240,
|
width: 240,
|
||||||
height: 130,
|
height: 130,
|
||||||
cornerRadius: 18,
|
cornerRadius: 18,
|
||||||
fills: [{ type: 'SOLID', color: { r: 0.08, g: 0.1, b: 0.18, a: 1 }, visible: true, opacity: 1 }]
|
fills: [
|
||||||
|
{ type: 'SOLID', color: { r: 0.08, g: 0.1, b: 0.18, a: 1 }, visible: true, opacity: 1 }
|
||||||
|
]
|
||||||
})
|
})
|
||||||
store.graph.createNode('RECTANGLE', frame.id, {
|
store.graph.createNode('RECTANGLE', frame.id, {
|
||||||
name: 'Luminance mask gradient',
|
name: 'Luminance mask gradient',
|
||||||
|
|
@ -281,6 +292,57 @@ test('luminance masks and transformed tile fills', async () => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const multiMaskFrame = store.graph.createNode('FRAME', pageId, {
|
||||||
|
name: 'Consecutive mask stack visual',
|
||||||
|
x: 620,
|
||||||
|
y: 76,
|
||||||
|
width: 220,
|
||||||
|
height: 130,
|
||||||
|
cornerRadius: 18,
|
||||||
|
fills: [
|
||||||
|
{ type: 'SOLID', color: { r: 0.08, g: 0.1, b: 0.18, a: 1 }, visible: true, opacity: 1 }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
store.graph.createNode('ELLIPSE', multiMaskFrame.id, {
|
||||||
|
name: 'First combined mask',
|
||||||
|
x: 18,
|
||||||
|
y: 16,
|
||||||
|
width: 112,
|
||||||
|
height: 98,
|
||||||
|
isMask: true,
|
||||||
|
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, visible: true, opacity: 1 }]
|
||||||
|
})
|
||||||
|
store.graph.createNode('ELLIPSE', multiMaskFrame.id, {
|
||||||
|
name: 'Second combined mask',
|
||||||
|
x: 90,
|
||||||
|
y: 16,
|
||||||
|
width: 112,
|
||||||
|
height: 98,
|
||||||
|
isMask: true,
|
||||||
|
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, visible: true, opacity: 1 }]
|
||||||
|
})
|
||||||
|
store.graph.createNode('RECTANGLE', multiMaskFrame.id, {
|
||||||
|
name: 'Consecutively masked content',
|
||||||
|
x: 18,
|
||||||
|
y: 16,
|
||||||
|
width: 184,
|
||||||
|
height: 98,
|
||||||
|
fills: [
|
||||||
|
{
|
||||||
|
type: 'GRADIENT_LINEAR',
|
||||||
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||||
|
visible: true,
|
||||||
|
opacity: 1,
|
||||||
|
gradientStops: [
|
||||||
|
{ color: { r: 0.96, g: 0.35, b: 0.35, a: 1 }, position: 0 },
|
||||||
|
{ color: { r: 0.08, g: 0.73, b: 0.73, a: 1 }, position: 0.5 },
|
||||||
|
{ color: { r: 0.58, g: 0.27, b: 0.95, a: 1 }, position: 1 }
|
||||||
|
],
|
||||||
|
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
store.clearSelection()
|
store.clearSelection()
|
||||||
store.requestRender()
|
store.requestRender()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 32 KiB |
19
tests/engine/io/fig/export/boolean-operation.test.ts
Normal file
19
tests/engine/io/fig/export/boolean-operation.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { sceneNodeToKiwi } from '#core/kiwi/fig/node-change/serialize'
|
||||||
|
import { SceneGraph } from '#core/scene-graph'
|
||||||
|
|
||||||
|
describe('Figma boolean operation export', () => {
|
||||||
|
test('exports boolean operation node type and operation', () => {
|
||||||
|
const graph = new SceneGraph()
|
||||||
|
const page = graph.getPages()[0]
|
||||||
|
const node = graph.createNode('BOOLEAN_OPERATION', page.id, {
|
||||||
|
booleanOperation: 'INTERSECT'
|
||||||
|
})
|
||||||
|
|
||||||
|
const changes = sceneNodeToKiwi(node, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [])
|
||||||
|
|
||||||
|
expect(changes[0].type).toBe('BOOLEAN_OPERATION')
|
||||||
|
expect(changes[0].booleanOperation).toBe('INTERSECT')
|
||||||
|
})
|
||||||
|
})
|
||||||
30
tests/engine/io/fig/export/font-variations.test.ts
Normal file
30
tests/engine/io/fig/export/font-variations.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { sceneNodeToKiwi } from '#core/kiwi/fig/node-change/serialize'
|
||||||
|
import { SceneGraph } from '#core/scene-graph'
|
||||||
|
|
||||||
|
describe('Figma font variation export', () => {
|
||||||
|
test('exports base and styled-run variable font axes', () => {
|
||||||
|
const graph = new SceneGraph()
|
||||||
|
const page = graph.getPages()[0]
|
||||||
|
const text = graph.createNode('TEXT', page.id, {
|
||||||
|
text: 'Axis',
|
||||||
|
fontVariations: [{ axis: 'wght', value: 650 }],
|
||||||
|
styleRuns: [
|
||||||
|
{
|
||||||
|
start: 0,
|
||||||
|
length: 2,
|
||||||
|
style: { fontVariations: [{ axis: 'wdth', value: 88 }] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const changes = sceneNodeToKiwi(text, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [])
|
||||||
|
const nodeChange = changes[0]
|
||||||
|
|
||||||
|
expect(nodeChange.fontVariations).toEqual([{ axisName: 'wght', value: 650 }])
|
||||||
|
expect(nodeChange.textData?.styleOverrideTable?.[0]?.fontVariations).toEqual([
|
||||||
|
{ axisName: 'wdth', value: 88 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
33
tests/engine/io/fig/import/boolean-operation.test.ts
Normal file
33
tests/engine/io/fig/import/boolean-operation.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import type { NodeChange } from '#core/kiwi/fig/codec'
|
||||||
|
import { nodeChangeToProps } from '#core/kiwi/fig/node-change/convert'
|
||||||
|
|
||||||
|
describe('Figma boolean operation import', () => {
|
||||||
|
test('preserves boolean operation nodes', () => {
|
||||||
|
const props = nodeChangeToProps(
|
||||||
|
{
|
||||||
|
type: 'BOOLEAN_OPERATION',
|
||||||
|
name: 'Imported boolean',
|
||||||
|
booleanOperation: 'SUBTRACT'
|
||||||
|
} as NodeChange,
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(props.nodeType).toBe('BOOLEAN_OPERATION')
|
||||||
|
expect(props.booleanOperation).toBe('SUBTRACT')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('defaults missing boolean operations to union', () => {
|
||||||
|
const props = nodeChangeToProps(
|
||||||
|
{
|
||||||
|
type: 'BOOLEAN_OPERATION',
|
||||||
|
name: 'Imported boolean'
|
||||||
|
} as NodeChange,
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(props.nodeType).toBe('BOOLEAN_OPERATION')
|
||||||
|
expect(props.booleanOperation).toBe('UNION')
|
||||||
|
})
|
||||||
|
})
|
||||||
46
tests/engine/io/fig/import/font-variations.test.ts
Normal file
46
tests/engine/io/fig/import/font-variations.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import type { NodeChange } from '#core/kiwi/fig/codec'
|
||||||
|
import { nodeChangeToProps, importStyleRuns } from '#core/kiwi/fig/node-change/convert'
|
||||||
|
|
||||||
|
describe('Figma font variation import', () => {
|
||||||
|
test('imports base text variable font axes', () => {
|
||||||
|
const props = nodeChangeToProps(
|
||||||
|
{
|
||||||
|
type: 'TEXT',
|
||||||
|
textData: { characters: 'Axis' },
|
||||||
|
fontVariations: [
|
||||||
|
{ axisTag: 0x77676874, value: 650 },
|
||||||
|
{ axisName: 'wdth', value: 88 }
|
||||||
|
]
|
||||||
|
} as NodeChange,
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(props.fontVariations).toEqual([
|
||||||
|
{ axis: 'wght', value: 650 },
|
||||||
|
{ axis: 'wdth', value: 88 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('imports styled-run variable font axes', () => {
|
||||||
|
const runs = importStyleRuns({
|
||||||
|
type: 'TEXT',
|
||||||
|
fontSize: 16,
|
||||||
|
textData: {
|
||||||
|
characters: 'Axis',
|
||||||
|
characterStyleIDs: [1, 1, 0, 0],
|
||||||
|
styleOverrideTable: [
|
||||||
|
{
|
||||||
|
styleID: 1,
|
||||||
|
fontVariations: [{ axisName: 'GRAD', value: -50 }]
|
||||||
|
} as NodeChange
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} as NodeChange)
|
||||||
|
|
||||||
|
expect(runs).toEqual([
|
||||||
|
{ start: 0, length: 2, style: { fontVariations: [{ axis: 'GRAD', value: -50 }] } }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -85,6 +85,42 @@ describe('canvas masks', () => {
|
||||||
expect(canvas.saveLayer).toHaveBeenCalledTimes(2)
|
expect(canvas.saveLayer).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('combines consecutive mask nodes before clipping following siblings', () => {
|
||||||
|
const graph = new SceneGraph()
|
||||||
|
const frame = graph.createNode('FRAME', pageId(graph), { width: 200, height: 200 })
|
||||||
|
const firstMask = graph.createNode('RECTANGLE', frame.id, {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
isMask: true
|
||||||
|
})
|
||||||
|
const secondMask = graph.createNode('ELLIPSE', frame.id, {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
isMask: true
|
||||||
|
})
|
||||||
|
const clipped = graph.createNode('RECTANGLE', frame.id, { width: 200, height: 200 })
|
||||||
|
const nextMask = graph.createNode('RECTANGLE', frame.id, {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
isMask: true
|
||||||
|
})
|
||||||
|
const nextClipped = graph.createNode('RECTANGLE', frame.id, { width: 100, height: 100 })
|
||||||
|
const { renderer, rendered } = createRenderer()
|
||||||
|
const canvas = createCanvas()
|
||||||
|
|
||||||
|
renderNode(renderer, canvas as Canvas, graph, frame.id, {})
|
||||||
|
|
||||||
|
expect(rendered).toEqual([
|
||||||
|
frame.id,
|
||||||
|
clipped.id,
|
||||||
|
firstMask.id,
|
||||||
|
secondMask.id,
|
||||||
|
nextClipped.id,
|
||||||
|
nextMask.id
|
||||||
|
])
|
||||||
|
expect(canvas.saveLayer).toHaveBeenCalledTimes(4)
|
||||||
|
})
|
||||||
|
|
||||||
test('applies luminance masks through a luma color filter', () => {
|
test('applies luminance masks through a luma color filter', () => {
|
||||||
const graph = new SceneGraph()
|
const graph = new SceneGraph()
|
||||||
const frame = graph.createNode('FRAME', pageId(graph), { width: 200, height: 200 })
|
const frame = graph.createNode('FRAME', pageId(graph), { width: 200, height: 200 })
|
||||||
|
|
|
||||||
22
tests/engine/render/canvas/text-font-variations.test.ts
Normal file
22
tests/engine/render/canvas/text-font-variations.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { textFontVariations } from '#core/canvas/text'
|
||||||
|
|
||||||
|
describe('canvas text font variations', () => {
|
||||||
|
test('passes imported variable font axes to CanvasKit text styles', () => {
|
||||||
|
expect(
|
||||||
|
textFontVariations([
|
||||||
|
{ axis: 'wght', value: 650 },
|
||||||
|
{ axis: 'wdth', value: 88 }
|
||||||
|
])
|
||||||
|
).toEqual([
|
||||||
|
{ axis: 'wght', value: 650 },
|
||||||
|
{ axis: 'wdth', value: 88 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('omits font variations when no axes are set', () => {
|
||||||
|
expect(textFontVariations([])).toBeUndefined()
|
||||||
|
expect(textFontVariations(undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue