- 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
572 lines
17 KiB
TypeScript
572 lines
17 KiB
TypeScript
import { DROP_HIGHLIGHT_ALPHA, DROP_HIGHLIGHT_STROKE, SECTION_CORNER_RADIUS } from '#core/constants'
|
|
import { vectorNetworkToCenterlinePath } from '#core/vector'
|
|
|
|
import { nodeHasRadius } from './effects'
|
|
|
|
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'
|
|
|
|
function drawVisibleFills(
|
|
r: SkiaRenderer,
|
|
node: SceneNode,
|
|
graph: SceneGraph,
|
|
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(fill)
|
|
r.fillPaint.setShader(null)
|
|
}
|
|
}
|
|
|
|
function isCulled(r: SkiaRenderer, node: SceneNode, absX: number, absY: number): boolean {
|
|
const canCull =
|
|
node.childIds.length === 0 ||
|
|
((node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE') &&
|
|
node.clipsContent)
|
|
if (!canCull) return false
|
|
|
|
const vp = r.worldViewport
|
|
const bw = node.width
|
|
const bh = node.height
|
|
if (node.rotation !== 0) {
|
|
const diag = Math.sqrt(bw * bw + bh * bh)
|
|
const cx = absX + bw / 2
|
|
const cy = absY + bh / 2
|
|
return (
|
|
cx - diag / 2 > vp.x + vp.w ||
|
|
cy - diag / 2 > vp.y + vp.h ||
|
|
cx + diag / 2 < vp.x ||
|
|
cy + diag / 2 < vp.y
|
|
)
|
|
}
|
|
return absX > vp.x + vp.w || absY > vp.y + vp.h || absX + bw < vp.x || absY + bh < vp.y
|
|
}
|
|
|
|
function applyNodeTransforms(
|
|
_r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
node: SceneNode,
|
|
nodeId: string,
|
|
overlays: RenderOverlays
|
|
): void {
|
|
const rotation =
|
|
overlays.rotationPreview?.nodeId === nodeId ? overlays.rotationPreview.angle : node.rotation
|
|
if (rotation !== 0) {
|
|
canvas.rotate(rotation, node.width / 2, node.height / 2)
|
|
}
|
|
|
|
if (node.flipX || node.flipY) {
|
|
canvas.translate(node.flipX ? node.width : 0, node.flipY ? node.height : 0)
|
|
canvas.scale(node.flipX ? -1 : 1, node.flipY ? -1 : 1)
|
|
}
|
|
}
|
|
|
|
function renderNodeContent(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
graph: SceneGraph,
|
|
node: SceneNode,
|
|
nodeId: string,
|
|
overlays: RenderOverlays
|
|
): void {
|
|
if (node.type === 'SECTION') {
|
|
r.renderSection(canvas, node, graph)
|
|
} else if (node.type === 'COMPONENT_SET') {
|
|
r.renderComponentSet(canvas, node, graph)
|
|
} else {
|
|
r.renderShape(canvas, node, graph)
|
|
}
|
|
|
|
if (overlays.editingTextId === nodeId && overlays.textEditor?.state?.paragraph) {
|
|
r.drawTextEditOverlay(canvas, node, overlays.textEditor)
|
|
}
|
|
|
|
if (overlays.dropTargetId === nodeId) {
|
|
r.auxStroke.setStrokeWidth(DROP_HIGHLIGHT_STROKE / r.zoom)
|
|
r.auxStroke.setColor(r.selColor(DROP_HIGHLIGHT_ALPHA))
|
|
canvas.drawRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.auxStroke)
|
|
}
|
|
}
|
|
|
|
function renderChildren(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
graph: SceneGraph,
|
|
node: SceneNode,
|
|
overlays: RenderOverlays
|
|
): void {
|
|
const isClippableContainer =
|
|
node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE'
|
|
if (isClippableContainer && node.clipsContent && node.childIds.length > 0) {
|
|
canvas.save()
|
|
if (nodeHasRadius(node)) {
|
|
canvas.clipRRect(r.makeRRect(node), r.ck.ClipOp.Intersect, true)
|
|
} else {
|
|
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, true)
|
|
}
|
|
for (const childId of node.childIds) {
|
|
r.renderNode(canvas, graph, childId, overlays)
|
|
}
|
|
canvas.restore()
|
|
} else {
|
|
for (const childId of node.childIds) {
|
|
r.renderNode(canvas, graph, childId, overlays)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function renderNode(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
graph: SceneGraph,
|
|
nodeId: string,
|
|
overlays: RenderOverlays,
|
|
parentAbsX = 0,
|
|
parentAbsY = 0
|
|
): void {
|
|
const node = graph.getNode(nodeId)
|
|
if (!node || !node.visible) return
|
|
|
|
// Hide the node being edited in node-edit mode (overlay draws it live)
|
|
if (overlays.nodeEditState?.nodeId === nodeId) return
|
|
|
|
r._nodeCount++
|
|
|
|
const absX = parentAbsX + node.x
|
|
const absY = parentAbsY + node.y
|
|
|
|
if (isCulled(r, node, absX, absY)) {
|
|
r._culledCount++
|
|
return
|
|
}
|
|
|
|
canvas.save()
|
|
canvas.translate(node.x, node.y)
|
|
|
|
if (node.opacity < 1) {
|
|
r.opacityPaint.setAlphaf(node.opacity)
|
|
canvas.saveLayer(r.opacityPaint)
|
|
}
|
|
|
|
const layerBlur = node.effects.find(
|
|
(e) => e.visible && (e.type === 'LAYER_BLUR' || e.type === 'FOREGROUND_BLUR')
|
|
)
|
|
if (layerBlur) {
|
|
// Entry guard: reset shared paint to known state
|
|
r.effectLayerPaint.setImageFilter(null)
|
|
r.effectLayerPaint.setColorFilter(null)
|
|
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver)
|
|
|
|
r.effectLayerPaint.setImageFilter(r.getCachedBlur(layerBlur.radius / 2))
|
|
canvas.saveLayer(r.effectLayerPaint)
|
|
}
|
|
|
|
applyNodeTransforms(r, canvas, node, nodeId, overlays)
|
|
renderNodeContent(r, canvas, graph, node, nodeId, overlays)
|
|
renderChildren(r, canvas, graph, node, overlays)
|
|
|
|
if (layerBlur) {
|
|
canvas.restore()
|
|
// Exit guard: ensure shared paint is in clean state
|
|
r.effectLayerPaint.setImageFilter(null)
|
|
r.effectLayerPaint.setColorFilter(null)
|
|
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver)
|
|
}
|
|
if (node.opacity < 1) {
|
|
canvas.restore()
|
|
}
|
|
canvas.restore()
|
|
}
|
|
|
|
function makeNodeRRect(r: SkiaRenderer, node: SceneNode, radius: number): Float32Array {
|
|
const rect = r.ck.LTRBRect(0, 0, node.width, node.height)
|
|
return r.ck.RRectXY(rect, radius, radius)
|
|
}
|
|
|
|
function forVisibleStrokes(
|
|
r: SkiaRenderer,
|
|
node: SceneNode,
|
|
graph: SceneGraph,
|
|
draw: (stroke: SceneNode['strokes'][number], color: Color) => void
|
|
): void {
|
|
for (let index = 0; index < node.strokes.length; index++) {
|
|
const stroke = node.strokes[index]
|
|
if (!stroke.visible) continue
|
|
draw(stroke, r.resolveStrokeColor(stroke, index, node, graph))
|
|
}
|
|
}
|
|
|
|
export function renderSection(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
node: SceneNode,
|
|
graph: SceneGraph
|
|
): void {
|
|
const rrect = makeNodeRRect(r, node, SECTION_CORNER_RADIUS)
|
|
|
|
drawVisibleFills(r, node, graph, () => canvas.drawRRect(rrect, r.fillPaint))
|
|
|
|
forVisibleStrokes(r, node, graph, (stroke, color) => {
|
|
r.strokePaint.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a))
|
|
r.strokePaint.setStrokeWidth(stroke.weight)
|
|
r.strokePaint.setAlphaf(stroke.opacity)
|
|
|
|
if (node.independentStrokeWeights) r.drawIndividualSideStrokes(canvas, node, stroke.align)
|
|
else r.drawRRectStrokeWithAlign(canvas, rrect, node, stroke)
|
|
})
|
|
}
|
|
|
|
export function renderComponentSet(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
node: SceneNode,
|
|
graph: SceneGraph
|
|
): void {
|
|
const rrect = makeNodeRRect(r, node, 5)
|
|
|
|
drawVisibleFills(r, node, graph, () => canvas.drawRRect(rrect, r.fillPaint))
|
|
|
|
r.auxStroke.setStrokeWidth(r.COMPONENT_SET_BORDER_WIDTH / r.zoom)
|
|
r.auxStroke.setColor(r.compColor())
|
|
r.auxStroke.setPathEffect(
|
|
r.ck.PathEffect.MakeDash([r.COMPONENT_SET_DASH / r.zoom, r.COMPONENT_SET_DASH_GAP / r.zoom], 0)
|
|
)
|
|
canvas.drawRRect(rrect, r.auxStroke)
|
|
r.auxStroke.setPathEffect(null)
|
|
}
|
|
|
|
export function renderShape(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
node: SceneNode,
|
|
graph: SceneGraph
|
|
): void {
|
|
const hasEffects = node.effects.length > 0 && node.effects.some((e) => e.visible)
|
|
|
|
if (hasEffects) {
|
|
const cached = r.nodePictureCache.get(node.id)
|
|
if (cached) {
|
|
canvas.drawPicture(cached)
|
|
return
|
|
}
|
|
|
|
const margin = r.effectOverflow(node)
|
|
const bounds = r.ck.LTRBRect(-margin, -margin, node.width + margin, node.height + margin)
|
|
const recorder = new r.ck.PictureRecorder()
|
|
const recCanvas = recorder.beginRecording(bounds)
|
|
r.renderShapeUncached(recCanvas, node, graph)
|
|
const picture = recorder.finishRecordingAsPicture()
|
|
recorder.delete()
|
|
r.nodePictureCache.set(node.id, picture)
|
|
canvas.drawPicture(picture)
|
|
} else {
|
|
r.renderShapeUncached(canvas, node, graph)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* When a container has no visible fills, Figma renders drop shadows
|
|
* using the shape of its children rather than its own rectangle.
|
|
* Returns the child to use for shadow shape, or null to use the node itself.
|
|
*/
|
|
function getShadowShapeChild(node: SceneNode, graph: SceneGraph): SceneNode | null {
|
|
if (node.fills.some((f) => f.visible)) return null
|
|
if (node.childIds.length === 0) return null
|
|
const child = graph.getNode(node.childIds[0])
|
|
if (!child?.visible) return null
|
|
return child
|
|
}
|
|
|
|
function getCapEntity(r: SkiaRenderer, cap: string | undefined): EmbindEnumEntity {
|
|
switch (cap) {
|
|
case 'ROUND':
|
|
return r.ck.StrokeCap.Round
|
|
case 'SQUARE':
|
|
return r.ck.StrokeCap.Square
|
|
default:
|
|
return r.ck.StrokeCap.Butt
|
|
}
|
|
}
|
|
|
|
function getJoinEntity(r: SkiaRenderer, join: string | undefined): EmbindEnumEntity {
|
|
switch (join) {
|
|
case 'ROUND':
|
|
return r.ck.StrokeJoin.Round
|
|
case 'BEVEL':
|
|
return r.ck.StrokeJoin.Bevel
|
|
default:
|
|
return r.ck.StrokeJoin.Miter
|
|
}
|
|
}
|
|
|
|
function drawVectorStrokeGeometry(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
sg: Path[],
|
|
sc: Color,
|
|
opacity: number
|
|
): void {
|
|
r.fillPaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
|
|
r.fillPaint.setAlphaf(opacity)
|
|
r.fillPaint.setShader(null)
|
|
for (const p of sg) canvas.drawPath(p, r.fillPaint)
|
|
}
|
|
|
|
function vectorStrokePaths(r: SkiaRenderer, node: SceneNode): Path[] | null {
|
|
if (!node.vectorNetwork) return null
|
|
|
|
const paths: Path[] = []
|
|
for (const segment of node.vectorNetwork.segments) {
|
|
const start = node.vectorNetwork.vertices[segment.start]
|
|
const end = node.vectorNetwork.vertices[segment.end]
|
|
|
|
const path = new r.ck.Path()
|
|
path.moveTo(start.x, start.y)
|
|
const isStraight =
|
|
Math.abs(segment.tangentStart.x) < 0.001 &&
|
|
Math.abs(segment.tangentStart.y) < 0.001 &&
|
|
Math.abs(segment.tangentEnd.x) < 0.001 &&
|
|
Math.abs(segment.tangentEnd.y) < 0.001
|
|
if (isStraight) {
|
|
path.lineTo(end.x, end.y)
|
|
} else {
|
|
path.cubicTo(
|
|
start.x + segment.tangentStart.x,
|
|
start.y + segment.tangentStart.y,
|
|
end.x + segment.tangentEnd.x,
|
|
end.y + segment.tangentEnd.y,
|
|
end.x,
|
|
end.y
|
|
)
|
|
}
|
|
paths.push(path)
|
|
}
|
|
|
|
return paths.length > 0 ? paths : null
|
|
}
|
|
|
|
function drawVectorPathStrokes(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
vectorPaths: Path[],
|
|
stroke: SceneNode['strokes'][0],
|
|
sc: Color
|
|
): void {
|
|
const dash = stroke.dashPattern
|
|
if (dash && dash.length > 0) {
|
|
r.strokePaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
|
|
r.strokePaint.setAlphaf(stroke.opacity)
|
|
r.strokePaint.setStrokeWidth(stroke.weight)
|
|
r.strokePaint.setStrokeCap(getCapEntity(r, stroke.cap ?? 'NONE'))
|
|
r.strokePaint.setStrokeJoin(getJoinEntity(r, stroke.join ?? 'MITER'))
|
|
r.strokePaint.setShader(null)
|
|
const effect = r.ck.PathEffect.MakeDash(dash, 0)
|
|
r.strokePaint.setPathEffect(effect)
|
|
for (const vp of vectorPaths) canvas.drawPath(vp, r.strokePaint)
|
|
r.strokePaint.setPathEffect(null)
|
|
effect.delete()
|
|
return
|
|
}
|
|
const strokeOpts = {
|
|
width: stroke.weight,
|
|
miter_limit: 4,
|
|
cap: getCapEntity(r, stroke.cap ?? 'NONE'),
|
|
join: getJoinEntity(r, stroke.join ?? 'MITER')
|
|
}
|
|
r.fillPaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
|
|
r.fillPaint.setAlphaf(stroke.opacity)
|
|
r.fillPaint.setShader(null)
|
|
for (const vp of vectorPaths) {
|
|
const outline = vp.copy().stroke(strokeOpts)
|
|
if (outline) {
|
|
canvas.drawPath(outline, r.fillPaint)
|
|
outline.delete()
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawRegularStroke(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
node: SceneNode,
|
|
rect: Float32Array,
|
|
hasRadius: boolean,
|
|
stroke: SceneNode['strokes'][0],
|
|
sc: Color
|
|
): void {
|
|
r.strokePaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
|
|
r.strokePaint.setStrokeWidth(stroke.weight)
|
|
r.strokePaint.setAlphaf(stroke.opacity)
|
|
|
|
if (stroke.cap) {
|
|
r.strokePaint.setStrokeCap(getCapEntity(r, stroke.cap))
|
|
}
|
|
if (stroke.join) {
|
|
r.strokePaint.setStrokeJoin(getJoinEntity(r, stroke.join))
|
|
}
|
|
if (stroke.dashPattern && stroke.dashPattern.length > 0) {
|
|
r.strokePaint.setPathEffect(r.ck.PathEffect.MakeDash(stroke.dashPattern, 0))
|
|
} else {
|
|
r.strokePaint.setPathEffect(null)
|
|
}
|
|
|
|
if (node.independentStrokeWeights && r.isRectangularType(node.type)) {
|
|
r.drawIndividualSideStrokes(canvas, node, stroke.align)
|
|
} else {
|
|
r.drawStrokeWithAlign(canvas, node, rect, hasRadius, stroke.align)
|
|
}
|
|
}
|
|
|
|
function drawNodeStroke(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
node: SceneNode,
|
|
rect: Float32Array,
|
|
hasRadius: boolean,
|
|
stroke: SceneNode['strokes'][0],
|
|
sc: Color,
|
|
sg: Path[] | null,
|
|
vectorPaths: Path[] | null,
|
|
vectorStroke: Path[] | null
|
|
): void {
|
|
if (vectorStroke && stroke.align === 'CENTER' && node.cornerRadius === 0) {
|
|
drawVectorPathStrokes(r, canvas, vectorStroke, stroke, sc)
|
|
return
|
|
}
|
|
if (!sg) {
|
|
if (vectorPaths) drawVectorPathStrokes(r, canvas, vectorPaths, stroke, sc)
|
|
else drawRegularStroke(r, canvas, node, rect, hasRadius, stroke, sc)
|
|
return
|
|
}
|
|
if (stroke.align !== 'INSIDE') {
|
|
drawVectorStrokeGeometry(r, canvas, sg, sc, stroke.opacity)
|
|
return
|
|
}
|
|
|
|
const clipPaths = node.type === 'VECTOR' ? r.getFillGeometry(node) : null
|
|
if (node.type === 'VECTOR' && !clipPaths) {
|
|
drawVectorStrokeGeometry(r, canvas, sg, sc, stroke.opacity)
|
|
return
|
|
}
|
|
|
|
canvas.save()
|
|
if (clipPaths) {
|
|
for (const path of clipPaths) canvas.clipPath(path, r.ck.ClipOp.Intersect, true)
|
|
} else {
|
|
r.clipNodeShape(canvas, node, rect, hasRadius)
|
|
}
|
|
drawVectorStrokeGeometry(r, canvas, sg, sc, stroke.opacity)
|
|
canvas.restore()
|
|
}
|
|
|
|
export function renderShapeUncached(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
node: SceneNode,
|
|
graph: SceneGraph
|
|
): void {
|
|
const rect = r.ck.LTRBRect(0, 0, node.width, node.height)
|
|
const hasRadius = nodeHasRadius(node)
|
|
|
|
const shadowChild = getShadowShapeChild(node, graph)
|
|
r.renderEffects(canvas, node, rect, hasRadius, 'behind', shadowChild)
|
|
|
|
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
|
|
const vectorStroke = node.type === 'VECTOR' ? vectorStrokePaths(r, node) : null
|
|
forVisibleStrokes(r, node, graph, (stroke, color) => {
|
|
if (
|
|
stroke.dashPattern &&
|
|
stroke.dashPattern.length > 0 &&
|
|
node.type === 'VECTOR' &&
|
|
node.vectorNetwork
|
|
) {
|
|
const centerline = vectorNetworkToCenterlinePath(r.ck, node.vectorNetwork)
|
|
drawVectorPathStrokes(r, canvas, [centerline], stroke, color)
|
|
centerline.delete()
|
|
return
|
|
}
|
|
drawNodeStroke(r, canvas, node, rect, hasRadius, stroke, color, sg, vectorPaths, vectorStroke)
|
|
})
|
|
if (vectorStroke) {
|
|
for (const path of vectorStroke) path.delete()
|
|
}
|
|
|
|
r.renderEffects(canvas, node, rect, hasRadius, 'front', shadowChild)
|
|
}
|
|
|
|
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
|
|
|
|
canvas.save()
|
|
const shouldClipText = node.textAutoResize === 'NONE' || node.textAutoResize === 'TRUNCATE'
|
|
if (shouldClipText) {
|
|
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) {
|
|
canvas.drawPicture(pic)
|
|
pic.delete()
|
|
canvas.restore()
|
|
return
|
|
}
|
|
}
|
|
if (r.fontsLoaded && r.fontProvider) {
|
|
const paragraph = r.buildParagraph(node, r.fillPaint.getColor())
|
|
canvas.drawParagraph(paragraph, 0, paragraphY)
|
|
paragraph.delete()
|
|
} else if (r.textFont) {
|
|
canvas.drawText(text, 0, node.fontSize || r.DEFAULT_FONT_SIZE, r.fillPaint, r.textFont)
|
|
}
|
|
|
|
canvas.restore()
|
|
}
|