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:
parent
68d15440b1
commit
7f3eb532d8
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue