fix(core): render gradient text fills

- Clip gradient paints through the shaped paragraph mask instead of reading only the paint color
- Cover the paragraph mask path and a headless red-to-blue text render

Fixes #246
This commit is contained in:
Danila Poyarkov 2026-05-06 00:13:00 +03:00
parent 68d15440b1
commit 7f3eb532d8
10 changed files with 214 additions and 28 deletions

View file

@ -35,6 +35,7 @@
- Improve layout inspector dropdown anchoring and icon clarity for spacing and padding controls.
- Fix bound color variable inspector swatches to display the resolved variable color and detach the binding when edited directly.
- Fix dashed strokes on vector nodes rendering as solid lines — dash pattern now uses `PathEffect.MakeDash` directly instead of outline conversion, and closed crescent shapes (e.g. annular wedges) render a single dashed centerline arc instead of two parallel arcs.
- Fix gradient fills on text nodes by clipping gradient paints through the shaped paragraph mask.
### Performance

View file

@ -484,19 +484,22 @@ function createExactCoreBarrelImportRule({ description, applies, message }) {
const noMcpCoreBarrelImports = createExactCoreBarrelImportRule({
description: 'Disallow MCP imports from @open-pencil/core root barrel — use domain subpaths',
applies: (file) => file.includes('/packages/mcp/src/'),
message: 'Use a targeted @open-pencil/core subpath in MCP code instead of the compatibility barrel.'
message:
'Use a targeted @open-pencil/core subpath in MCP code instead of the compatibility barrel.'
})
const noCliCoreBarrelImports = createExactCoreBarrelImportRule({
description: 'Disallow CLI imports from @open-pencil/core root barrel — use domain subpaths',
applies: (file) => file.includes('/packages/cli/src/'),
message: 'Use a targeted @open-pencil/core subpath in CLI code instead of the compatibility barrel.'
message:
'Use a targeted @open-pencil/core subpath in CLI code instead of the compatibility barrel.'
})
const noScriptCoreBarrelImports = createExactCoreBarrelImportRule({
description: 'Disallow script imports from @open-pencil/core root barrel — use domain subpaths',
applies: (file) => file.includes('/scripts/'),
message: 'Use a targeted @open-pencil/core subpath or #core/* alias in scripts instead of the compatibility barrel.'
message:
'Use a targeted @open-pencil/core subpath or #core/* alias in scripts instead of the compatibility barrel.'
})
const noCoreSelfPackageImports = {
@ -570,8 +573,7 @@ const noAppVueCoreBarrelImports = createExactCoreBarrelImportRule({
description:
'Disallow app and Vue SDK imports from @open-pencil/core root barrel — use domain subpaths',
applies: (file) =>
(file.includes('/src/') && !file.includes('/packages/')) ||
file.includes('/packages/vue/src/'),
(file.includes('/src/') && !file.includes('/packages/')) || file.includes('/packages/vue/src/'),
message:
'Use a targeted @open-pencil/core subpath (editor, scene-graph, constants, io, etc.) instead of the compatibility barrel.'
})
@ -708,7 +710,10 @@ const noLegacyTestAppImports = {
function reportLegacyTestImport(node) {
const source = importSource(node)
if (!source || !/^(?:\.\.\/)+src\/(?:ai|automation|composables|stores|utils|engine)\//.test(source)) {
if (
!source ||
!/^(?:\.\.\/)+src\/(?:ai|automation|composables|stores|utils|engine)\//.test(source)
) {
return
}
context.report({
@ -779,6 +784,28 @@ const noBroadDoubleCast = {
}
}
const noUnknownRecordDoubleCast = {
meta: {
docs: {
description: 'Disallow `as unknown as Record<string, unknown>` broad object casts'
}
},
create(context) {
return {
TSAsExpression(node) {
if (!isUnknownTypeAnnotation(node.expression?.typeAnnotation)) return
const targetType = context.sourceCode.getText(node.typeAnnotation).replace(/\s+/g, '')
if (targetType !== 'Record<string,unknown>') return
context.report({
node,
message:
'Avoid `as unknown as Record<string, unknown>`; use a precise type or direct public API.'
})
}
}
}
}
const noCoreBrowserGlobals = {
meta: {
docs: {
@ -1006,7 +1033,11 @@ const vueComponentFilePascalCase = {
return {
Program(node) {
const basename = file.split('/').at(-1)?.replace(/\.vue$/, '') ?? ''
const basename =
file
.split('/')
.at(-1)
?.replace(/\.vue$/, '') ?? ''
if (isPascalCaseName(basename)) return
context.report({
node,
@ -1138,7 +1169,8 @@ const noComponentRootSiblingFolder = {
const noUselessPassThroughWrappers = {
meta: {
docs: {
description: 'Disallow functions that only return another function call with the same arguments'
description:
'Disallow functions that only return another function call with the same arguments'
}
},
create(context) {
@ -1192,7 +1224,11 @@ const noUselessPassThroughWrappers = {
VariableDeclarator(node) {
if (node.id?.type !== 'Identifier') return
const init = node.init
if (!init || (init.type !== 'ArrowFunctionExpression' && init.type !== 'FunctionExpression')) return
if (
!init ||
(init.type !== 'ArrowFunctionExpression' && init.type !== 'FunctionExpression')
)
return
check(node, node.id.name, init.params, init.body)
}
}
@ -1282,6 +1318,7 @@ const plugin = {
'no-legacy-test-app-imports': noLegacyTestAppImports,
'no-test-core-source-imports': noTestCoreSourceImports,
'no-broad-double-cast': noBroadDoubleCast,
'no-unknown-record-double-cast': noUnknownRecordDoubleCast,
'no-core-browser-globals': noCoreBrowserGlobals,
'no-direct-graph-emitter-subscriptions': noDirectGraphEmitterSubscriptions,
'no-on-unmounted-in-composition-roots': noOnUnmountedInCompositionRoots,

View file

@ -37,6 +37,7 @@
"open-pencil/no-legacy-test-app-imports": "error",
"open-pencil/no-test-core-source-imports": "error",
"open-pencil/no-broad-double-cast": "error",
"open-pencil/no-unknown-record-double-cast": "error",
"open-pencil/no-core-browser-globals": "error",
"open-pencil/no-direct-graph-emitter-subscriptions": "error",
"open-pencil/no-on-unmounted-in-composition-roots": "error",

View file

@ -7,7 +7,8 @@ export function drawNodeFill(
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean
hasRadius: boolean,
fill?: Fill
): void {
switch (node.type) {
case 'VECTOR': {
@ -30,7 +31,7 @@ export function drawNodeFill(
}
break
case 'TEXT':
r.renderText(canvas, node)
r.renderText(canvas, node, fill)
break
case 'LINE':
canvas.drawLine(0, 0, node.width, node.height, r.fillPaint)

View file

@ -201,12 +201,13 @@ export class SkiaRenderer {
pass: 'behind' | 'front',
shadowShapeChild?: SceneNode | null
) => void
declare renderText: (canvas: Canvas, node: SceneNode) => void
declare renderText: (canvas: Canvas, node: SceneNode, fill?: Fill) => void
declare drawNodeFill: (
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean
hasRadius: boolean,
fill?: Fill
) => void
declare applyFill: (fill: Fill, node: SceneNode, graph: SceneGraph, fillIndex?: number) => boolean
declare applyGradientFill: (fill: Fill, node: SceneNode, graph: SceneGraph) => void

View file

@ -167,12 +167,18 @@ const rendererMethods: ThisType<SkiaRenderer> = {
renderShadowEffects(this, canvas, node, rect, hasRadius, pass, shadowShapeChild)
},
renderText(canvas: Canvas, node: SceneNode): void {
SceneRender.renderText(this, canvas, node)
renderText(canvas: Canvas, node: SceneNode, fill?: Fill): void {
SceneRender.renderText(this, canvas, node, fill)
},
drawNodeFill(canvas: Canvas, node: SceneNode, rect: Float32Array, hasRadius: boolean): void {
Fills.drawNodeFill(this, canvas, node, rect, hasRadius)
drawNodeFill(
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean,
fill?: Fill
): void {
Fills.drawNodeFill(this, canvas, node, rect, hasRadius, fill)
},
applyFill(fill: Fill, node: SceneNode, graph: SceneGraph, fillIndex = 0): boolean {

View file

@ -3,7 +3,7 @@ import { vectorNetworkToCenterlinePath } from '#core/vector'
import { nodeHasRadius } from './effects'
import type { SceneNode, SceneGraph } from '#core/scene-graph'
import type { SceneNode, SceneGraph, Fill } from '#core/scene-graph'
import type { Color } from '#core/types'
import type { SkiaRenderer, RenderOverlays } from './renderer'
import type { Canvas, EmbindEnumEntity, Path } from 'canvaskit-wasm'
@ -12,14 +12,14 @@ function drawVisibleFills(
r: SkiaRenderer,
node: SceneNode,
graph: SceneGraph,
draw: () => void
draw: (fill: Fill) => void
): void {
for (let fi = 0; fi < node.fills.length; fi++) {
const fill = node.fills[fi]
if (!fill.visible) continue
if (!r.applyFill(fill, node, graph, fi)) continue
r.fillPaint.setAlphaf(fill.opacity)
draw()
draw(fill)
r.fillPaint.setShader(null)
}
}
@ -477,7 +477,7 @@ export function renderShapeUncached(
const shadowChild = getShadowShapeChild(node, graph)
r.renderEffects(canvas, node, rect, hasRadius, 'behind', shadowChild)
drawVisibleFills(r, node, graph, () => r.drawNodeFill(canvas, node, rect, hasRadius))
drawVisibleFills(r, node, graph, (fill) => r.drawNodeFill(canvas, node, rect, hasRadius, fill))
const sg = node.strokeGeometry.length > 0 ? r.getStrokeGeometry(node) : null
const vectorPaths = node.type === 'VECTOR' ? r.getVectorPaths(node) : null
@ -503,7 +503,38 @@ export function renderShapeUncached(
r.renderEffects(canvas, node, rect, hasRadius, 'front', shadowChild)
}
export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): void {
function isGradientFill(fill?: Fill): boolean {
return fill?.type.startsWith('GRADIENT') === true
}
function drawGradientText(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
paragraphY: number
): boolean {
if (!r.fontsLoaded || !r.fontProvider) return false
const paragraph = r.buildParagraph(node, r.ck.Color4f(0, 0, 0, 1))
r.effectLayerPaint.setImageFilter(null)
r.effectLayerPaint.setColorFilter(null)
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver)
canvas.saveLayer(r.effectLayerPaint)
canvas.drawParagraph(paragraph, 0, paragraphY)
paragraph.delete()
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcIn)
canvas.saveLayer(r.effectLayerPaint)
canvas.drawRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.fillPaint)
canvas.restore()
canvas.restore()
r.effectLayerPaint.setImageFilter(null)
r.effectLayerPaint.setColorFilter(null)
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver)
return true
}
export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fill?: Fill): void {
const text = node.text
if (!text) return
@ -513,6 +544,12 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): vo
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, false)
}
const paragraphY = -1
if (isGradientFill(fill) && drawGradientText(r, canvas, node, paragraphY)) {
canvas.restore()
return
}
if (node.textPicture) {
const pic = r.ck.MakePicture(node.textPicture)
if (pic) {
@ -524,7 +561,6 @@ export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): vo
}
if (r.fontsLoaded && r.fontProvider) {
const paragraph = r.buildParagraph(node, r.fillPaint.getColor())
const paragraphY = -1
canvas.drawParagraph(paragraph, 0, paragraphY)
paragraph.delete()
} else if (r.textFont) {

View file

@ -93,7 +93,7 @@ function captureNodeSnapshot(
if (!targetId) return undefined
const raw = figma.graph.getNode(targetId)
if (!raw) return undefined
return structuredClone(raw) as unknown as Record<string, unknown>
return Object.fromEntries(Object.entries(structuredClone(raw)))
}
function emitToolLog(

View file

@ -104,7 +104,7 @@ function getAttrs(wrapped: XPathNode): XPathAttr[] {
for (const attrName of QUERYABLE_ATTRS) {
if (attrName in node) {
const value = (node as unknown as Record<string, unknown>)[attrName]
const value = Reflect.get(node, attrName)
if (value === undefined || value === null || typeof value === 'symbol') continue
const stringValue =
typeof value === 'object'
@ -168,7 +168,7 @@ function createDomFacade(graph: SceneGraph) {
if (isDocument(node)) return null
const sceneNode = node._sceneNode
if (attributeName in sceneNode) {
const value = (sceneNode as unknown as Record<string, unknown>)[attributeName]
const value = Reflect.get(sceneNode, attributeName)
if (value === undefined || value === null || typeof value === 'symbol') return null
return typeof value === 'object'
? JSON.stringify(value)

View file

@ -15,7 +15,9 @@ function createMockCanvas() {
drawParagraph: mock(() => {}),
drawPicture: mock(() => {}),
drawText: mock(() => {}),
drawRect: mock(() => {}),
save: mock(() => {}),
saveLayer: mock(() => {}),
restore: mock(() => {}),
clipRect: mock(() => {})
}
@ -36,9 +38,16 @@ function createMockRenderer(overrides: Partial<Record<string, unknown>> = {}) {
fontProvider: {},
textFont: {},
fillPaint: { getColor: () => new Float32Array([0, 0, 0, 1]) },
effectLayerPaint: {
setBlendMode: mock(() => {}),
setColorFilter: mock(() => {}),
setImageFilter: mock(() => {})
},
ck: {
MakePicture: mock(() => createMockPicture()),
LTRBRect: mock((...args: number[]) => args),
Color4f: mock((...args: number[]) => new Float32Array(args)),
BlendMode: { SrcOver: 0, SrcIn: 1 },
ClipOp: { Intersect: 0 }
},
DEFAULT_FONT_SIZE: 14,
@ -82,6 +91,26 @@ describe('renderText', () => {
expect(canvas.drawText).not.toHaveBeenCalled()
})
test('renders gradient text through a paragraph mask', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
renderText(r, canvas as never, textNode(), {
type: 'GRADIENT_LINEAR',
visible: true,
opacity: 1,
gradientStops: [],
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
})
expect(r.buildParagraph).toHaveBeenCalledTimes(1)
expect(canvas.saveLayer).toHaveBeenCalledTimes(2)
expect(canvas.drawParagraph).toHaveBeenCalledTimes(1)
expect(canvas.drawRect).toHaveBeenCalledTimes(1)
expect(r.effectLayerPaint.setBlendMode).toHaveBeenCalledWith(r.ck.BlendMode.SrcIn)
expect(r._paragraph.delete).toHaveBeenCalledTimes(1)
})
test('prefers textPicture over paragraph', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
@ -156,7 +185,7 @@ describe('renderText headless visual', () => {
renderer.viewportHeight = 50
renderer.dpr = 1
renderer.fontsLoaded = true
;(renderer as unknown as Record<string, unknown>).fontProvider = fontProvider
renderer.fontProvider = fontProvider
const canvas = surface.getCanvas()
canvas.clear(ck.WHITE)
@ -191,6 +220,80 @@ describe('renderText headless visual', () => {
expect(darkPixels).toBeGreaterThan(500)
})
test('renders linear gradient text through the canvas scene fill path', async () => {
const ck = await initCanvasKit()
const fontProvider = ck.TypefaceFontProvider.Make()
fontManager.attachProvider(ck, fontProvider)
const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
fontProvider.registerFont(interData, 'Inter')
fontManager.markLoaded('Inter', 'Regular', interData)
const graph = new SceneGraph()
const page = graph.getPages()[0]
const node = graph.createNode('TEXT', page.id, {
text: 'OPEN',
fontFamily: 'Inter',
fontSize: 64,
fontWeight: 400,
width: 220,
height: 80,
fills: [
{
type: 'GRADIENT_LINEAR',
opacity: 1,
visible: true,
gradientStops: [
{ position: 0, color: { r: 1, g: 0, b: 0, a: 1 } },
{ position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }
],
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
}
]
})
const surface = ck.MakeSurface(220, 80)!
const renderer = new SkiaRendererClass(ck, surface)
renderer.viewportWidth = 220
renderer.viewportHeight = 80
renderer.dpr = 1
renderer.fontsLoaded = true
renderer.fontProvider = fontProvider
const canvas = surface.getCanvas()
canvas.clear(ck.WHITE)
renderer.renderShape(canvas, graph.getNode(node.id)!, graph)
surface.flush()
const image = surface.makeImageSnapshot()
const pixels = image.readPixels(0, 0, {
width: 220,
height: 80,
colorType: ck.ColorType.RGBA_8888,
alphaType: ck.AlphaType.Unpremul,
colorSpace: ck.ColorSpace.SRGB
})!
image.delete()
surface.delete()
let redTextPixels = 0
let blueTextPixels = 0
for (let y = 0; y < 80; y++) {
for (let x = 0; x < 220; x++) {
const i = (y * 220 + x) * 4
const r = pixels[i]
const g = pixels[i + 1]
const b = pixels[i + 2]
if (g > 220) continue
if (x < 110 && r > b + 40) redTextPixels++
if (x >= 110 && b > r + 40) blueTextPixels++
}
}
expect(redTextPixels).toBeGreaterThan(40)
expect(blueTextPixels).toBeGreaterThan(40)
})
test('renders Arabic text via fallback font through paragraph shaper', async () => {
const ck = await initCanvasKit()
const fontProvider = ck.TypefaceFontProvider.Make()
@ -225,7 +328,7 @@ describe('renderText headless visual', () => {
renderer.viewportHeight = 60
renderer.dpr = 1
renderer.fontsLoaded = true
;(renderer as unknown as Record<string, unknown>).fontProvider = fontProvider
renderer.fontProvider = fontProvider
const canvas = surface.getCanvas()
canvas.clear(ck.WHITE)