@zseven-w/pen-renderer 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,599 @@
1
+ import type { CanvasKit, Canvas, Paint, Font, Typeface } from 'canvaskit-wasm'
2
+ import type { PenNode, ContainerProps, EllipseNode, LineNode, PolygonNode, PathNode, ImageNode, IconFontNode } from '@zseven-w/pen-types'
3
+ import type { PenFill, PenStroke, PenEffect, ShadowEffect, ImageFill } from '@zseven-w/pen-types'
4
+ import { DEFAULT_FILL, DEFAULT_STROKE, DEFAULT_STROKE_WIDTH, buildEllipseArcPath, isArcEllipse } from '@zseven-w/pen-core'
5
+ import { parseColor, cornerRadiusValue, cornerRadii, resolveFillColor, resolveStrokeColor, resolveStrokeWidth } from './paint-utils.js'
6
+ import { sanitizeSvgPath, hasInvalidNumbers, tryManualPathParse } from './path-utils.js'
7
+ import { SkiaImageLoader } from './image-loader.js'
8
+ import { SkiaTextRenderer } from './text-renderer.js'
9
+ import type { SkiaFontManager, FontManagerOptions } from './font-manager.js'
10
+ import type { RenderNode, IconLookupFn } from './types.js'
11
+
12
+ const FALLBACK_ICON_D = 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0'
13
+
14
+ /**
15
+ * Core node renderer for CanvasKit/Skia. Draws PenNode shapes, fills,
16
+ * strokes, effects, text, and images. No editor overlays or store dependencies.
17
+ */
18
+ export class SkiaNodeRenderer {
19
+ protected ck: CanvasKit
20
+ private defaultTypeface: Typeface | null = null
21
+ private defaultFont: Font | null = null
22
+
23
+ // Current viewport zoom (set by engine before each render frame)
24
+ zoom = 1
25
+
26
+ // Device pixel ratio
27
+ devicePixelRatio: number | undefined
28
+
29
+ // Sub-renderers
30
+ private textRenderer: SkiaTextRenderer
31
+ imageLoader: SkiaImageLoader
32
+
33
+ // Injectable icon lookup
34
+ private iconLookup: IconLookupFn | null = null
35
+
36
+ /** Font manager — delegates to text renderer */
37
+ get fontManager(): SkiaFontManager {
38
+ return this.textRenderer.fontManager
39
+ }
40
+
41
+ constructor(ck: CanvasKit, fontOptions?: FontManagerOptions) {
42
+ this.ck = ck
43
+ this.imageLoader = new SkiaImageLoader(ck)
44
+ this.textRenderer = new SkiaTextRenderer(ck, fontOptions)
45
+ }
46
+
47
+ init() {
48
+ this.defaultFont = new this.ck.Font(null, 16)
49
+ }
50
+
51
+ /** Set callback to trigger re-render when async images finish loading. */
52
+ setRedrawCallback(cb: () => void) {
53
+ this.imageLoader.setOnLoaded(cb)
54
+ }
55
+
56
+ /** Set injectable icon lookup function. */
57
+ setIconLookup(fn: IconLookupFn) {
58
+ this.iconLookup = fn
59
+ }
60
+
61
+ dispose() {
62
+ this.defaultFont?.delete()
63
+ this.defaultFont = null
64
+ this.defaultTypeface?.delete()
65
+ this.defaultTypeface = null
66
+ this.textRenderer.dispose()
67
+ this.imageLoader.dispose()
68
+ }
69
+
70
+ clearTextCache() { this.textRenderer.clearTextCache() }
71
+ clearParaCache() { this.textRenderer.clearParaCache() }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Fill paint
75
+ // ---------------------------------------------------------------------------
76
+
77
+ private makeFillPaint(
78
+ fills: PenFill[] | string | undefined,
79
+ w: number, h: number, opacity: number, absX: number, absY: number,
80
+ ): { paint: Paint; imageFillDraw?: { fill: ImageFill; w: number; h: number; absX: number; absY: number; opacity: number } } {
81
+ const ck = this.ck
82
+ const paint = new ck.Paint()
83
+ paint.setStyle(ck.PaintStyle.Fill)
84
+ paint.setAntiAlias(true)
85
+
86
+ if (typeof fills === 'string') {
87
+ const c = parseColor(ck, fills); c[3] *= opacity; paint.setColor(c)
88
+ return { paint }
89
+ }
90
+ if (!fills || fills.length === 0) {
91
+ const c = parseColor(ck, DEFAULT_FILL); c[3] *= opacity; paint.setColor(c)
92
+ return { paint }
93
+ }
94
+
95
+ const first = fills[0]
96
+ if (first.type === 'solid') {
97
+ const c = parseColor(ck, first.color); c[3] *= (first.opacity ?? 1) * opacity; paint.setColor(c)
98
+ } else if (first.type === 'linear_gradient') {
99
+ const stops = first.stops ?? []
100
+ const fillOpacity = (first.opacity ?? 1) * opacity
101
+ if (stops.length >= 2) {
102
+ const rad = ((first.angle ?? 0) - 90) * Math.PI / 180
103
+ const cos = Math.cos(rad), sin = Math.sin(rad)
104
+ const x1 = absX + w / 2 - (cos * w) / 2, y1 = absY + h / 2 - (sin * h) / 2
105
+ const x2 = absX + w / 2 + (cos * w) / 2, y2 = absY + h / 2 + (sin * h) / 2
106
+ const colors = stops.map((s) => { const c = parseColor(ck, s.color); c[3] *= fillOpacity; return c })
107
+ const positions = stops.map((s) => Math.max(0, Math.min(1, s.offset)))
108
+ const shader = ck.Shader.MakeLinearGradient([x1, y1], [x2, y2], colors, positions, ck.TileMode.Clamp)
109
+ if (shader) paint.setShader(shader)
110
+ } else {
111
+ const c = parseColor(ck, stops[0]?.color ?? DEFAULT_FILL); c[3] *= fillOpacity; paint.setColor(c)
112
+ }
113
+ } else if (first.type === 'radial_gradient') {
114
+ const stops = first.stops ?? []
115
+ const fillOpacity = (first.opacity ?? 1) * opacity
116
+ if (stops.length >= 2) {
117
+ const cx = absX + (first.cx ?? 0.5) * w, cy = absY + (first.cy ?? 0.5) * h
118
+ const r = (first.radius ?? 0.5) * Math.max(w, h)
119
+ const colors = stops.map((s) => { const c = parseColor(ck, s.color); c[3] *= fillOpacity; return c })
120
+ const positions = stops.map((s) => Math.max(0, Math.min(1, s.offset)))
121
+ const shader = ck.Shader.MakeRadialGradient([cx, cy], r, colors, positions, ck.TileMode.Clamp)
122
+ if (shader) paint.setShader(shader)
123
+ } else {
124
+ const c = parseColor(ck, stops[0]?.color ?? DEFAULT_FILL); c[3] *= fillOpacity; paint.setColor(c)
125
+ }
126
+ } else if (first.type === 'image') {
127
+ const result = this.applyImageFillToPaint(paint, first, w, h, opacity, absX, absY)
128
+ if (result.needsDrawImageRect && result.fill) {
129
+ return { paint, imageFillDraw: { fill: result.fill, w: result.w!, h: result.h!, absX: result.absX!, absY: result.absY!, opacity: result.opacity! } }
130
+ }
131
+ }
132
+
133
+ return { paint }
134
+ }
135
+
136
+ private applyImageFillToPaint(
137
+ paint: Paint, fill: ImageFill, w: number, h: number,
138
+ opacity: number, absX: number, absY: number,
139
+ ): { needsDrawImageRect: boolean; fill?: ImageFill; w?: number; h?: number; absX?: number; absY?: number; opacity?: number } {
140
+ const ck = this.ck
141
+ const fillOpacity = (fill.opacity ?? 1) * opacity
142
+ const url = fill.url
143
+ if (!url) { const c = parseColor(ck, '#e5e7eb'); c[3] *= fillOpacity; paint.setColor(c); return { needsDrawImageRect: false } }
144
+
145
+ const cached = this.imageLoader.get(url)
146
+ if (cached === undefined) this.imageLoader.request(url)
147
+ if (!cached) { const c = parseColor(ck, '#e5e7eb'); c[3] *= fillOpacity; paint.setColor(c); return { needsDrawImageRect: false } }
148
+
149
+ const imgW = cached.width(), imgH = cached.height()
150
+ if (imgW <= 0 || imgH <= 0) return { needsDrawImageRect: false }
151
+
152
+ const mode = fill.mode ?? 'fill'
153
+ if (mode === 'tile') {
154
+ const dispX = absX + (w - imgW) / 2, dispY = absY + (h - imgH) / 2
155
+ const localMatrix = Float32Array.of(1, 0, -dispX, 0, 1, -dispY, 0, 0, 1)
156
+ const shader = cached.makeShaderOptions(ck.TileMode.Repeat, ck.TileMode.Repeat, ck.FilterMode.Linear, ck.MipmapMode.None, localMatrix)
157
+ if (shader) { paint.setShader(shader); if (fillOpacity < 1) paint.setAlphaf(fillOpacity); const cf = this.buildImageAdjustmentFilter(fill); if (cf) paint.setColorFilter(cf) }
158
+ return { needsDrawImageRect: false }
159
+ }
160
+
161
+ paint.setColor(Float32Array.of(0, 0, 0, 0))
162
+ return { needsDrawImageRect: true, fill, w, h, absX, absY, opacity: fillOpacity }
163
+ }
164
+
165
+ private drawImageFillRect(canvas: Canvas, fill: ImageFill, w: number, h: number, absX: number, absY: number, fillOpacity: number) {
166
+ const ck = this.ck
167
+ const url = fill.url
168
+ if (!url) return
169
+ const cached = this.imageLoader.get(url)
170
+ if (!cached) return
171
+ const imgW = cached.width(), imgH = cached.height()
172
+ if (imgW <= 0 || imgH <= 0) return
173
+
174
+ const mode = fill.mode ?? 'fill'
175
+ const paint = new ck.Paint()
176
+ paint.setAntiAlias(true)
177
+ if (fillOpacity < 1) paint.setAlphaf(fillOpacity)
178
+ const adjFilter = this.buildImageAdjustmentFilter(fill)
179
+ if (adjFilter) paint.setColorFilter(adjFilter)
180
+
181
+ if (mode === 'fit') {
182
+ const scale = Math.min(w / imgW, h / imgH)
183
+ const dw = imgW * scale, dh = imgH * scale
184
+ const dx = absX + (w - dw) / 2, dy = absY + (h - dh) / 2
185
+ canvas.drawImageRect(cached, ck.LTRBRect(0, 0, imgW, imgH), ck.LTRBRect(dx, dy, dx + dw, dy + dh), paint)
186
+ } else if (mode === 'stretch') {
187
+ canvas.drawImageRect(cached, ck.LTRBRect(0, 0, imgW, imgH), ck.LTRBRect(absX, absY, absX + w, absY + h), paint)
188
+ } else {
189
+ const scale = Math.max(w / imgW, h / imgH)
190
+ const dw = imgW * scale, dh = imgH * scale
191
+ const dx = absX + (w - dw) / 2, dy = absY + (h - dh) / 2
192
+ canvas.drawImageRect(cached, ck.LTRBRect(0, 0, imgW, imgH), ck.LTRBRect(dx, dy, dx + dw, dy + dh), paint)
193
+ }
194
+ paint.delete()
195
+ }
196
+
197
+ private buildImageAdjustmentFilter(adj: { exposure?: number; contrast?: number; saturation?: number; temperature?: number; tint?: number; highlights?: number; shadows?: number }) {
198
+ const ck = this.ck
199
+ const exp = (adj.exposure ?? 0) / 100, con = (adj.contrast ?? 0) / 100, sat = (adj.saturation ?? 0) / 100
200
+ const temp = (adj.temperature ?? 0) / 100, tintVal = (adj.tint ?? 0) / 100
201
+ const hi = (adj.highlights ?? 0) / 100, sh = (adj.shadows ?? 0) / 100
202
+ if (exp === 0 && con === 0 && sat === 0 && temp === 0 && tintVal === 0 && hi === 0 && sh === 0) return null
203
+
204
+ const e = 1 + exp * 1.5, c = 1 + con, cOff = 0.5 * (1 - c)
205
+ const s = 1 + sat
206
+ const lr = 0.2126, lg = 0.7152, lb = 0.0722
207
+ const sr = (1 - s) * lr, sg = (1 - s) * lg, sb = (1 - s) * lb
208
+ const f = c * e
209
+ const offR = cOff + temp * 0.15 + (hi + sh * 0.5) * 0.1
210
+ const offG = cOff + tintVal * 0.15 + (hi + sh * 0.5) * 0.1
211
+ const offB = cOff - temp * 0.15 + (hi + sh * 0.5) * 0.1
212
+
213
+ return ck.ColorFilter.MakeMatrix([
214
+ f * (sr + s), f * sg, f * sb, 0, offR,
215
+ f * sr, f * (sg + s), f * sb, 0, offG,
216
+ f * sr, f * sg, f * (sb + s), 0, offB,
217
+ 0, 0, 0, 1, 0,
218
+ ])
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Stroke paint
223
+ // ---------------------------------------------------------------------------
224
+
225
+ private makeStrokePaint(stroke: PenStroke | undefined, opacity: number): Paint | null {
226
+ if (!stroke) return null
227
+ const strokeColor = resolveStrokeColor(stroke)
228
+ const strokeWidth = resolveStrokeWidth(stroke)
229
+ if (!strokeColor || strokeWidth <= 0) return null
230
+
231
+ const ck = this.ck
232
+ const paint = new ck.Paint()
233
+ paint.setStyle(ck.PaintStyle.Stroke)
234
+ paint.setAntiAlias(true)
235
+ paint.setStrokeWidth(strokeWidth)
236
+ const c = parseColor(ck, strokeColor); c[3] *= opacity; paint.setColor(c)
237
+
238
+ if (stroke.join === 'round') paint.setStrokeJoin(ck.StrokeJoin.Round)
239
+ else if (stroke.join === 'bevel') paint.setStrokeJoin(ck.StrokeJoin.Bevel)
240
+ if (stroke.cap === 'round') paint.setStrokeCap(ck.StrokeCap.Round)
241
+ else if (stroke.cap === 'square') paint.setStrokeCap(ck.StrokeCap.Square)
242
+ if (stroke.dashPattern && stroke.dashPattern.length >= 2) {
243
+ const effect = ck.PathEffect.MakeDash(stroke.dashPattern, 0)
244
+ if (effect) paint.setPathEffect(effect)
245
+ }
246
+
247
+ return paint
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Shadow
252
+ // ---------------------------------------------------------------------------
253
+
254
+ private applyShadowDirect(canvas: Canvas, effects: PenEffect[] | undefined, x: number, y: number, w: number, h: number): boolean {
255
+ if (!effects) return false
256
+ const shadow = effects.find((e): e is ShadowEffect => e.type === 'shadow')
257
+ if (!shadow) return false
258
+
259
+ const ck = this.ck
260
+ const paint = new ck.Paint()
261
+ paint.setStyle(ck.PaintStyle.Fill)
262
+ paint.setAntiAlias(true)
263
+ paint.setColor(parseColor(ck, shadow.color))
264
+ paint.setMaskFilter(ck.MaskFilter.MakeBlur(ck.BlurStyle.Normal, shadow.blur / 2, true))
265
+ canvas.drawRect(ck.LTRBRect(
266
+ x + shadow.offsetX - shadow.spread, y + shadow.offsetY - shadow.spread,
267
+ x + w + shadow.offsetX + shadow.spread, y + h + shadow.offsetY + shadow.spread,
268
+ ), paint)
269
+ paint.delete()
270
+ return true
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Draw a single render node (no selection/overlay logic)
275
+ // ---------------------------------------------------------------------------
276
+
277
+ drawNode(canvas: Canvas, rn: RenderNode) {
278
+ const { node, absX, absY, absW, absH, clipRect } = rn
279
+ const ck = this.ck
280
+ const opacity = typeof node.opacity === 'number' ? node.opacity : 1
281
+
282
+ if (('visible' in node ? node.visible : undefined) === false) return
283
+
284
+ // Pass zoom to text renderer
285
+ this.textRenderer.zoom = this.zoom
286
+ this.textRenderer.devicePixelRatio = this.devicePixelRatio
287
+
288
+ // Apply clipping from parent frame
289
+ let clipped = false
290
+ if (clipRect) {
291
+ canvas.save(); clipped = true
292
+ if (clipRect.rx > 0) {
293
+ canvas.clipRRect(ck.RRectXY(ck.LTRBRect(clipRect.x, clipRect.y, clipRect.x + clipRect.w, clipRect.y + clipRect.h), clipRect.rx, clipRect.rx), ck.ClipOp.Intersect, true)
294
+ } else {
295
+ canvas.clipRect(ck.LTRBRect(clipRect.x, clipRect.y, clipRect.x + clipRect.w, clipRect.y + clipRect.h), ck.ClipOp.Intersect, true)
296
+ }
297
+ }
298
+
299
+ // Apply flip
300
+ const flipX = node.flipX === true, flipY = node.flipY === true
301
+ if (flipX || flipY) {
302
+ canvas.save()
303
+ canvas.translate(absX + absW / 2, absY + absH / 2)
304
+ canvas.scale(flipX ? -1 : 1, flipY ? -1 : 1)
305
+ canvas.translate(-(absX + absW / 2), -(absY + absH / 2))
306
+ }
307
+
308
+ // Apply rotation
309
+ const rotation = node.rotation ?? 0
310
+ if (rotation !== 0) {
311
+ canvas.save()
312
+ canvas.rotate(rotation, absX + absW / 2, absY + absH / 2)
313
+ }
314
+
315
+ // Apply shadow (text uses glyph-shaped shadow, not rectangle)
316
+ const effects = 'effects' in node ? (node as PenNode & { effects?: PenEffect[] }).effects : undefined
317
+ if (node.type !== 'text') {
318
+ this.applyShadowDirect(canvas, effects, absX, absY, absW, absH)
319
+ }
320
+
321
+ switch (node.type) {
322
+ case 'frame': case 'rectangle': case 'group':
323
+ this.drawRect(canvas, node, absX, absY, absW, absH, opacity); break
324
+ case 'ellipse':
325
+ this.drawEllipse(canvas, node, absX, absY, absW, absH, opacity); break
326
+ case 'line':
327
+ this.drawLine(canvas, node, absX, absY, opacity); break
328
+ case 'polygon':
329
+ this.drawPolygon(canvas, node, absX, absY, absW, absH, opacity); break
330
+ case 'path':
331
+ this.drawPath(canvas, node, absX, absY, absW, absH, opacity); break
332
+ case 'icon_font':
333
+ this.drawIconFont(canvas, node, absX, absY, absW, absH, opacity); break
334
+ case 'text':
335
+ this.textRenderer.drawText(canvas, node, absX, absY, absW, absH, opacity, effects); break
336
+ case 'image':
337
+ this.drawImage(canvas, node, absX, absY, absW, absH, opacity); break
338
+ }
339
+
340
+ if (rotation !== 0) canvas.restore()
341
+ if (flipX || flipY) canvas.restore()
342
+ if (clipped) canvas.restore()
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // Shape drawing
347
+ // ---------------------------------------------------------------------------
348
+
349
+ private drawRect(canvas: Canvas, node: PenNode, x: number, y: number, w: number, h: number, opacity: number) {
350
+ const ck = this.ck
351
+ const container = node as PenNode & ContainerProps
352
+ const cr = cornerRadii(container.cornerRadius)
353
+ const fills = container.fill
354
+ const stroke = container.stroke
355
+ const hasFill = fills && fills.length > 0
356
+ const isContainer = node.type === 'frame' || node.type === 'group'
357
+
358
+ const { paint: fillPaint, imageFillDraw } = this.makeFillPaint(hasFill ? fills : (isContainer ? 'transparent' : undefined), w, h, opacity, x, y)
359
+ const hasRoundedCorners = cr.some((r) => r > 0)
360
+ if (hasRoundedCorners) {
361
+ const maxR = Math.min(w / 2, h / 2)
362
+ canvas.drawRRect(ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)), fillPaint)
363
+ } else {
364
+ canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fillPaint)
365
+ }
366
+ fillPaint.delete()
367
+
368
+ if (imageFillDraw) {
369
+ canvas.save()
370
+ if (hasRoundedCorners) {
371
+ const maxR = Math.min(w / 2, h / 2)
372
+ canvas.clipRRect(ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)), ck.ClipOp.Intersect, true)
373
+ } else {
374
+ canvas.clipRect(ck.LTRBRect(x, y, x + w, y + h), ck.ClipOp.Intersect, true)
375
+ }
376
+ this.drawImageFillRect(canvas, imageFillDraw.fill, imageFillDraw.w, imageFillDraw.h, imageFillDraw.absX, imageFillDraw.absY, imageFillDraw.opacity)
377
+ canvas.restore()
378
+ }
379
+
380
+ const strokePaint = this.makeStrokePaint(stroke, opacity)
381
+ if (strokePaint) {
382
+ if (hasRoundedCorners) {
383
+ const maxR = Math.min(w / 2, h / 2)
384
+ canvas.drawRRect(ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)), strokePaint)
385
+ } else {
386
+ canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), strokePaint)
387
+ }
388
+ strokePaint.delete()
389
+ }
390
+ }
391
+
392
+ private drawEllipse(canvas: Canvas, node: PenNode, x: number, y: number, w: number, h: number, opacity: number) {
393
+ const ck = this.ck
394
+ const eNode = node as EllipseNode
395
+ const fills = eNode.fill, stroke = eNode.stroke
396
+ const cr = cornerRadiusValue(eNode.cornerRadius)
397
+
398
+ if (isArcEllipse(eNode.startAngle, eNode.sweepAngle, eNode.innerRadius)) {
399
+ const arcD = buildEllipseArcPath(w, h, eNode.startAngle ?? 0, eNode.sweepAngle ?? 360, eNode.innerRadius ?? 0)
400
+ const path = ck.Path.MakeFromSVGString(arcD)
401
+ if (path) {
402
+ path.offset(x, y)
403
+ const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y)
404
+ if (cr > 0) { const effect = ck.PathEffect.MakeCorner(cr); if (effect) fillPaint.setPathEffect(effect) }
405
+ canvas.drawPath(path, fillPaint); fillPaint.delete()
406
+ const strokePaint = this.makeStrokePaint(stroke, opacity)
407
+ if (strokePaint) { if (cr > 0) { const effect = ck.PathEffect.MakeCorner(cr); if (effect) strokePaint.setPathEffect(effect) }; canvas.drawPath(path, strokePaint); strokePaint.delete() }
408
+ path.delete()
409
+ }
410
+ return
411
+ }
412
+
413
+ const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y)
414
+ canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), fillPaint); fillPaint.delete()
415
+ const strokePaint = this.makeStrokePaint(stroke, opacity)
416
+ if (strokePaint) { canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), strokePaint); strokePaint.delete() }
417
+ }
418
+
419
+ private drawLine(canvas: Canvas, node: PenNode, x: number, y: number, opacity: number) {
420
+ const ck = this.ck
421
+ const lNode = node as LineNode
422
+ const x2 = lNode.x2 ?? x + 100, y2 = lNode.y2 ?? y
423
+ const strokeColor = resolveStrokeColor(lNode.stroke) ?? DEFAULT_STROKE
424
+ const strokeWidth = resolveStrokeWidth(lNode.stroke) || DEFAULT_STROKE_WIDTH
425
+ const paint = new ck.Paint()
426
+ paint.setStyle(ck.PaintStyle.Stroke); paint.setAntiAlias(true); paint.setStrokeWidth(strokeWidth)
427
+ const c = parseColor(ck, strokeColor); c[3] *= opacity; paint.setColor(c)
428
+ canvas.drawLine(x, y, x2, y2, paint); paint.delete()
429
+ }
430
+
431
+ private drawPolygon(canvas: Canvas, node: PenNode, x: number, y: number, w: number, h: number, opacity: number) {
432
+ const ck = this.ck
433
+ const pNode = node as PolygonNode
434
+ const count = pNode.polygonCount || 6
435
+ const fills = pNode.fill, stroke = pNode.stroke
436
+ const cr = cornerRadiusValue(pNode.cornerRadius)
437
+
438
+ const raw: [number, number][] = []
439
+ for (let i = 0; i < count; i++) {
440
+ const angle = (i * 2 * Math.PI) / count - Math.PI / 2
441
+ raw.push([Math.cos(angle), Math.sin(angle)])
442
+ }
443
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
444
+ for (const [rx, ry] of raw) { if (rx < minX) minX = rx; if (rx > maxX) maxX = rx; if (ry < minY) minY = ry; if (ry > maxY) maxY = ry }
445
+ const rawW = maxX - minX, rawH = maxY - minY
446
+
447
+ const path = new ck.Path()
448
+ for (let i = 0; i < count; i++) {
449
+ const px = x + ((raw[i][0] - minX) / rawW) * w, py = y + ((raw[i][1] - minY) / rawH) * h
450
+ if (i === 0) path.moveTo(px, py); else path.lineTo(px, py)
451
+ }
452
+ path.close()
453
+
454
+ const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y)
455
+ if (cr > 0) { const effect = ck.PathEffect.MakeCorner(cr); if (effect) fillPaint.setPathEffect(effect) }
456
+ canvas.drawPath(path, fillPaint); fillPaint.delete()
457
+ const strokePaint = this.makeStrokePaint(stroke, opacity)
458
+ if (strokePaint) { if (cr > 0) { const effect = ck.PathEffect.MakeCorner(cr); if (effect) strokePaint.setPathEffect(effect) }; canvas.drawPath(path, strokePaint); strokePaint.delete() }
459
+ path.delete()
460
+ }
461
+
462
+ private drawPath(canvas: Canvas, node: PenNode, x: number, y: number, w: number, h: number, opacity: number) {
463
+ const ck = this.ck
464
+ const pNode = node as PathNode
465
+ const rawD = typeof pNode.d === 'string' && pNode.d.trim().length > 0 ? pNode.d : 'M0 0 L0 0'
466
+ const fills = pNode.fill, stroke = pNode.stroke
467
+
468
+ let path: ReturnType<typeof ck.Path.MakeFromSVGString> = null
469
+ if (hasInvalidNumbers(rawD)) { path = tryManualPathParse(ck, rawD) }
470
+ else {
471
+ const d = sanitizeSvgPath(rawD)
472
+ path = ck.Path.MakeFromSVGString(d)
473
+ if (!path && d !== rawD) path = ck.Path.MakeFromSVGString(rawD)
474
+ if (!path) path = tryManualPathParse(ck, rawD)
475
+ }
476
+ if (!path) {
477
+ if (w > 0 && h > 0) { const { paint: fp } = this.makeFillPaint(fills, w, h, opacity, x, y); canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fp); fp.delete() }
478
+ return
479
+ }
480
+
481
+ const bounds = path.getBounds()
482
+ const nativeW = bounds[2] - bounds[0], nativeH = bounds[3] - bounds[1]
483
+ if (w > 0 && h > 0 && nativeW > 0.01 && nativeH > 0.01) {
484
+ const isIcon = !!pNode.iconId
485
+ const sx = isIcon ? Math.min(w / nativeW, h / nativeH) : w / nativeW
486
+ const sy = isIcon ? sx : h / nativeH
487
+ path.transform(ck.Matrix.multiply(ck.Matrix.translated(x - bounds[0] * sx, y - bounds[1] * sy), ck.Matrix.scaled(sx, sy)))
488
+ } else if (nativeW > 0.01 || nativeH > 0.01) {
489
+ const sx = nativeW > 0.01 && w > 0 ? w / nativeW : 1, sy = nativeH > 0.01 && h > 0 ? h / nativeH : 1
490
+ path.transform(ck.Matrix.multiply(ck.Matrix.translated(x - bounds[0] * sx, y - bounds[1] * sy), ck.Matrix.scaled(sx, sy)))
491
+ } else { path.offset(x, y) }
492
+
493
+ const hasExplicitFill = fills && fills.length > 0
494
+ const strokeColor = resolveStrokeColor(stroke), strokeWidth = resolveStrokeWidth(stroke)
495
+ const hasVisibleStroke = strokeWidth > 0 && !!strokeColor
496
+
497
+ if (hasExplicitFill || !hasVisibleStroke) {
498
+ const { paint: fillPaint } = this.makeFillPaint(hasExplicitFill ? fills : undefined, w, h, opacity, x, y)
499
+ const closeCount = (rawD.match(/Z/gi) || []).length
500
+ path.setFillType(closeCount > 1 ? ck.FillType.EvenOdd : ck.FillType.Winding)
501
+ canvas.drawPath(path, fillPaint); fillPaint.delete()
502
+ }
503
+ if (hasVisibleStroke) { const sp = this.makeStrokePaint(stroke, opacity); if (sp) { canvas.drawPath(path, sp); sp.delete() } }
504
+ path.delete()
505
+ }
506
+
507
+ private drawIconFont(canvas: Canvas, node: PenNode, x: number, y: number, w: number, h: number, opacity: number) {
508
+ const ck = this.ck
509
+ const iNode = node as IconFontNode
510
+ const iconName = iNode.iconFontName ?? iNode.name ?? ''
511
+ const iconMatch = this.iconLookup?.(iconName) ?? null
512
+ const iconD = iconMatch?.d ?? FALLBACK_ICON_D
513
+ const iconStyle = iconMatch?.style ?? 'stroke'
514
+
515
+ const rawFill = iNode.fill
516
+ const iconFillColor = typeof rawFill === 'string' ? rawFill
517
+ : Array.isArray(iNode.fill) && iNode.fill.length > 0 ? resolveFillColor(iNode.fill) : '#64748B'
518
+
519
+ const sanitizedIconD = sanitizeSvgPath(iconD)
520
+ let path = ck.Path.MakeFromSVGString(sanitizedIconD)
521
+ if (!path && sanitizedIconD !== iconD) path = ck.Path.MakeFromSVGString(iconD)
522
+ if (!path) path = tryManualPathParse(ck, iconD)
523
+ if (!path) return
524
+
525
+ const bounds = path.getBounds()
526
+ const nativeW = bounds[2] - bounds[0], nativeH = bounds[3] - bounds[1]
527
+ if (w > 0 && h > 0 && nativeW > 0 && nativeH > 0) {
528
+ const s = Math.min(w / nativeW, h / nativeH)
529
+ path.transform(ck.Matrix.multiply(ck.Matrix.translated(x - bounds[0] * s, y - bounds[1] * s), ck.Matrix.scaled(s, s)))
530
+ } else { path.offset(x, y) }
531
+
532
+ const paint = new ck.Paint()
533
+ paint.setAntiAlias(true)
534
+ const c = parseColor(ck, iconFillColor); c[3] *= opacity; paint.setColor(c)
535
+ if (iconStyle === 'stroke') {
536
+ paint.setStyle(ck.PaintStyle.Stroke); paint.setStrokeWidth(2)
537
+ paint.setStrokeCap(ck.StrokeCap.Round); paint.setStrokeJoin(ck.StrokeJoin.Round)
538
+ } else {
539
+ paint.setStyle(ck.PaintStyle.Fill); path.setFillType(ck.FillType.EvenOdd)
540
+ }
541
+ canvas.drawPath(path, paint); paint.delete(); path.delete()
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // Image drawing
546
+ // ---------------------------------------------------------------------------
547
+
548
+ private drawImage(canvas: Canvas, node: PenNode, x: number, y: number, w: number, h: number, opacity: number) {
549
+ const ck = this.ck
550
+ const iNode = node as ImageNode
551
+ const src: string | undefined = iNode.src
552
+ const cr = cornerRadiusValue(iNode.cornerRadius)
553
+
554
+ if (!src) { this.drawImageFallback(canvas, x, y, w, h, cr, opacity); return }
555
+
556
+ const cached = this.imageLoader.get(src)
557
+ if (cached === undefined) { this.imageLoader.request(src); this.drawImageFallback(canvas, x, y, w, h, cr, opacity); return }
558
+ if (!cached) { this.drawImageFallback(canvas, x, y, w, h, cr, opacity); return }
559
+
560
+ const imgW = cached.width(), imgH = cached.height()
561
+
562
+ if (cr > 0) { canvas.save(); const maxR = Math.min(cr, w / 2, h / 2); canvas.clipRRect(ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), maxR, maxR), ck.ClipOp.Intersect, true) }
563
+ else { canvas.save(); canvas.clipRect(ck.LTRBRect(x, y, x + w, y + h), ck.ClipOp.Intersect, true) }
564
+
565
+ const paint = new ck.Paint()
566
+ paint.setAntiAlias(true)
567
+ if (opacity < 1) paint.setAlphaf(opacity)
568
+ const adjFilter = this.buildImageAdjustmentFilter(iNode)
569
+ if (adjFilter) paint.setColorFilter(adjFilter)
570
+
571
+ const fit = iNode.objectFit ?? 'fill'
572
+ if (fit === 'tile') {
573
+ const tileMatrix = Float32Array.of(1, 0, -x, 0, 1, -y, 0, 0, 1)
574
+ const shader = cached.makeShaderOptions(ck.TileMode.Repeat, ck.TileMode.Repeat, ck.FilterMode.Linear, ck.MipmapMode.None, tileMatrix)
575
+ if (shader) { paint.setShader(shader); canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), paint) }
576
+ } else if (fit === 'fit') {
577
+ const bgPaint = new ck.Paint(); bgPaint.setStyle(ck.PaintStyle.Fill); bgPaint.setColor(parseColor(ck, '#f3f4f6'))
578
+ if (opacity < 1) bgPaint.setAlphaf(opacity * 0.3); else bgPaint.setAlphaf(0.3)
579
+ canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), bgPaint); bgPaint.delete()
580
+ const scale = Math.min(w / imgW, h / imgH), dw = imgW * scale, dh = imgH * scale
581
+ const dx = x + (w - dw) / 2, dy = y + (h - dh) / 2
582
+ canvas.drawImageRect(cached, ck.LTRBRect(0, 0, imgW, imgH), ck.LTRBRect(dx, dy, dx + dw, dy + dh), paint)
583
+ } else {
584
+ const scale = Math.max(w / imgW, h / imgH), dw = imgW * scale, dh = imgH * scale
585
+ const dx = x + (w - dw) / 2, dy = y + (h - dh) / 2
586
+ canvas.drawImageRect(cached, ck.LTRBRect(0, 0, imgW, imgH), ck.LTRBRect(dx, dy, dx + dw, dy + dh), paint)
587
+ }
588
+ paint.delete(); canvas.restore()
589
+ }
590
+
591
+ private drawImageFallback(canvas: Canvas, x: number, y: number, w: number, h: number, cr: number, opacity: number) {
592
+ const ck = this.ck
593
+ const paint = new ck.Paint(); paint.setStyle(ck.PaintStyle.Fill); paint.setAntiAlias(true)
594
+ const c = parseColor(ck, '#e5e7eb'); c[3] *= opacity; paint.setColor(c)
595
+ if (cr > 0) { const maxR = Math.min(cr, w / 2, h / 2); canvas.drawRRect(ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), maxR, maxR), paint) }
596
+ else { canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), paint) }
597
+ paint.delete()
598
+ }
599
+ }