diff --git a/CHANGELOG.md b/CHANGELOG.md index 497828582..afcbabd7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/core/src/canvas/masks.ts b/packages/core/src/canvas/masks.ts index 25edf84a0..582eaa7da 100644 --- a/packages/core/src/canvas/masks.ts +++ b/packages/core/src/canvas/masks.ts @@ -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() diff --git a/packages/core/src/canvas/text.ts b/packages/core/src/canvas/text.ts index ddbb21ad4..10bfc2a72 100644 --- a/packages/core/src/canvas/text.ts +++ b/packages/core/src/canvas/text.ts @@ -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, diff --git a/packages/core/src/kiwi/fig/codec/index.ts b/packages/core/src/kiwi/fig/codec/index.ts index 0b3fa19cf..74a28a315 100644 --- a/packages/core/src/kiwi/fig/codec/index.ts +++ b/packages/core/src/kiwi/fig/codec/index.ts @@ -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 diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index e5361f1e0..01f2e8224 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -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 = { 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)) + blob instanceof Uint8Array + ? blob + : new Uint8Array(Object.values(blob as Record)) } 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 } } diff --git a/packages/core/src/kiwi/fig/node-change/export-node.ts b/packages/core/src/kiwi/fig/node-change/export-node.ts index 25b9f902f..6082adbe6 100644 --- a/packages/core/src/kiwi/fig/node-change/export-node.ts +++ b/packages/core/src/kiwi/fig/node-change/export-node.ts @@ -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 = { 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) diff --git a/packages/core/src/kiwi/fig/node-change/font-variations.ts b/packages/core/src/kiwi/fig/node-change/font-variations.ts new file mode 100644 index 000000000..35901948e --- /dev/null +++ b/packages/core/src/kiwi/fig/node-change/font-variations.ts @@ -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 +} diff --git a/packages/core/src/kiwi/fig/node-change/serialize.ts b/packages/core/src/kiwi/fig/node-change/serialize.ts index d1b274941..9d974f6b0 100644 --- a/packages/core/src/kiwi/fig/node-change/serialize.ts +++ b/packages/core/src/kiwi/fig/node-change/serialize.ts @@ -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 } diff --git a/packages/core/src/kiwi/fig/node-change/style-runs.ts b/packages/core/src/kiwi/fig/node-change/style-runs.ts index 7e0ed6b50..18aa6b5e2 100644 --- a/packages/core/src/kiwi/fig/node-change/style-runs.ts +++ b/packages/core/src/kiwi/fig/node-change/style-runs.ts @@ -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, diff --git a/packages/core/src/scene-graph/node-defaults.ts b/packages/core/src/scene-graph/node-defaults.ts index 1db4f3628..e5e02ed0a 100644 --- a/packages/core/src/scene-graph/node-defaults.ts +++ b/packages/core/src/scene-graph/node-defaults.ts @@ -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', diff --git a/packages/core/src/scene-graph/types.ts b/packages/core/src/scene-graph/types.ts index 0a235e90f..66dd7b159 100644 --- a/packages/core/src/scene-graph/types.ts +++ b/packages/core/src/scene-graph/types.ts @@ -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 diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index b3f7fbfd3..0e45fc73d 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -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. diff --git a/tests/e2e/canvas/renderer-visuals.spec.ts b/tests/e2e/canvas/renderer-visuals.spec.ts index ffc679ccb..8421270a9 100644 --- a/tests/e2e/canvas/renderer-visuals.spec.ts +++ b/tests/e2e/canvas/renderer-visuals.spec.ts @@ -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() }) diff --git a/tests/e2e/canvas/renderer-visuals.spec.ts-snapshots/luminance-masks-and-transformed-tile-fills-openpencil-darwin.png b/tests/e2e/canvas/renderer-visuals.spec.ts-snapshots/luminance-masks-and-transformed-tile-fills-openpencil-darwin.png index a65ab2e42..c2396bc9c 100644 Binary files a/tests/e2e/canvas/renderer-visuals.spec.ts-snapshots/luminance-masks-and-transformed-tile-fills-openpencil-darwin.png and b/tests/e2e/canvas/renderer-visuals.spec.ts-snapshots/luminance-masks-and-transformed-tile-fills-openpencil-darwin.png differ diff --git a/tests/engine/io/fig/export/boolean-operation.test.ts b/tests/engine/io/fig/export/boolean-operation.test.ts new file mode 100644 index 000000000..090d01b95 --- /dev/null +++ b/tests/engine/io/fig/export/boolean-operation.test.ts @@ -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') + }) +}) diff --git a/tests/engine/io/fig/export/font-variations.test.ts b/tests/engine/io/fig/export/font-variations.test.ts new file mode 100644 index 000000000..04f554397 --- /dev/null +++ b/tests/engine/io/fig/export/font-variations.test.ts @@ -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 } + ]) + }) +}) diff --git a/tests/engine/io/fig/import/boolean-operation.test.ts b/tests/engine/io/fig/import/boolean-operation.test.ts new file mode 100644 index 000000000..13fe28393 --- /dev/null +++ b/tests/engine/io/fig/import/boolean-operation.test.ts @@ -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') + }) +}) diff --git a/tests/engine/io/fig/import/font-variations.test.ts b/tests/engine/io/fig/import/font-variations.test.ts new file mode 100644 index 000000000..704091053 --- /dev/null +++ b/tests/engine/io/fig/import/font-variations.test.ts @@ -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 }] } } + ]) + }) +}) diff --git a/tests/engine/render/canvas/masks.test.ts b/tests/engine/render/canvas/masks.test.ts index 6446813f7..040e57516 100644 --- a/tests/engine/render/canvas/masks.test.ts +++ b/tests/engine/render/canvas/masks.test.ts @@ -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 }) diff --git a/tests/engine/render/canvas/text-font-variations.test.ts b/tests/engine/render/canvas/text-font-variations.test.ts new file mode 100644 index 000000000..df3ce67c3 --- /dev/null +++ b/tests/engine/render/canvas/text-font-variations.test.ts @@ -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() + }) +})