From 7f3eb532d83eda2f70cecc28057d51860fcf51ee Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 6 May 2026 00:13:00 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + lint/plugin.js | 55 ++++++++-- oxlint.structure.json | 1 + packages/core/src/canvas/fills.ts | 5 +- packages/core/src/canvas/renderer.ts | 5 +- packages/core/src/canvas/renderer/methods.ts | 14 ++- packages/core/src/canvas/scene.ts | 48 +++++++-- packages/core/src/tools/ai-adapter.ts | 2 +- packages/core/src/xpath.ts | 4 +- tests/engine/render-text.test.ts | 107 ++++++++++++++++++- 10 files changed, 214 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6c96838b..2e35d528a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lint/plugin.js b/lint/plugin.js index 302b840ae..6e5c13959 100644 --- a/lint/plugin.js +++ b/lint/plugin.js @@ -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` 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') return + context.report({ + node, + message: + 'Avoid `as unknown as Record`; 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, diff --git a/oxlint.structure.json b/oxlint.structure.json index 1f80605cd..dbc8c398a 100644 --- a/oxlint.structure.json +++ b/oxlint.structure.json @@ -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", diff --git a/packages/core/src/canvas/fills.ts b/packages/core/src/canvas/fills.ts index 0a83f3896..293c6fd80 100644 --- a/packages/core/src/canvas/fills.ts +++ b/packages/core/src/canvas/fills.ts @@ -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) diff --git a/packages/core/src/canvas/renderer.ts b/packages/core/src/canvas/renderer.ts index 60a838093..a494597a9 100644 --- a/packages/core/src/canvas/renderer.ts +++ b/packages/core/src/canvas/renderer.ts @@ -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 diff --git a/packages/core/src/canvas/renderer/methods.ts b/packages/core/src/canvas/renderer/methods.ts index 885a6b7ba..eb173e1ce 100644 --- a/packages/core/src/canvas/renderer/methods.ts +++ b/packages/core/src/canvas/renderer/methods.ts @@ -167,12 +167,18 @@ const rendererMethods: ThisType = { 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 { diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 94c6a89e2..83cef885b 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -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) { diff --git a/packages/core/src/tools/ai-adapter.ts b/packages/core/src/tools/ai-adapter.ts index 0721f0597..72161f965 100644 --- a/packages/core/src/tools/ai-adapter.ts +++ b/packages/core/src/tools/ai-adapter.ts @@ -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 + return Object.fromEntries(Object.entries(structuredClone(raw))) } function emitToolLog( diff --git a/packages/core/src/xpath.ts b/packages/core/src/xpath.ts index a2d5c94fc..8db666bb3 100644 --- a/packages/core/src/xpath.ts +++ b/packages/core/src/xpath.ts @@ -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)[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)[attributeName] + const value = Reflect.get(sceneNode, attributeName) if (value === undefined || value === null || typeof value === 'symbol') return null return typeof value === 'object' ? JSON.stringify(value) diff --git a/tests/engine/render-text.test.ts b/tests/engine/render-text.test.ts index 51acc0cd8..d44cae556 100644 --- a/tests/engine/render-text.test.ts +++ b/tests/engine/render-text.test.ts @@ -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> = {}) { 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).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).fontProvider = fontProvider + renderer.fontProvider = fontProvider const canvas = surface.getCanvas() canvas.clear(ck.WHITE)