From 8c85d2b9534eef1e92cb812b1ecb0ab3b7672210 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 23 May 2026 00:18:33 +0300 Subject: [PATCH] fix(canvas): improve Figma mask and text fidelity --- CHANGELOG.md | 4 +- packages/core/src/canvas/masks.ts | 37 +++++++-- packages/core/src/canvas/text.ts | 17 ++++- packages/core/src/kiwi/fig/codec/index.ts | 6 +- .../core/src/kiwi/fig/node-change/convert.ts | 31 +++++++- .../src/kiwi/fig/node-change/export-node.ts | 68 ++++++++++++----- .../kiwi/fig/node-change/font-variations.ts | 23 ++++++ .../src/kiwi/fig/node-change/serialize.ts | 37 +++++---- .../src/kiwi/fig/node-change/style-runs.ts | 3 + .../core/src/scene-graph/node-defaults.ts | 5 +- packages/core/src/scene-graph/types.ts | 7 ++ packages/docs/development/roadmap.md | 18 ++--- tests/e2e/canvas/renderer-visuals.spec.ts | 70 +++++++++++++++++- ...ansformed-tile-fills-openpencil-darwin.png | Bin 25579 -> 33250 bytes .../io/fig/export/boolean-operation.test.ts | 19 +++++ .../io/fig/export/font-variations.test.ts | 30 ++++++++ .../io/fig/import/boolean-operation.test.ts | 33 +++++++++ .../io/fig/import/font-variations.test.ts | 46 ++++++++++++ tests/engine/render/canvas/masks.test.ts | 36 +++++++++ .../canvas/text-font-variations.test.ts | 22 ++++++ 20 files changed, 449 insertions(+), 63 deletions(-) create mode 100644 packages/core/src/kiwi/fig/node-change/font-variations.ts create mode 100644 tests/engine/io/fig/export/boolean-operation.test.ts create mode 100644 tests/engine/io/fig/export/font-variations.test.ts create mode 100644 tests/engine/io/fig/import/boolean-operation.test.ts create mode 100644 tests/engine/io/fig/import/font-variations.test.ts create mode 100644 tests/engine/render/canvas/text-font-variations.test.ts 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 a65ab2e425f3692c4decfe36dbe5b9265b08b291..c2396bc9cfe8f1fb7a273e1412c386bd58a283fd 100644 GIT binary patch literal 33250 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 literal 25579 zcmeHwcT`h(*X}_bu?^^eg3^Xz#)eWvq{ECjprW8aK04GhCT zB%z2jA%xITx^#pPS`r{8^gswn?g{9W_r7a=f84L#wa7mnA!nbR{d@M_&n{U!zGiIr z^`0Yp008*<(!~p=0KmW8`Qxr_;6Iw}F$n;$AGmbkC-Z>RPsH6XY+xZ%EFRAS0>fHA zKiz_hS+NzDNRiww9Iy6c`~HP9mxT81)~LUgwEqo0e&6@AUwyM}(C+-`GnGpNBe!ba zYd_Z(f3APXMaa9|p~<0^^7BZca_Kpbo7ONa?pJm?0&1Q0D?K%=N5PUuTlD!*0m-Xc zo=wkkkFBa$IY*^X_&V`l?)EC17r?b00^*yOzwO_qw0ZTT><)*`tFZICsyDBIi2Y## zo0ofJf$f`DqUV8~n^(sl0{b?vuI<_q2wGyxhMREMg3X23ItVd7v zE48^d2DdJ=TU(be+sG8Lhxl1&-FhY*H-4YkmXq{YMd5MVdmtvVp@E5lk<`@GQg~!>QcrK>C4oeQ z*)ZJNj~LmP)Se%99X51#h$*A)-PUWlv$2j8yd;8P!_q5Ri}rhfhjZg%c8eU+N^^e| zC5WHQsslvASeC`qy37@06#R~=pDEB6s}m$m@~7$~*!q8HUAp(%g(AO2#G0#2`Iw^K z+CUm!AS@GA3GZGmK(xj2255a9tFzyNqLi|CIHVa2&-dmOrHP^Y^yscQb}3A{ssy)~ z&&af&r4y4}RdkksDQpQ z^MFjZE>93I?-iAXQ^WWk(5-JriKQ5e7A(L&hm?*^dj=_ zdpi(}z7{+0<(IA~D5aab9xmug@TJiez0Z03=+0$Ga96jhO(F;aVF+U6*E9t4YCmBb z*)Is{A2#2;@AdhQA=Jsn_!P0K)tq!Uz14^aEj?ltZxL?@+v)Ju^zz=ldvOcSE16}) zQ0nNikwV2NSrNy;*|Va>2yFi33nj{p~;In11bQ-hzKOgVNhjPZRcu_;S&$JP^;#iMHH;# z8GGfX(%4nM%FMDy)2ZLASIDg0S6hEaJLjqH%iyx8sCXGQVsJ~ZrR{+mEukE>5zZK4 z^)@g% zczOD96XwbMW5Jr#D6%>=m`b!~m%u97Wr&ryP|8)W)rj<i7V7ksqXxJGm4HUE*$)rYH{ zy_)h=#HwzS_o8fJGV4(!n)ch%F{K6vk03ie{xCR*g>X{#adugvJr$XI$pdAV{G?-h z(-!Z;@U$||ig`1_T4|Y`|MU+nidT(|tEN7R4F`0e?zf@)cDmQub64UxGX=a*z(Mtt zLMehmC9PS{u4nc0=CFJh`|-n$(fLviwp$DzK;FA(zlse{512ik{=7W+_gf0L%;Kjf zMJDc~VB<}4v(ZirSB>woH6@E~M4Y62f0!cH<;HgFv#8MRh z_j9kaFY@xxIc)SUM|R`&hk3KP4G)U zDBhEvd&F+xag~?FMh`z1ek~$$nkv9I$yXv_P39j<=$9-*M^^g1>?%2pN(;XGt*dP2 zF35Z8W@z$q-(wZ*5Pf&z^DH1Mrf66$>;-y6UXVC#-%|+Tfl#ox4cNldG2%lfDr=i{RANPz$PNqS*xI zD4d!mCH@0!1Ujo&MV1l>bG@NEkQUT_Gr`{92h;#K9t&v49<^#4NR`wk@(rT042wu@ zo6%gV$m!0Z`D4z9ep5koCfspTf#f_}$X^gz^jvH=T(x*t=iFYW{7?fiW5$~`;Vz}{ zhS;Q{b&;dTj#=kfIMlDB%ed|1PFld+z81lu;CGz*CZ0C`iK6H|F-dOJyLo1clG-|R z!n|Lu+OXhRH4zW|RE%24pC0FBS;j6GRz_7~Ydq7J{3jYg11I`%2by?X%g|+*pjXKJ z&tkzWY?+`zV*#%b=S7%c0M*wf^*wdx_!%#3d{_0v5A-U(nFnG*Y3+!y0zS|L#3c}` zReJ1232xvVh>$8K^KSIS#nmNO&-FR`;In|z1y*&6FoB=hemk{oE+A<*V8H=bL}P$H z;{`OfDpr#F%ZTuf(diS%yo6%Hz?i@h2+QzW<)37;91!>H?F6>1jVhET4SKoXB1ByO zL{=G|QzmHAp*Nd~V0Go)ckLC_y;rBRqPrguuLxdk6C4BY1+MKE2=FMTqT2&UMh8E& zsoj$vJ``4XZ$gi>K;llmlkwN%VdNk>F5KI>*6ET{KL&)QiOBO~mP*4X9XNFxuAfT!Ooh31a7^zA|6kZv+Cg zKUL->YutgWoNkq_U^xqj2hH+2qXdr$q=V@>p~^SNVTw!1Ob_tr7y+t_PRc|t3TW^@ zs_0LE^xih8#Lgi<`_+px{@az9hYlSA-!n(Ap-jr;IKpeTfVzC>&>_yn%7%^eNKek7a`OkUH++9tk zojGs|{iPQmrEYrfqi|@(%2Ycj159cDi&Wk(bN5#AZSTvj^`O1>0WtfcegW_OZQnL; zmX(=GI33KuQh>!O+9cHNwT?=WuF{2c{QR##^*iqye4sUd3nX|ElM?oo4y_<`f!w+2 z4oQun>2V_kp?x2yo<1Dcumj_-`9=DRp8$p78kDek-g`2AgiFt|<7rS3240qAW$Kvt zLh5Y!th=z@8)tZsldtcJn)JSX4VIq7(zgRbKuk>VjdnpQCfKH=oGaY8N{@`V^)mzN#Sh(4>NlJkB~59Uh_w_NV!e+#P|V z31x{S&wWo4L7gA9vPRf4;vOb>DMI@i^o8#V12N))uHYkn1GagO9Yw1NmC{0f7zW-V zvW5*1&W^IWl{ili>jBmc*u<6ITql&NZIrVGM5Cd>6D8EzI|PF*wl7*g4817Pt#qed zU+FtPZlhZ4y5mag8i>4sa(5WuP_M4OktgJ0(u26s_g~ z9%z~lNPGV@5n%JSuOg|&U-PU?^y}1u3cR0sf(C|7~MQc$fUinN!d=+)UkXT4S+^UmKUk$U?&D zK6%f#gDIkekt%jbOmL{J?*cmL_*|=dnU+A z67xoLrxgo$j&PKG)mMIYKT|eaR&#lNJLkk7BRh|@;esq!2U29O)^nRMj7U3M*m>fU z!pnEQ=ly!#mTH(w6AjG3U|nuA#Ynp{4me+-R9b;bEu@aM6jW$_&29eq(H)ju&xt?k z)Uray3(iPZU4kB2&x+(4@YI~6!xi-w{C?$~tREvZ6)u=XsnvFe_FngTwjvpO>eH+A z7Z#^H=8hKNrx|FDG>;;7PG{OEZ2>1sc(OV?ZvJ)sM)2`{^768eNI zAO4lF|7tyY{MmKMk?VWH%hYGgyx7=sP=aD5w>O-AHlS$kEU9Cx0ZrDwV^uztNm!p! z)R^hlzcGKvg!h9Z`AFJY^L1>r581me--P2Apoz9+;tfMs&C}8&J~s0O-#yF>l`UA! z;!GACS&v@r?+@Jw*GhRXnj=KXbFZwkEo}tG;W@ zUe*v16lM9cHa;{(f$+Sxx!`c+^~;7FW2^c2?&c)zWwGUMPL<@Rtdx)ZG#57ZO<2<< zsS3Fy_!?o4877Dox64eiK9qu;8EB1AwCL@Uxe{kZG0MQKIi5MUV1dD&RK($yPMaGc z6x`aebs)s=w1aX$Vs{uMc5mn(8a(&Ny*jKf_t1Y&TuEJiv8Q7l<+0n^L*@lV@~M{? z_2?)0X1V$v^?^>m0s%3>ON?^v zFTWXHn^`Uhys3KVXrD~t;F%ywdCIv|ypNM}v_@8M1|#Bt`LfAnWX{lSnM9pcP2V)16ntgbENHt>WpWQ<1Ayl@URb$%UoyZpzwmGWg^&Yje4*fRcNgB6=Uc_@znd;hlh)};O-JH~e~_S5x@~~`Z-y{et`9vWQEvS-J1e@ZxRTta znI*awm#D`qGc>U&tgIa6zHx3QosXteCWE(*U(DyX!~sBk=e9ZVI~KCBlXpUmpr;^^ z+syEryrVhL0&5sfw$xP7D{fN9%uFuJ!0r|=x(ceOsHLcD@)l!ymI?vm&a>g|o2}}t z2K0tG|7&~h0WtSz`<=yq$`OJ}NkFHv6VD&S81&w@XmiOtyx5XiK2_O>IiT-G$)X&C z55ni+wRla752=<&4Piq&=MDXU=x;$Dsu?kyd0xDP`@%HP>28ip>v>NXWrYTOW!He1 zL1#3X{MAYV$_c-O+`$jX+LD>&CpT^$9XbZye43_z`_xxC*Sq)T7#Aj4(JH#fu4xy; zK=iNs{;N-DDXJ=}E3#ieiS27IDo$8z;&$!Wji{o{u?L@qIZxsgU!9kHzZcK!c;S(S zu}W0G_bp?9KAc|74UP7t43y8?ZSEu|_Eg6Roo)Inhx0<&I?# zjeHkYtrcFsu=zpzbiwFQN$E;rOrcIEkx%vQ%0SgR(?^I!LtaCLu%=<1pdk_baOZi< z_;G~_<3gdr1GK9mbTb!9S(*}JItd?ttnX_5Q+C=H58l7SVMFrq`(Sh^O*=D~SGUW} zf+JDhhkqSXTI9v(#0v@+!}{1%C!AOc#I5l_Wt+R6jz3NP!$lR9J2NpeOCr`{cTRVxM~X&fHQ~5rja>)ZbHiksrYJCGV zj(P+aPTSp-h#4S^%q=*#CM|j=ZVtR&pWNZlhxw@)BSi994uHLN>Tnwj(-4l**oHC@w$o@;wRZEilYJ-19nwXW9VZ*Vus=doX4yoF zBhQ8><)_q=E+r32BCVwo@(xM1@tG^wBzg(79*K9qSeeqxjuA3C;@hO_@rv$;_ENwJ_4FMjU3ZU z@b{Fe!1qk|;f>uUErVpeqMUm#L);!#Vk_e-heo>EZq|ugs`yC@`E9g`0gLMg9q2}m zz4>==(YTXwf1OYhSQcy;2lyYDNVa~A7xJnXAMa|Kd7Q^(7FvJQAL(@{GYQ1`fc*!@ zP5M!N@|&=m*fmt{9;Ay$3C8WaMwgeEo*0O_OPe@$eHl992$mKsA99&#;y+=TT!;QS zTQm^g$@n88qN3=jyydH)oNgnIiDZkXGgbm?@af(RzZ+P&NY-h{QaRBS`zFQ71y(7)EO_<%%P~Fn=?$Of_?#EzzT37b(6B40ecjG>_I9$|MiXCK~rorQj$Gm2Od?t zUVX(L$V*dk4JdIIuJMWNdx&P>d}kig>-D_+M)F3)d*>&_N3TulGq*3j0N}?1e7@Rg zObpyHWOkcke8>!!;G%U$Ry8m$M;%f!b8^{KuF*g>+C3K9DqHZfUk$Qi1-0|HTOW4P z`S1uY+6pfLk#ym64Rl!+%$pK2X|qVDpqcd6!Cg7=<5d-`<$JZCE~R9vr3GBp>9wwK z@;qqZ^8xzkM7_Z)G&W+8QYF|uQtZmC$I~sY;PO>5eJY5s( zkkT*`W6`rPgDGjA>A@DWxh-nx!QA)V#~KXed-3ZzN)MdmERa34KUhsvnV?aoEo`KS zbH8!jCKtyt+sw-2kL-*kHYBh5^Cf2+cHOo}lWZ|9h|N+ud62GjoFD-YTRr5$r|!Em9Weku|TMidJUt~}BJ-K$ZotN2#rC`Ge3Sd00w z&@FKh1171@N49xPU2YF=Af19QBe{34V^5yNit3|g?1*$q^dTpe<(a_mI}%%;#OBPP zt31^}GNPqe1&J`oL@3+(*{On&`2FYD1&%=jVn7o8OuJ3d^34l*`+dD4DbVJ)3cIyJ z!T|%5XwDsGLc+|tK6uK(+(YMWxL3q~0`GT+ey(s1d`_Ie)`G?cih%#DlHk?0n8@O# z8{fpOgWUJ354m|QMZQn33k)lL!+(~7uAqncdzD{6T1{Wd5u35g#dKn@n&vrf=aNN` z7TDxRh5e6wM0v^Qdt(ZpAw0-gl&`42z-So!I-lk8fdFsc2qnPr2Oz&ohP+y%-Bv3~b2JL=;OGNZN+QA@pUcQE3R7MP$6M&to=Rp;J| zcw<*yCcNfY1V`drG25{~Y)${%W_7AsM<8s{aKN2kpW21Izo7FRww7zkyVCCYsHnF# ze`X9d6ck4fy;Z0je_k$9l&ySs@>EHacH77;FUBN;v51vIrspaHkoIf-RVjV65Kg$aOouyFLySX&%<7P|d#0A06kL#v$J5Az zv4EPEt=IAQ0~He8_2v@~mEa$HDpdN^ z5{wr#9J z{q$+>W0NG1u!6nR_fPI@Mx&-50cp7$AylB~gqeIl`h_MGtZ(tdO&9|Yb7jP%!scEt zFm&Y3WWBM>8n~Df4IdGn-YP4Y?QQqWVRB&>brW~5`66E zbUCWN8%&oPFUf|sD>8Scr{|C5lTnjIa|rJPNt*59@>l-F><9C&Z*%ii?1!n9yw?>s z?YveDxjuJtYL?rCMejQ@dbvru8q^kD0h-OX7C zw1`Wq_Zi_sLol6zkkMzwoeTnTf$Ya0uKmfstI{| zi!a_kw)oVSF{HzE>hrK@J6WuCrz!J^3e9CjI+ZkkTahq=Z7Tdbai{< z(R85IinDiu2`dq8^;LXwr(p;w`#0M?`(LqMcQdG~t4aL)K{H5$f!sL>$n8lnQ8}o$z-;nfajtbIY47SpbebT=~^o$=^we)F}84Y)hU^ZrNWiQ>GF1=w^KVVLzBdnizMY+`i`_`@!Bw#)mx;Xe;Y9l|nKmzRc#M=GcpY+gTO; zyidC@$%Srz*rAJfAM}T2tJpP-bS+Bl{lQM!L(CZ}gg=>1j_1!t0iY^`+lh$r2s(u> zzOL>3hSAWo7b0)m^tf&8VYixR(xC5H>w8!8Jd2rLXn~rq+HoZOIdb6DjIk@DP((;d zdo>`yyQAhYOQ(?Wa6oCJ4OMuSx*>UEm?=lpJY}m4>q)+O8OnS39@8!#!)ki*#6AKhaSU^l_Y^KlOc7f-Ubo*hb(Ox-! z=2+JQQ)6djXYS0&$=iK&=n&rc*h32x)NuZJ`3Y65-Al2ggk&+BWszS{$5LmwYEECW zv7(~VNIthoF{%sC6lO+t5fODw=coNi%a5;B*hVonR#LokF+irQw1(;#DVAf8!mnCS zE%I{-hYc5d+R;kXu^WDy)e;D|RDsP|4e<93LInZaw=c!~kv>;#G{pkWm5VQRsvq4R z^X*?#xsOB!IHvMF18DzTq`449M0qMIiHeBi0o9Luy!oAmJ>LRip4`rH2lhjeb34P( ziB>*pVJv?&B~gjKjvatO%$EVvTW>)MMQ{k67I+y5h+d92YzK7Tc7l^YN?Xdde@xQC z<+sfZx_a#fOnka}o&nlHuSCS7!F9OPfRd9WKW~I>+f^NrV()W?)F5WNA4nUInU)h+ zy=DzaKlf{&Pw)i{VmF@}=JMNZC`NFogBS=0A6ld)$!hsA$M}B6+o{^ce?Ao1Z5B>< z&j*{Q+6_NT#O)YeHbYil^B@M^`D-%gy?d$_71Vg*3H@09Hco0$du2W3xBHo>H@a!Q ztJ2jdyTJDBr0Q$hyym?4coBiHXU~LNSfd-PSo zXhC0d#>GOTus?3!j?Rrs@Y4JyOiiZh(Am2A=mgx!?bW+{{v2=5E}5y3{Mo2*bOAd< zW*1;&chA86HD~nx$fbC0rjogh!`zhZ{Ku%WX>7t_s-!pPUxVeZ^lIq^xuQ`!2 zaAHr7InZcVw1))ks~?^Az)6T#%<`%%cLD}s+xgpP*Y*hnoU-Ap%QHX0gW*9Fk;{6V zMztLdpE&Fy>+UE;Uhr@63Wh6}-HL zUvu>cWGX12iu&Mj%`{<=`1%)9Ahd6NH4g7t7r6`yxum;`s=v8B69H%|xVU5!7J7$v znjjXA zn_oV2Ur^?VHiu3Mu#Qm%oR9KK5u64?1#YEZewFvfX+p;vHT?XVk%F!spmdKlmm*xt zuVsGw7Ti*SFBR&9)AJJS7wk#P?LbVDOhxc`>eq%s+#unS?Q)|Dr1kE9Y^fy3m)Zq( zx0UTnv*WGFtS6qi^G?+0+++4IuUCUoLzS#B^nd^RYKEujCbYVi}L*$+`P zBx8M#MtZi4v8?)3Ho`Ij&-#jPD<%9cU-RQH0PU*$^0nxWktNp4f^~{kC>c-SNhmi0 zqOv@fP$F4GewLj=WKNBL)s3&-6~qxHtkJ#L5kR%dw=xckj_3GNUKjz=?uu{M;3k^F zt84wdQQk@p0k;7fRmOV!5dyPz+R{T~Ch|?0UTMHs%o=Vdh&=Ao#V`R2Tis&Hc8?8R zk^ybThAzYf(2@-`^45xkNiV&?x>;`Dkg@jsLm>Ajv$nYzqoX7KZ54j!BsJc_X1uA+ zd)qm-`gXj!f>4qSw|#Q*0x`ZaA;+Y6>dyFfN~q=XdhJ=H*$R3(Y2MwdeDe3b+mvdJ zZ_4lzo*W%vqiFW950m#6UzrdK(SD{S0Uuwi;f~sd4mPB(uBH<{4 zzEv9aRjM5=?V$~)c?)DSWKLFwSN=!GnprE^=6E0Q7kN^_^0 zi7N~7M-4g@(*N3>%`2ZAUqrCl^f(I%wtQ;= zjgNuW>-hs837u1}UTedP1(i-(5!qow+6H!C|z4rQK8R=-MuHwOc1hBEYG(H(s)ESUL@VtJF5MQ4>h?df#q~gE5Bjp`6xV66~}! zG!BP-=*+3p7pz#Mw^JS$-}z1{vTA;gX%e#5*fA2&LiJ>I@wWzJ;@`i2EM^kP< z9#kYSK2be(^QG>pmJCM;>nN#dSwno>h!sg+OZ0mUsH^I9dt9v--#*uRv3%K|#xjQa zXT-}yeI%OqtHdLzsc#jk9qr4ZL#20NBp5GTSf@CInokU3e$r9b>i2{VXG-0mE$}-2 z<8At*e|7_Y-<-{Wq2a;*ic)d2zxAKim)rXC86R%{==uB`Lj3P19{fAOSHzYL`DWO% zA>RuBsH3)E0~%y&lCpt||NkQPD9^P+=oVl3oh)n8gglae8MgaN!$3*)%f86b9iPA7 zT>bD%Z!B?tHUHh~h%n)s`^O3|lq>fd6`ESRj*6f9VBm0eA~@ z{~egy!r2ziws6MJ^jlN`0Jazr0BjLI0N5hSTQva~61VE8tW9T2Z^nO{AZxt3XXh0Y)vr!n}Xwyp9b&q zXYbYDB4|OuSqJ#G-=c0?5s8n3tq$T=2a%71tq$T=!NSME)>gq*!2-hI|Ct^Ho^65y z!1f#bzas$3+v){uxfTe6tzN)?f@|^J3H)LrkLOXFmjV7~C*Q}m14SnuOE9FQQox@8 Omwq { + 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() + }) +})