@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.
- package/README.md +64 -0
- package/package.json +31 -0
- package/src/document-flattener.ts +340 -0
- package/src/font-manager.ts +401 -0
- package/src/image-loader.ts +93 -0
- package/src/index.ts +60 -0
- package/src/init.ts +44 -0
- package/src/node-renderer.ts +599 -0
- package/src/paint-utils.ts +148 -0
- package/src/path-utils.ts +225 -0
- package/src/renderer.ts +374 -0
- package/src/spatial-index.ts +89 -0
- package/src/text-renderer.ts +531 -0
- package/src/types.ts +40 -0
- package/src/viewport.ts +102 -0
|
@@ -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
|
+
}
|