fix(canvas): improve Figma mask and text fidelity

This commit is contained in:
Danila Poyarkov 2026-05-23 00:18:33 +03:00
parent 5b12b1597f
commit 8c85d2b953
20 changed files with 449 additions and 63 deletions

View file

@ -14,9 +14,11 @@
- Fix MCP startup in the browser.
- 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 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.
- 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

View file

@ -20,18 +20,32 @@ export function renderMaskedChildIds(
): void {
for (let index = 0; index < childIds.length; index++) {
const childId = childIds[index]
const maskType = getVisibleMaskType(childId)
if (!maskType) {
const firstMaskType = getVisibleMaskType(childId)
if (!firstMaskType) {
renderChild(childId)
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
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 {
resetMaskPaint(r)
canvas.save()
@ -41,9 +55,18 @@ export function renderMaskedChildIds(
resetMaskPaint(r)
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.DstIn)
if (lumaFilter) r.effectLayerPaint.setColorFilter(lumaFilter)
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()

View file

@ -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 { 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 {
switch (decoration) {
case 'UNDERLINE':
@ -214,6 +227,7 @@ function addStyledRuns(
weight: { value: s.fontWeight ?? node.fontWeight } as FontWeight,
slant: (s.italic ?? node.italic) ? ck.FontSlant.Italic : ck.FontSlant.Upright
},
fontVariations: textFontVariations(s.fontVariations ?? node.fontVariations),
letterSpacing: s.letterSpacing ?? (node.letterSpacing || 0),
decoration: textDecorationValue(ck, s.textDecoration ?? node.textDecoration),
heightMultiplier: runLineHeight ? runLineHeight / runFontSize : undefined,
@ -269,6 +283,7 @@ export function buildParagraph(
weight: { value: node.fontWeight } as FontWeight,
slant: node.italic ? ck.FontSlant.Italic : ck.FontSlant.Upright
},
fontVariations: textFontVariations(node.fontVariations),
letterSpacing: node.letterSpacing || 0,
decoration: textDecorationValue(ck, node.textDecoration),
heightMultiplier: node.lineHeight ? node.lineHeight / baseFontSize : undefined,

View file

@ -136,7 +136,9 @@ export function encodeMessage(message: FigmaMessage): Uint8Array {
const ncHex = Buffer.from(ncBytes).toString('hex')
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)
}
@ -306,6 +308,7 @@ export interface NodeChange {
frameMaskDisabled?: boolean
resizeToFit?: boolean
// Vector
booleanOperation?: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE'
vectorData?: unknown
fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
@ -362,6 +365,7 @@ export interface NodeChange {
textBidiVersion?: number
textDecoration?: string
textDecorationSkipInk?: boolean
fontVariations?: Array<{ axisTag?: number; axisName?: string; value?: number }>
fontVariantCommonLigatures?: boolean
fontVariantContextualLigatures?: boolean
fontVersion?: string

View file

@ -8,6 +8,7 @@ import { convertEffects, convertFills, convertStrokes } from './paint'
import { importStyleRuns } from './style-runs'
export { importStyleRuns } from './style-runs'
import { convertFigmaDerivedTextGlyphs } from './derived-text-glyphs'
import { convertFontVariations } from './font-variations'
import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
export { convertEffects, convertFills, convertStrokes, setVariableColorResolver } from './paint'
export { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
@ -89,7 +90,7 @@ const NODE_TYPE_MAP: Record<string, NodeType | 'DOCUMENT' | 'VARIABLE'> = {
STAR: 'STAR',
REGULAR_POLYGON: 'POLYGON',
VECTOR: 'VECTOR',
BOOLEAN_OPERATION: 'VECTOR',
BOOLEAN_OPERATION: 'BOOLEAN_OPERATION',
GROUP: 'GROUP',
SECTION: 'SECTION',
COMPONENT: 'COMPONENT',
@ -105,6 +106,18 @@ function mapNodeType(type?: string): NodeType | 'DOCUMENT' | 'VARIABLE' {
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 {
switch (mode) {
case 'HORIZONTAL':
@ -280,6 +293,7 @@ function convertTextProps(
| 'letterSpacing'
| 'maxLines'
| 'styleRuns'
| 'fontVariations'
| 'textTruncation'
| 'textDirection'
| 'figmaDerivedLayout'
@ -304,6 +318,7 @@ function convertTextProps(
letterSpacing: convertLetterSpacing(nc.letterSpacing, nc.fontSize),
maxLines: (nc.maxLines ?? null) as number | null,
styleRuns: importStyleRuns(nc),
fontVariations: convertFontVariations(nc),
textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED',
textDirection:
(getOpenPencilPluginValue(nc, TEXT_DIRECTION_PLUGIN_KEY) as
@ -466,6 +481,7 @@ export function nodeChangeToProps(
visible: nc.visible ?? true,
locked: nc.locked ?? false,
blendMode: (nc.blendMode as Fill['blendMode']) ?? 'PASS_THROUGH',
booleanOperation: mapBooleanOperation(nc),
fills: convertFills(nc.fillPaints),
strokes: convertStrokes(
nc.strokePaints,
@ -707,7 +723,9 @@ function preserveFigmaPayloadBlobs(value: unknown, blobs: Uint8Array[]): unknown
} else {
result[key] = {
__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
}
} else {
@ -798,10 +816,15 @@ function extractFigmaSymbolMetadata(
| undefined
return {
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[],
derivedSymbolDataLayoutVersion:
typeof nc.derivedSymbolDataLayoutVersion === 'number' ? nc.derivedSymbolDataLayoutVersion : null,
typeof nc.derivedSymbolDataLayoutVersion === 'number'
? nc.derivedSymbolDataLayoutVersion
: null,
uniformScaleFactor: typeof sd?.uniformScaleFactor === 'number' ? sd.uniformScaleFactor : null
}
}

View file

@ -102,7 +102,10 @@ function parseGuidOrNull(value: string) {
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 SUPPORTED_VARIABLE_DATA_TYPES = new Set([
@ -118,14 +121,21 @@ const SUPPORTED_VARIABLE_DATA_TYPES = new Set([
function isSupportedVariableMapEntry(value: unknown): boolean {
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
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 {
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
}
@ -143,8 +153,9 @@ function materializeSafeVariableMap(
function paintVariableKey(value: unknown): string | null {
if (!value || typeof value !== 'object') return null
const assetRef = (value as { value?: { alias?: { assetRef?: { key?: unknown; version?: unknown } } } })
.value?.alias?.assetRef
const assetRef = (
value as { value?: { alias?: { assetRef?: { key?: unknown; version?: unknown } } } }
).value?.alias?.assetRef
return typeof assetRef?.key === 'string'
? `${assetRef.key}:${typeof assetRef.version === 'string' ? assetRef.version : ''}`
: null
@ -192,7 +203,8 @@ function materializeFigmaPayload(
options: MaterializeFigmaPayloadOptions = {}
): unknown {
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 ('__openPencilFigmaBlob' in value) {
return materializeFigmaBlob(
@ -326,11 +338,15 @@ function applyInstancePayload(
if (symbolID) {
const symbolData: Record<string, unknown> = { symbolID }
if (node.source.fig.symbolOverrides.length > 0) {
symbolData.symbolOverrides = materializeFigmaPayload(node.source.fig.symbolOverrides, context.blobs, {
blobIndexByHex: context.blobIndexByHex,
includeVariableMaps: true,
paintVariableColorMap: context.paintVariableColorMap
})
symbolData.symbolOverrides = materializeFigmaPayload(
node.source.fig.symbolOverrides,
context.blobs,
{
blobIndexByHex: context.blobIndexByHex,
includeVariableMaps: true,
paintVariableColorMap: context.paintVariableColorMap
}
)
}
if (node.source.fig.uniformScaleFactor != null) {
symbolData.uniformScaleFactor = node.source.fig.uniformScaleFactor
@ -349,11 +365,15 @@ function applyInstancePayload(
)
}
if (node.source.fig.derivedSymbolData.length > 0) {
nc.derivedSymbolData = materializeFigmaPayload(node.source.fig.derivedSymbolData, context.blobs, {
blobIndexByHex: context.blobIndexByHex,
includeVariableMaps: true,
paintVariableColorMap: context.paintVariableColorMap
})
nc.derivedSymbolData = materializeFigmaPayload(
node.source.fig.derivedSymbolData,
context.blobs,
{
blobIndexByHex: context.blobIndexByHex,
includeVariableMaps: true,
paintVariableColorMap: context.paintVariableColorMap
}
)
}
if (node.source.fig.derivedSymbolDataLayoutVersion != null) {
nc.derivedSymbolDataLayoutVersion = node.source.fig.derivedSymbolDataLayoutVersion
@ -400,15 +420,22 @@ function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void {
}
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 {
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 {
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 {
@ -527,6 +554,7 @@ export function sceneNodeToKiwiWithContext(
applyInstancePayload(context, node, nc, localIdCounter)
if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type)
if (nc.type === 'CANVAS') nc.pageType = 'DESIGN'
if (node.type === 'BOOLEAN_OPERATION') nc.booleanOperation = node.booleanOperation ?? 'UNION'
if (strokePaints.length > 0) nc.strokePaints = strokePaints
context.serializeLayoutProps(node, nc)

View 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
}

View file

@ -46,6 +46,8 @@ export function mapToFigmaType(type: SceneNode['type']): string {
return 'REGULAR_POLYGON'
case 'VECTOR':
return 'VECTOR'
case 'BOOLEAN_OPERATION':
return 'BOOLEAN_OPERATION'
case 'GROUP':
return 'FRAME'
case 'SECTION':
@ -137,12 +139,13 @@ function buildDerivedTextData(
: glyphAdvance,
rotation: 0
}))
: (getGlyphOutlineMetricsSync(
node.fontFamily,
weightToStyle(node.fontWeight, node.italic),
node.text,
node.fontSize
) ?? []
: (
getGlyphOutlineMetricsSync(
node.fontFamily,
weightToStyle(node.fontWeight, node.italic),
node.text,
node.fontSize
) ?? []
).map((glyph, index) => ({
commandsBlob: appendGlyphBlob(
blobs,
@ -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'] {
const runs = node.styleRuns
if (runs.length === 0) {
@ -206,6 +213,9 @@ function exportTextData(node: SceneNode): NodeChange['textData'] {
postscript: ''
}
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) {
override.letterSpacing = { value: style.letterSpacing, units: 'PIXELS' }
}
@ -300,6 +310,9 @@ function serializeTextProps(
postscript: ''
}
nc.textData = exportTextData(node)
if (node.fontVariations.length > 0) {
nc.fontVariations = node.fontVariations.map(fontVariationToKiwi)
}
const autoResize = resolveTextAutoResize(node, graph)
nc.textAutoResize = autoResize
nc.textAlignHorizontal = node.textAlignHorizontal
@ -322,16 +335,14 @@ function serializeTextProps(
}
}
function normalizeStackMode(
value: string | undefined
): KiwiNodeChange['stackMode'] {
function normalizeStackMode(value: string | undefined): KiwiNodeChange['stackMode'] {
return value === 'HORIZONTAL' || value === 'VERTICAL' || value === 'NONE' ? value : undefined
}
function normalizeStackSizing(
value: string | undefined
): KiwiNodeChange['stackPrimarySizing'] {
return value === 'FIXED' || value === 'RESIZE_TO_FIT' || value === 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE'
function normalizeStackSizing(value: string | undefined): KiwiNodeChange['stackPrimarySizing'] {
return value === 'FIXED' ||
value === 'RESIZE_TO_FIT' ||
value === 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE'
? value
: undefined
}

View file

@ -2,6 +2,7 @@ import type { NodeChange } from '#core/kiwi/fig/codec'
import type { CharacterStyleOverride, StyleRun } from '#core/scene-graph'
import { styleToWeight } from '#core/text/fonts'
import { convertFontVariations } from './font-variations'
import { convertFills } from './paint'
import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
@ -16,6 +17,8 @@ function convertStyleOverride(
style.italic = override.fontName.style.toLowerCase().includes('italic')
}
if (override.fontSize !== undefined) style.fontSize = override.fontSize
const fontVariations = convertFontVariations(override)
if (fontVariations.length > 0) style.fontVariations = fontVariations
if (override.letterSpacing) {
style.letterSpacing = convertLetterSpacing(
override.letterSpacing,

View file

@ -36,9 +36,7 @@ export function createDefaultNode(
},
figmaDerivedLayout: null,
fills:
type === 'TEXT'
? [{ type: 'SOLID' as const, color: BLACK, opacity: 1, visible: true }]
: [],
type === 'TEXT' ? [{ type: 'SOLID' as const, color: BLACK, opacity: 1, visible: true }] : [],
strokes: [],
effects: [],
opacity: 1,
@ -88,6 +86,7 @@ export function createDefaultNode(
textDecoration: 'NONE',
maxLines: null,
styleRuns: [],
fontVariations: [],
horizontalConstraint: 'MIN',
verticalConstraint: 'MIN',
strokeCap: 'NONE',

View file

@ -173,6 +173,11 @@ export type TextDecoration = 'NONE' | 'UNDERLINE' | 'STRIKETHROUGH'
export type TextDirection = 'AUTO' | 'LTR' | 'RTL'
export type LayoutDirection = 'AUTO' | 'LTR' | 'RTL'
export interface FontVariation {
axis: string
value: number
}
export interface CharacterStyleOverride {
fontWeight?: number
italic?: boolean
@ -182,6 +187,7 @@ export interface CharacterStyleOverride {
letterSpacing?: number
lineHeight?: number | null
fills?: Fill[]
fontVariations?: FontVariation[]
}
export interface StyleRun {
@ -326,6 +332,7 @@ export interface SceneNode {
maxLines: number | null
styleRuns: StyleRun[]
fontVariations: FontVariation[]
horizontalConstraint: ConstraintType
verticalConstraint: ConstraintType

View file

@ -20,8 +20,8 @@ OpenPencil is moving toward production-grade Figma compatibility while keeping d
- Preserve and round-trip more Figma metadata safely.
- 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.
- Improve boolean operation import so Figma `BOOLEAN_OPERATION` nodes remain editable where possible.
- Close high-impact renderer gaps: remaining mask edge cases, blend isolation, pattern fills, and broader variable-font fixtures.
- Improve boolean operation editing/export now that imported Figma `BOOLEAN_OPERATION` nodes remain boolean operations.
### Editor depth
@ -117,7 +117,7 @@ Figma's design documentation groups features into these areas:
| Polygons / stars | ✅ | ✅ | ◐ | ✅ | ✅ | `pointCount` and `starInnerRadius` modeled. |
| Text | ✅ | ✅ | ◐ | ✅ | ✅ | Derived Figma glyphs improve fidelity; advanced typography is partial. |
| 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. |
| 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. |
@ -139,7 +139,7 @@ Figma's design documentation groups features into these areas:
| Effect styles | ↩ | — | — | ↩ | — | Style IDs round-trip; no style manager. |
| 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. |
| 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: wrap | ✅ | ✅ | ✅ | ✅ | ✅ | UI toggle exists. |
| 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. |
| Vertical text alignment | ✅ | ◐ | — | ✅ | ✅ | Modeled; UI/render parity needs more coverage. |
| 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 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. |
@ -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. |
| 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. |
| `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. |
## Highest-priority visual gaps
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.
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.
5. **Boolean operation import** — keep Figma `BOOLEAN_OPERATION` nodes as boolean operations where possible instead of importing them as vectors.
4. **Variable-font fixtures** — broaden real-file coverage for variable axes and OpenType feature metadata.
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.
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.

View file

@ -104,7 +104,9 @@ test('gradients and image fill modes', async () => {
width: 700,
height: 250,
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 = [
@ -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.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()) {
store.graph.createNode('RECTANGLE', pageId, {
name: `${type} visual`,
@ -121,7 +128,9 @@ test('gradients and image fill modes', async () => {
width: 92,
height: 72,
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,
height: 130,
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, {
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.requestRender()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View 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')
})
})

View 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 }
])
})
})

View 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')
})
})

View 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 }] } }
])
})
})

View file

@ -85,6 +85,42 @@ describe('canvas masks', () => {
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', () => {
const graph = new SceneGraph()
const frame = graph.createNode('FRAME', pageId(graph), { width: 200, height: 200 })

View 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()
})
})