Figma's kiwi format uses FOREGROUND_BLUR for what the Plugin API calls LAYER_BLUR — a blur that applies to the entire layer including children. Previously FOREGROUND_BLUR was handled in the per-effect 'front' pass via applyClippedBlur, which only blurred the node's own fills (drawn before children). For nodes with no fills but with children (like groups), the blur had no visible effect. Now FOREGROUND_BLUR is handled alongside LAYER_BLUR in renderNode, using a saveLayer that wraps both the node content and its children. This fixes gradient blobs that should appear soft/blurred (e.g. the decorative gradients in gold-preview.fig's Preview Thumbnail).
123 lines
4.7 KiB
TypeScript
123 lines
4.7 KiB
TypeScript
import { describe, test, expect } from 'bun:test'
|
|
import { readdir } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
|
|
const rendererDir = new URL('../../packages/core/src/renderer/', import.meta.url).pathname
|
|
const files = await readdir(rendererDir)
|
|
const parts = await Promise.all(
|
|
files.filter((f) => f.endsWith('.ts')).map((f) => Bun.file(join(rendererDir, f)).text())
|
|
)
|
|
const rendererSource = parts.join('\n')
|
|
|
|
describe('Renderer effect ordering', () => {
|
|
test('drop shadow renders before fills', () => {
|
|
const behindIdx = rendererSource.indexOf("renderEffects(canvas, node, rect, hasRadius, 'behind')")
|
|
const fillsIdx = rendererSource.indexOf('drawNodeFill(canvas, node, rect, hasRadius)')
|
|
expect(behindIdx).toBeGreaterThan(-1)
|
|
expect(fillsIdx).toBeGreaterThan(-1)
|
|
expect(behindIdx).toBeLessThan(fillsIdx)
|
|
})
|
|
|
|
test('inner shadow and blur render after strokes', () => {
|
|
const strokeIdx = rendererSource.indexOf('drawStrokeWithAlign(canvas, node, rect, hasRadius, stroke.align)')
|
|
const frontIdx = rendererSource.indexOf("renderEffects(canvas, node, rect, hasRadius, 'front')")
|
|
expect(strokeIdx).toBeGreaterThan(-1)
|
|
expect(frontIdx).toBeGreaterThan(-1)
|
|
expect(strokeIdx).toBeLessThan(frontIdx)
|
|
})
|
|
})
|
|
|
|
describe('Renderer handles all effect types', () => {
|
|
test('handles DROP_SHADOW', () => {
|
|
expect(rendererSource).toContain("effect.type === 'DROP_SHADOW'")
|
|
})
|
|
|
|
test('handles INNER_SHADOW', () => {
|
|
expect(rendererSource).toContain("effect.type === 'INNER_SHADOW'")
|
|
})
|
|
|
|
test('handles LAYER_BLUR', () => {
|
|
expect(rendererSource).toContain("e.type === 'LAYER_BLUR'")
|
|
})
|
|
|
|
test('handles BACKGROUND_BLUR', () => {
|
|
expect(rendererSource).toContain("effect.type === 'BACKGROUND_BLUR'")
|
|
})
|
|
|
|
test('handles FOREGROUND_BLUR as layer blur', () => {
|
|
expect(rendererSource).toContain("e.type === 'FOREGROUND_BLUR'")
|
|
})
|
|
})
|
|
|
|
describe('Shadow spread support', () => {
|
|
test('drop shadow uses spread for shape expansion', () => {
|
|
const dropShadowSection = rendererSource.slice(
|
|
rendererSource.indexOf("effect.type === 'DROP_SHADOW'"),
|
|
rendererSource.indexOf("effect.type === 'DROP_SHADOW'") + 2000
|
|
)
|
|
expect(dropShadowSection).toContain('effect.spread')
|
|
expect(dropShadowSection).toContain('makeRRectWithSpread')
|
|
expect(dropShadowSection).toContain('getCachedDropShadow')
|
|
})
|
|
|
|
test('inner shadow uses spread for cutout contraction', () => {
|
|
const innerShadowSection = rendererSource.slice(
|
|
rendererSource.indexOf("effect.type === 'INNER_SHADOW'"),
|
|
rendererSource.indexOf("effect.type === 'INNER_SHADOW'") + 3000
|
|
)
|
|
expect(innerShadowSection).toContain('effect.spread')
|
|
})
|
|
|
|
test('makeRRectWithSpread helper exists', () => {
|
|
expect(rendererSource).toContain('function makeRRectWithSpread')
|
|
})
|
|
|
|
test('makeRRectWithOffset helper exists', () => {
|
|
expect(rendererSource).toContain('function makeRRectWithOffset')
|
|
})
|
|
})
|
|
|
|
describe('Text shadow renders on glyphs, not bounding box', () => {
|
|
test('drop shadow has TEXT-specific branch', () => {
|
|
const dropShadowBlock = rendererSource.slice(
|
|
rendererSource.indexOf("effect.type === 'DROP_SHADOW'"),
|
|
rendererSource.indexOf("effect.type === 'DROP_SHADOW'") + 2000
|
|
)
|
|
expect(dropShadowBlock).toContain("node.type === 'TEXT'")
|
|
expect(dropShadowBlock).toContain('renderText')
|
|
expect(dropShadowBlock).toContain('getCachedDropShadow')
|
|
})
|
|
|
|
test('inner shadow has TEXT-specific branch', () => {
|
|
const innerShadowBlock = rendererSource.slice(
|
|
rendererSource.indexOf("effect.type === 'INNER_SHADOW'"),
|
|
rendererSource.indexOf("effect.type === 'INNER_SHADOW'") + 1500
|
|
)
|
|
expect(innerShadowBlock).toContain("node.type === 'TEXT'")
|
|
expect(innerShadowBlock).toContain('renderText')
|
|
})
|
|
})
|
|
|
|
describe('Blur effects use saveLayer pattern', () => {
|
|
test('layer blur wraps node content in a blurred saveLayer', () => {
|
|
expect(rendererSource).toContain("e.type === 'LAYER_BLUR'")
|
|
const layerBlurIdx = rendererSource.indexOf("e.type === 'LAYER_BLUR'")
|
|
const layerBlurSection = rendererSource.slice(layerBlurIdx - 200, layerBlurIdx + 300)
|
|
expect(layerBlurSection).toContain('getCachedBlur')
|
|
expect(layerBlurSection).toContain('saveLayer')
|
|
})
|
|
|
|
test('background blur uses applyClippedBlur', () => {
|
|
expect(rendererSource).toContain("effect.type === 'BACKGROUND_BLUR'")
|
|
expect(rendererSource).toContain('applyClippedBlur')
|
|
})
|
|
|
|
test('applyClippedBlur clips to node shape and uses saveLayer', () => {
|
|
const idx = rendererSource.indexOf('function applyClippedBlur')
|
|
const section = rendererSource.slice(idx, idx + 500)
|
|
expect(section).toContain('clipNodeShape')
|
|
expect(section).toContain('getCachedBlur')
|
|
expect(section).toContain('saveLayer')
|
|
})
|
|
})
|