From 8e1d5d40e71f086ff3c88e02d73580ed989930bf Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 22 May 2026 20:59:53 +0300 Subject: [PATCH] fix(canvas): render Figma blend modes --- CHANGELOG.md | 1 + packages/core/src/canvas/blend.ts | 44 +++++++ packages/core/src/canvas/scene.ts | 14 +- packages/docs/development/roadmap.md | 2 +- tests/engine/render/canvas/blend.test.ts | 155 +++++++++++++++++++++++ 5 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/canvas/blend.ts create mode 100644 tests/engine/render/canvas/blend.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f5ddbd8c7..0631198b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Fix accidental duplicate creation when Alt-clicking without dragging. - 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. ### Performance diff --git a/packages/core/src/canvas/blend.ts b/packages/core/src/canvas/blend.ts new file mode 100644 index 000000000..c72eff7c7 --- /dev/null +++ b/packages/core/src/canvas/blend.ts @@ -0,0 +1,44 @@ +import type { BlendMode as SkBlendMode, CanvasKit } from 'canvaskit-wasm' + +import type { BlendMode } from '#core/scene-graph' + +export function figmaBlendModeToSkia(ck: CanvasKit, mode?: BlendMode): SkBlendMode { + switch (mode) { + case 'DARKEN': + return ck.BlendMode.Darken + case 'MULTIPLY': + return ck.BlendMode.Multiply + case 'COLOR_BURN': + return ck.BlendMode.ColorBurn + case 'LIGHTEN': + return ck.BlendMode.Lighten + case 'SCREEN': + return ck.BlendMode.Screen + case 'COLOR_DODGE': + return ck.BlendMode.ColorDodge + case 'OVERLAY': + return ck.BlendMode.Overlay + case 'SOFT_LIGHT': + return ck.BlendMode.SoftLight + case 'HARD_LIGHT': + return ck.BlendMode.HardLight + case 'DIFFERENCE': + return ck.BlendMode.Difference + case 'EXCLUSION': + return ck.BlendMode.Exclusion + case 'HUE': + return ck.BlendMode.Hue + case 'SATURATION': + return ck.BlendMode.Saturation + case 'COLOR': + return ck.BlendMode.Color + case 'LUMINOSITY': + return ck.BlendMode.Luminosity + default: + return ck.BlendMode.SrcOver + } +} + +export function needsIsolatedBlendLayer(mode?: BlendMode): boolean { + return mode !== undefined && mode !== 'NORMAL' && mode !== 'PASS_THROUGH' +} diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 77d13e87f..e1d5d0e8a 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -5,6 +5,7 @@ import type { SceneNode, SceneGraph, Fill } from '#core/scene-graph' import type { Color } from '#core/types' import { vectorNetworkToCenterlinePath } from '#core/vector' +import { figmaBlendModeToSkia, needsIsolatedBlendLayer } from './blend' import { renderBooleanOperation } from './boolean' import type { SkiaRenderer, RenderOverlays } from './renderer' import { nodeHasRadius } from './shapes' @@ -27,11 +28,12 @@ function drawVisibleFills( if (!fill.visible) continue if (!r.applyFill(fill, node, graph, fi)) continue r.fillPaint.setAlphaf(fill.opacity) + r.fillPaint.setBlendMode(figmaBlendModeToSkia(r.ck, fill.blendMode)) draw(fill) r.fillPaint.setShader(null) + r.fillPaint.setBlendMode(r.ck.BlendMode.SrcOver) } } - function isCulled(r: SkiaRenderer, node: SceneNode, absX: number, absY: number): boolean { const canCull = node.childIds.length === 0 || @@ -75,7 +77,6 @@ function applyNodeTransforms( canvas.scale(node.flipX ? -1 : 1, node.flipY ? -1 : 1) } } - function renderNodeContent( r: SkiaRenderer, canvas: Canvas, @@ -134,7 +135,6 @@ function renderChildren( } } } - export function renderNode( r: SkiaRenderer, canvas: Canvas, @@ -163,8 +163,10 @@ export function renderNode( canvas.save() canvas.translate(node.x, node.y) - if (node.opacity < 1) { + const needsNodeLayer = node.opacity < 1 || needsIsolatedBlendLayer(node.blendMode) + if (needsNodeLayer) { r.opacityPaint.setAlphaf(node.opacity) + r.opacityPaint.setBlendMode(figmaBlendModeToSkia(r.ck, node.blendMode)) canvas.saveLayer(r.opacityPaint) } @@ -192,8 +194,10 @@ export function renderNode( r.effectLayerPaint.setColorFilter(null) r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver) } - if (node.opacity < 1) { + if (needsNodeLayer) { canvas.restore() + r.opacityPaint.setAlphaf(1) + r.opacityPaint.setBlendMode(r.ck.BlendMode.SrcOver) } canvas.restore() } diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index f1166cf1d..2b358791a 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -131,7 +131,7 @@ Figma's design documentation groups features into these areas: | Image fills | ✅ | ✅ | ◐ | ✅ | ✅ | Fill/fit/crop/tile support exists; exact Figma image transform parity is partial. | | Pattern fills/strokes | — | — | — | — | — | Figma pattern fills are not currently modeled. | | Video/GIF/media fills | — | — | — | — | — | No video playback or media layer support. | -| Layer/fill/effect blend modes | ✅ | ◐ | — | ✅ | ✅ | Parsed/exported, SVG export maps some modes, but Canvas rendering does not fully apply node/fill/effect blend modes. | +| Layer/fill/effect blend modes | ✅ | ◐ | — | ✅ | ✅ | Canvas applies node and fill blend modes; effect blend modes and Figma isolation edge cases are still partial. | | Opacity | ✅ | ✅ | ✅ | ✅ | ✅ | Node opacity uses save layers in the renderer. | | Strokes | ✅ | ✅ | ✅ | ✅ | ✅ | Weight, alignment, dashes, and side weights are supported. | | Stroke caps / joins / miter limit | ✅ | ✅ | ◐ | ✅ | ✅ | Renderer/export support exists; inspector controls are limited. | diff --git a/tests/engine/render/canvas/blend.test.ts b/tests/engine/render/canvas/blend.test.ts new file mode 100644 index 000000000..326b059e5 --- /dev/null +++ b/tests/engine/render/canvas/blend.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, mock, test } from 'bun:test' +import type { Canvas } from 'canvaskit-wasm' + +import { figmaBlendModeToSkia } from '#core/canvas/blend' +import type { SkiaRenderer } from '#core/canvas/renderer' +import { renderNode, renderSection } from '#core/canvas/scene' +import { SceneGraph } from '#core/scene-graph' +import type { BlendMode, Fill, SceneNode } from '#core/scene-graph' + +function pageId(graph: SceneGraph) { + return graph.getPages()[0].id +} + +const blendModes = { + SrcOver: 'SrcOver', + Darken: 'Darken', + Multiply: 'Multiply', + ColorBurn: 'ColorBurn', + Lighten: 'Lighten', + Screen: 'Screen', + ColorDodge: 'ColorDodge', + Overlay: 'Overlay', + SoftLight: 'SoftLight', + HardLight: 'HardLight', + Difference: 'Difference', + Exclusion: 'Exclusion', + Hue: 'Hue', + Saturation: 'Saturation', + Color: 'Color', + Luminosity: 'Luminosity' +} + +function createCanvas() { + return { + save: mock(() => undefined), + restore: mock(() => undefined), + translate: mock(() => undefined), + saveLayer: mock(() => undefined), + drawRRect: mock(() => undefined) + } +} + +function createRenderer(overrides: Partial = {}) { + return { + _nodeCount: 0, + _culledCount: 0, + worldViewport: { x: -100, y: -100, w: 1000, h: 1000 }, + ck: { + BlendMode: blendModes, + LTRBRect: mock(() => new Float32Array(4)), + RRectXY: mock(() => new Float32Array(12)) + }, + opacityPaint: { + setAlphaf: mock(() => undefined), + setBlendMode: mock(() => undefined) + }, + effectLayerPaint: { + setImageFilter: mock(() => undefined), + setColorFilter: mock(() => undefined), + setBlendMode: mock(() => undefined) + }, + fillPaint: { + setAlphaf: mock(() => undefined), + setBlendMode: mock(() => undefined), + setShader: mock(() => undefined) + }, + getCachedBlur: mock(() => null), + applyFill: mock(() => true), + makeRRect: mock(() => new Float32Array(12)), + renderShape: mock(() => undefined), + renderSection: mock(() => undefined), + renderComponentSet: mock(() => undefined), + renderNode(canvas, graph, nodeId, overlays, parentAbsX, parentAbsY) { + renderNode(this as SkiaRenderer, canvas, graph, nodeId, overlays, parentAbsX, parentAbsY) + }, + ...overrides + } as SkiaRenderer +} + +function calls(fn: ReturnType): unknown[][] { + return (fn as { mock: { calls: unknown[][] } }).mock.calls +} + +describe('canvas blend modes', () => { + test('maps Figma blend modes to CanvasKit blend modes', () => { + const ck = { BlendMode: blendModes } as SkiaRenderer['ck'] + const cases: Array<[BlendMode, string]> = [ + ['NORMAL', 'SrcOver'], + ['PASS_THROUGH', 'SrcOver'], + ['DARKEN', 'Darken'], + ['MULTIPLY', 'Multiply'], + ['COLOR_BURN', 'ColorBurn'], + ['LIGHTEN', 'Lighten'], + ['SCREEN', 'Screen'], + ['COLOR_DODGE', 'ColorDodge'], + ['OVERLAY', 'Overlay'], + ['SOFT_LIGHT', 'SoftLight'], + ['HARD_LIGHT', 'HardLight'], + ['DIFFERENCE', 'Difference'], + ['EXCLUSION', 'Exclusion'], + ['HUE', 'Hue'], + ['SATURATION', 'Saturation'], + ['COLOR', 'Color'], + ['LUMINOSITY', 'Luminosity'] + ] + + for (const [figmaMode, skiaMode] of cases) { + expect(figmaBlendModeToSkia(ck, figmaMode)).toBe(skiaMode) + } + }) + + test('isolates node content when node blend mode needs compositing', () => { + const graph = new SceneGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 100, + blendMode: 'MULTIPLY' + }) + const renderer = createRenderer() + const canvas = createCanvas() + + renderNode(renderer, canvas as Canvas, graph, node.id, {}) + + expect(renderer.opacityPaint.setAlphaf).toHaveBeenCalledWith(1) + expect(renderer.opacityPaint.setBlendMode).toHaveBeenCalledWith('Multiply') + expect(canvas.saveLayer).toHaveBeenCalledWith(renderer.opacityPaint) + expect(renderer.renderShape).toHaveBeenCalled() + expect(renderer.opacityPaint.setBlendMode).toHaveBeenLastCalledWith('SrcOver') + }) + + test('applies fill blend modes per fill and resets shared paint', () => { + const graph = new SceneGraph() + const fill: Fill = { + type: 'SOLID', + color: { r: 1, g: 0, b: 0, a: 1 }, + opacity: 1, + visible: true, + blendMode: 'SCREEN' + } + const node = graph.createNode('SECTION', pageId(graph), { + width: 100, + height: 100, + fills: [fill] + }) as Extract + const renderer = createRenderer() + const canvas = createCanvas() + + renderSection(renderer, canvas as Canvas, node, graph) + + expect(calls(renderer.fillPaint.setBlendMode as ReturnType)).toEqual([ + ['Screen'], + ['SrcOver'] + ]) + }) +})