@zseven-w/pen-renderer 0.6.0 → 0.7.0

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.
@@ -1,74 +1,107 @@
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'
1
+ import type { CanvasKit, Canvas, Paint, Font, Typeface } from 'canvaskit-wasm';
2
+ import type {
3
+ PenNode,
4
+ ContainerProps,
5
+ EllipseNode,
6
+ LineNode,
7
+ PolygonNode,
8
+ PathNode,
9
+ ImageNode,
10
+ IconFontNode,
11
+ } from '@zseven-w/pen-types';
12
+ import type { PenFill, PenStroke, PenEffect, ShadowEffect, ImageFill } from '@zseven-w/pen-types';
13
+ import {
14
+ DEFAULT_FILL,
15
+ DEFAULT_STROKE,
16
+ DEFAULT_STROKE_WIDTH,
17
+ buildEllipseArcPath,
18
+ getPathBoundsFromAnchors,
19
+ isArcEllipse,
20
+ pathDataToAnchors,
21
+ } from '@zseven-w/pen-core';
22
+ import {
23
+ parseColor,
24
+ cornerRadiusValue,
25
+ cornerRadii,
26
+ resolveFillColor,
27
+ shouldUseTransparentFallbackFill,
28
+ resolveStrokeColor,
29
+ resolveStrokeWidth,
30
+ } from './paint-utils.js';
31
+ import { sanitizeSvgPath, hasInvalidNumbers, tryManualPathParse } from './path-utils.js';
32
+ import { SkiaImageLoader } from './image-loader.js';
33
+ import { SkiaTextRenderer } from './text-renderer.js';
34
+ import type { SkiaFontManager, FontManagerOptions } from './font-manager.js';
35
+ import type { RenderNode, IconLookupFn } from './types.js';
36
+
37
+ const FALLBACK_ICON_D = 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0';
13
38
 
14
39
  /**
15
40
  * Core node renderer for CanvasKit/Skia. Draws PenNode shapes, fills,
16
41
  * strokes, effects, text, and images. No editor overlays or store dependencies.
17
42
  */
18
43
  export class SkiaNodeRenderer {
19
- protected ck: CanvasKit
20
- private defaultTypeface: Typeface | null = null
21
- private defaultFont: Font | null = null
44
+ protected ck: CanvasKit;
45
+ private defaultTypeface: Typeface | null = null;
46
+ private defaultFont: Font | null = null;
22
47
 
23
48
  // Current viewport zoom (set by engine before each render frame)
24
- zoom = 1
49
+ zoom = 1;
25
50
 
26
51
  // Device pixel ratio
27
- devicePixelRatio: number | undefined
52
+ devicePixelRatio: number | undefined;
28
53
 
29
54
  // Sub-renderers
30
- private textRenderer: SkiaTextRenderer
31
- imageLoader: SkiaImageLoader
55
+ private textRenderer: SkiaTextRenderer;
56
+ imageLoader: SkiaImageLoader;
32
57
 
33
58
  // Injectable icon lookup
34
- private iconLookup: IconLookupFn | null = null
59
+ private iconLookup: IconLookupFn | null = null;
35
60
 
36
61
  /** Font manager — delegates to text renderer */
37
62
  get fontManager(): SkiaFontManager {
38
- return this.textRenderer.fontManager
63
+ return this.textRenderer.fontManager;
39
64
  }
40
65
 
41
66
  constructor(ck: CanvasKit, fontOptions?: FontManagerOptions) {
42
- this.ck = ck
43
- this.imageLoader = new SkiaImageLoader(ck)
44
- this.textRenderer = new SkiaTextRenderer(ck, fontOptions)
67
+ this.ck = ck;
68
+ this.imageLoader = new SkiaImageLoader(ck);
69
+ this.textRenderer = new SkiaTextRenderer(ck, fontOptions);
45
70
  }
46
71
 
47
72
  init() {
48
- this.defaultFont = new this.ck.Font(null, 16)
73
+ this.defaultFont = new this.ck.Font(null, 16);
49
74
  }
50
75
 
51
76
  /** Set callback to trigger re-render when async images finish loading. */
52
77
  setRedrawCallback(cb: () => void) {
53
- this.imageLoader.setOnLoaded(cb)
78
+ this.imageLoader.setOnLoaded(cb);
54
79
  }
55
80
 
56
81
  /** Set injectable icon lookup function. */
57
82
  setIconLookup(fn: IconLookupFn) {
58
- this.iconLookup = fn
83
+ this.iconLookup = fn;
84
+ }
85
+
86
+ setImageSourceResolver(resolver: (src: string) => { cacheKey: string; loadUrl: string | null }) {
87
+ this.imageLoader.setSourceResolver(resolver);
59
88
  }
60
89
 
61
90
  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()
91
+ this.defaultFont?.delete();
92
+ this.defaultFont = null;
93
+ this.defaultTypeface?.delete();
94
+ this.defaultTypeface = null;
95
+ this.textRenderer.dispose();
96
+ this.imageLoader.dispose();
68
97
  }
69
98
 
70
- clearTextCache() { this.textRenderer.clearTextCache() }
71
- clearParaCache() { this.textRenderer.clearParaCache() }
99
+ clearTextCache() {
100
+ this.textRenderer.clearTextCache();
101
+ }
102
+ clearParaCache() {
103
+ this.textRenderer.clearParaCache();
104
+ }
72
105
 
73
106
  // ---------------------------------------------------------------------------
74
107
  // Fill paint
@@ -76,146 +109,304 @@ export class SkiaNodeRenderer {
76
109
 
77
110
  private makeFillPaint(
78
111
  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)
112
+ w: number,
113
+ h: number,
114
+ opacity: number,
115
+ absX: number,
116
+ absY: number,
117
+ ): {
118
+ paint: Paint;
119
+ imageFillDraw?: {
120
+ fill: ImageFill;
121
+ w: number;
122
+ h: number;
123
+ absX: number;
124
+ absY: number;
125
+ opacity: number;
126
+ };
127
+ } {
128
+ const ck = this.ck;
129
+ const paint = new ck.Paint();
130
+ paint.setStyle(ck.PaintStyle.Fill);
131
+ paint.setAntiAlias(true);
85
132
 
86
133
  if (typeof fills === 'string') {
87
- const c = parseColor(ck, fills); c[3] *= opacity; paint.setColor(c)
88
- return { paint }
134
+ const c = parseColor(ck, fills);
135
+ c[3] *= opacity;
136
+ paint.setColor(c);
137
+ return { paint };
89
138
  }
90
139
  if (!fills || fills.length === 0) {
91
- const c = parseColor(ck, DEFAULT_FILL); c[3] *= opacity; paint.setColor(c)
92
- return { paint }
140
+ const c = parseColor(ck, DEFAULT_FILL);
141
+ c[3] *= opacity;
142
+ paint.setColor(c);
143
+ return { paint };
93
144
  }
94
145
 
95
- const first = fills[0]
146
+ const first = fills[0];
96
147
  if (first.type === 'solid') {
97
- const c = parseColor(ck, first.color); c[3] *= (first.opacity ?? 1) * opacity; paint.setColor(c)
148
+ const c = parseColor(ck, first.color);
149
+ c[3] *= (first.opacity ?? 1) * opacity;
150
+ paint.setColor(c);
98
151
  } else if (first.type === 'linear_gradient') {
99
- const stops = first.stops ?? []
100
- const fillOpacity = (first.opacity ?? 1) * opacity
152
+ const stops = first.stops ?? [];
153
+ const fillOpacity = (first.opacity ?? 1) * opacity;
101
154
  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)
155
+ const rad = (((first.angle ?? 0) - 90) * Math.PI) / 180;
156
+ const cos = Math.cos(rad),
157
+ sin = Math.sin(rad);
158
+ const x1 = absX + w / 2 - (cos * w) / 2,
159
+ y1 = absY + h / 2 - (sin * h) / 2;
160
+ const x2 = absX + w / 2 + (cos * w) / 2,
161
+ y2 = absY + h / 2 + (sin * h) / 2;
162
+ const colors = stops.map((s) => {
163
+ const c = parseColor(ck, s.color);
164
+ c[3] *= fillOpacity;
165
+ return c;
166
+ });
167
+ const positions = stops.map((s) => Math.max(0, Math.min(1, s.offset)));
168
+ const shader = ck.Shader.MakeLinearGradient(
169
+ [x1, y1],
170
+ [x2, y2],
171
+ colors,
172
+ positions,
173
+ ck.TileMode.Clamp,
174
+ );
175
+ if (shader) paint.setShader(shader);
110
176
  } else {
111
- const c = parseColor(ck, stops[0]?.color ?? DEFAULT_FILL); c[3] *= fillOpacity; paint.setColor(c)
177
+ const c = parseColor(ck, stops[0]?.color ?? DEFAULT_FILL);
178
+ c[3] *= fillOpacity;
179
+ paint.setColor(c);
112
180
  }
113
181
  } else if (first.type === 'radial_gradient') {
114
- const stops = first.stops ?? []
115
- const fillOpacity = (first.opacity ?? 1) * opacity
182
+ const stops = first.stops ?? [];
183
+ const fillOpacity = (first.opacity ?? 1) * opacity;
116
184
  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)
185
+ const cx = absX + (first.cx ?? 0.5) * w,
186
+ cy = absY + (first.cy ?? 0.5) * h;
187
+ const r = (first.radius ?? 0.5) * Math.max(w, h);
188
+ const colors = stops.map((s) => {
189
+ const c = parseColor(ck, s.color);
190
+ c[3] *= fillOpacity;
191
+ return c;
192
+ });
193
+ const positions = stops.map((s) => Math.max(0, Math.min(1, s.offset)));
194
+ const shader = ck.Shader.MakeRadialGradient(
195
+ [cx, cy],
196
+ r,
197
+ colors,
198
+ positions,
199
+ ck.TileMode.Clamp,
200
+ );
201
+ if (shader) paint.setShader(shader);
123
202
  } else {
124
- const c = parseColor(ck, stops[0]?.color ?? DEFAULT_FILL); c[3] *= fillOpacity; paint.setColor(c)
203
+ const c = parseColor(ck, stops[0]?.color ?? DEFAULT_FILL);
204
+ c[3] *= fillOpacity;
205
+ paint.setColor(c);
125
206
  }
126
207
  } else if (first.type === 'image') {
127
- const result = this.applyImageFillToPaint(paint, first, w, h, opacity, absX, absY)
208
+ const result = this.applyImageFillToPaint(paint, first, w, h, opacity, absX, absY);
128
209
  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! } }
210
+ return {
211
+ paint,
212
+ imageFillDraw: {
213
+ fill: result.fill,
214
+ w: result.w!,
215
+ h: result.h!,
216
+ absX: result.absX!,
217
+ absY: result.absY!,
218
+ opacity: result.opacity!,
219
+ },
220
+ };
130
221
  }
131
222
  }
132
223
 
133
- return { paint }
224
+ return { paint };
134
225
  }
135
226
 
136
227
  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'
228
+ paint: Paint,
229
+ fill: ImageFill,
230
+ w: number,
231
+ h: number,
232
+ opacity: number,
233
+ absX: number,
234
+ absY: number,
235
+ ): {
236
+ needsDrawImageRect: boolean;
237
+ fill?: ImageFill;
238
+ w?: number;
239
+ h?: number;
240
+ absX?: number;
241
+ absY?: number;
242
+ opacity?: number;
243
+ } {
244
+ const ck = this.ck;
245
+ const fillOpacity = (fill.opacity ?? 1) * opacity;
246
+ const url = fill.url;
247
+ if (!url) {
248
+ const c = parseColor(ck, '#e5e7eb');
249
+ c[3] *= fillOpacity;
250
+ paint.setColor(c);
251
+ return { needsDrawImageRect: false };
252
+ }
253
+
254
+ const cached = this.imageLoader.get(url);
255
+ if (cached === undefined) this.imageLoader.request(url);
256
+ if (!cached) {
257
+ const isMissing = this.imageLoader.getStatus(url)?.state === 'missing';
258
+ const c = parseColor(ck, isMissing ? '#f1d7d7' : '#e5e7eb');
259
+ c[3] *= fillOpacity;
260
+ paint.setColor(c);
261
+ return { needsDrawImageRect: false };
262
+ }
263
+
264
+ const imgW = cached.width(),
265
+ imgH = cached.height();
266
+ if (imgW <= 0 || imgH <= 0) return { needsDrawImageRect: false };
267
+
268
+ const mode = fill.mode ?? 'fill';
153
269
  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 }
270
+ const dispX = absX + (w - imgW) / 2,
271
+ dispY = absY + (h - imgH) / 2;
272
+ const localMatrix = Float32Array.of(1, 0, -dispX, 0, 1, -dispY, 0, 0, 1);
273
+ const shader = cached.makeShaderOptions(
274
+ ck.TileMode.Repeat,
275
+ ck.TileMode.Repeat,
276
+ ck.FilterMode.Linear,
277
+ ck.MipmapMode.None,
278
+ localMatrix,
279
+ );
280
+ if (shader) {
281
+ paint.setShader(shader);
282
+ if (fillOpacity < 1) paint.setAlphaf(fillOpacity);
283
+ const cf = this.buildImageAdjustmentFilter(fill);
284
+ if (cf) paint.setColorFilter(cf);
285
+ }
286
+ return { needsDrawImageRect: false };
159
287
  }
160
288
 
161
- paint.setColor(Float32Array.of(0, 0, 0, 0))
162
- return { needsDrawImageRect: true, fill, w, h, absX, absY, opacity: fillOpacity }
289
+ paint.setColor(Float32Array.of(0, 0, 0, 0));
290
+ return { needsDrawImageRect: true, fill, w, h, absX, absY, opacity: fillOpacity };
163
291
  }
164
292
 
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)
293
+ private drawImageFillRect(
294
+ canvas: Canvas,
295
+ fill: ImageFill,
296
+ w: number,
297
+ h: number,
298
+ absX: number,
299
+ absY: number,
300
+ fillOpacity: number,
301
+ ) {
302
+ const ck = this.ck;
303
+ const url = fill.url;
304
+ if (!url) return;
305
+ const cached = this.imageLoader.get(url);
306
+ if (!cached) return;
307
+ const imgW = cached.width(),
308
+ imgH = cached.height();
309
+ if (imgW <= 0 || imgH <= 0) return;
310
+
311
+ const mode = fill.mode ?? 'fill';
312
+ const paint = new ck.Paint();
313
+ paint.setAntiAlias(true);
314
+ if (fillOpacity < 1) paint.setAlphaf(fillOpacity);
315
+ const adjFilter = this.buildImageAdjustmentFilter(fill);
316
+ if (adjFilter) paint.setColorFilter(adjFilter);
180
317
 
181
318
  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)
319
+ const scale = Math.min(w / imgW, h / imgH);
320
+ const dw = imgW * scale,
321
+ dh = imgH * scale;
322
+ const dx = absX + (w - dw) / 2,
323
+ dy = absY + (h - dh) / 2;
324
+ canvas.drawImageRect(
325
+ cached,
326
+ ck.LTRBRect(0, 0, imgW, imgH),
327
+ ck.LTRBRect(dx, dy, dx + dw, dy + dh),
328
+ paint,
329
+ );
186
330
  } else if (mode === 'stretch') {
187
- canvas.drawImageRect(cached, ck.LTRBRect(0, 0, imgW, imgH), ck.LTRBRect(absX, absY, absX + w, absY + h), paint)
331
+ canvas.drawImageRect(
332
+ cached,
333
+ ck.LTRBRect(0, 0, imgW, imgH),
334
+ ck.LTRBRect(absX, absY, absX + w, absY + h),
335
+ paint,
336
+ );
188
337
  } 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)
338
+ const scale = Math.max(w / imgW, h / imgH);
339
+ const dw = imgW * scale,
340
+ dh = imgH * scale;
341
+ const dx = absX + (w - dw) / 2,
342
+ dy = absY + (h - dh) / 2;
343
+ canvas.drawImageRect(
344
+ cached,
345
+ ck.LTRBRect(0, 0, imgW, imgH),
346
+ ck.LTRBRect(dx, dy, dx + dw, dy + dh),
347
+ paint,
348
+ );
193
349
  }
194
- paint.delete()
350
+ paint.delete();
195
351
  }
196
352
 
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
353
+ private buildImageAdjustmentFilter(adj: {
354
+ exposure?: number;
355
+ contrast?: number;
356
+ saturation?: number;
357
+ temperature?: number;
358
+ tint?: number;
359
+ highlights?: number;
360
+ shadows?: number;
361
+ }) {
362
+ const ck = this.ck;
363
+ const exp = (adj.exposure ?? 0) / 100,
364
+ con = (adj.contrast ?? 0) / 100,
365
+ sat = (adj.saturation ?? 0) / 100;
366
+ const temp = (adj.temperature ?? 0) / 100,
367
+ tintVal = (adj.tint ?? 0) / 100;
368
+ const hi = (adj.highlights ?? 0) / 100,
369
+ sh = (adj.shadows ?? 0) / 100;
370
+ if (exp === 0 && con === 0 && sat === 0 && temp === 0 && tintVal === 0 && hi === 0 && sh === 0)
371
+ return null;
372
+
373
+ const e = 1 + exp * 1.5,
374
+ c = 1 + con,
375
+ cOff = 0.5 * (1 - c);
376
+ const s = 1 + sat;
377
+ const lr = 0.2126,
378
+ lg = 0.7152,
379
+ lb = 0.0722;
380
+ const sr = (1 - s) * lr,
381
+ sg = (1 - s) * lg,
382
+ sb = (1 - s) * lb;
383
+ const f = c * e;
384
+ const offR = cOff + temp * 0.15 + (hi + sh * 0.5) * 0.1;
385
+ const offG = cOff + tintVal * 0.15 + (hi + sh * 0.5) * 0.1;
386
+ const offB = cOff - temp * 0.15 + (hi + sh * 0.5) * 0.1;
212
387
 
213
388
  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
- ])
389
+ f * (sr + s),
390
+ f * sg,
391
+ f * sb,
392
+ 0,
393
+ offR,
394
+ f * sr,
395
+ f * (sg + s),
396
+ f * sb,
397
+ 0,
398
+ offG,
399
+ f * sr,
400
+ f * sg,
401
+ f * (sb + s),
402
+ 0,
403
+ offB,
404
+ 0,
405
+ 0,
406
+ 0,
407
+ 1,
408
+ 0,
409
+ ]);
219
410
  }
220
411
 
221
412
  // ---------------------------------------------------------------------------
@@ -223,51 +414,65 @@ export class SkiaNodeRenderer {
223
414
  // ---------------------------------------------------------------------------
224
415
 
225
416
  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)
417
+ if (!stroke) return null;
418
+ const strokeColor = resolveStrokeColor(stroke);
419
+ const strokeWidth = resolveStrokeWidth(stroke);
420
+ if (!strokeColor || strokeWidth <= 0) return null;
421
+
422
+ const ck = this.ck;
423
+ const paint = new ck.Paint();
424
+ paint.setStyle(ck.PaintStyle.Stroke);
425
+ paint.setAntiAlias(true);
426
+ paint.setStrokeWidth(strokeWidth);
427
+ const c = parseColor(ck, strokeColor);
428
+ c[3] *= opacity;
429
+ paint.setColor(c);
430
+
431
+ if (stroke.join === 'round') paint.setStrokeJoin(ck.StrokeJoin.Round);
432
+ else if (stroke.join === 'bevel') paint.setStrokeJoin(ck.StrokeJoin.Bevel);
433
+ if (stroke.cap === 'round') paint.setStrokeCap(ck.StrokeCap.Round);
434
+ else if (stroke.cap === 'square') paint.setStrokeCap(ck.StrokeCap.Square);
242
435
  if (stroke.dashPattern && stroke.dashPattern.length >= 2) {
243
- const effect = ck.PathEffect.MakeDash(stroke.dashPattern, 0)
244
- if (effect) paint.setPathEffect(effect)
436
+ const effect = ck.PathEffect.MakeDash(stroke.dashPattern, stroke.dashOffset ?? 0);
437
+ if (effect) paint.setPathEffect(effect);
245
438
  }
246
439
 
247
- return paint
440
+ return paint;
248
441
  }
249
442
 
250
443
  // ---------------------------------------------------------------------------
251
444
  // Shadow
252
445
  // ---------------------------------------------------------------------------
253
446
 
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
447
+ private applyShadowDirect(
448
+ canvas: Canvas,
449
+ effects: PenEffect[] | undefined,
450
+ x: number,
451
+ y: number,
452
+ w: number,
453
+ h: number,
454
+ ): boolean {
455
+ if (!effects) return false;
456
+ const shadow = effects.find((e): e is ShadowEffect => e.type === 'shadow');
457
+ if (!shadow) return false;
458
+
459
+ const ck = this.ck;
460
+ const paint = new ck.Paint();
461
+ paint.setStyle(ck.PaintStyle.Fill);
462
+ paint.setAntiAlias(true);
463
+ paint.setColor(parseColor(ck, shadow.color));
464
+ paint.setMaskFilter(ck.MaskFilter.MakeBlur(ck.BlurStyle.Normal, shadow.blur / 2, true));
465
+ canvas.drawRect(
466
+ ck.LTRBRect(
467
+ x + shadow.offsetX - shadow.spread,
468
+ y + shadow.offsetY - shadow.spread,
469
+ x + w + shadow.offsetX + shadow.spread,
470
+ y + h + shadow.offsetY + shadow.spread,
471
+ ),
472
+ paint,
473
+ );
474
+ paint.delete();
475
+ return true;
271
476
  }
272
477
 
273
478
  // ---------------------------------------------------------------------------
@@ -275,325 +480,691 @@ export class SkiaNodeRenderer {
275
480
  // ---------------------------------------------------------------------------
276
481
 
277
482
  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
483
+ const { node, absX, absY, absW, absH, clipRect } = rn;
484
+ const ck = this.ck;
485
+ const opacity = typeof node.opacity === 'number' ? node.opacity : 1;
281
486
 
282
- if (('visible' in node ? node.visible : undefined) === false) return
487
+ if (('visible' in node ? node.visible : undefined) === false) return;
283
488
 
284
489
  // Pass zoom to text renderer
285
- this.textRenderer.zoom = this.zoom
286
- this.textRenderer.devicePixelRatio = this.devicePixelRatio
490
+ this.textRenderer.zoom = this.zoom;
491
+ this.textRenderer.devicePixelRatio = this.devicePixelRatio;
287
492
 
288
493
  // Apply clipping from parent frame
289
- let clipped = false
494
+ let clipped = false;
290
495
  if (clipRect) {
291
- canvas.save(); clipped = true
496
+ canvas.save();
497
+ clipped = true;
292
498
  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)
499
+ canvas.clipRRect(
500
+ ck.RRectXY(
501
+ ck.LTRBRect(clipRect.x, clipRect.y, clipRect.x + clipRect.w, clipRect.y + clipRect.h),
502
+ clipRect.rx,
503
+ clipRect.rx,
504
+ ),
505
+ ck.ClipOp.Intersect,
506
+ true,
507
+ );
294
508
  } else {
295
- canvas.clipRect(ck.LTRBRect(clipRect.x, clipRect.y, clipRect.x + clipRect.w, clipRect.y + clipRect.h), ck.ClipOp.Intersect, true)
509
+ canvas.clipRect(
510
+ ck.LTRBRect(clipRect.x, clipRect.y, clipRect.x + clipRect.w, clipRect.y + clipRect.h),
511
+ ck.ClipOp.Intersect,
512
+ true,
513
+ );
296
514
  }
297
515
  }
298
516
 
299
517
  // Apply flip
300
- const flipX = node.flipX === true, flipY = node.flipY === true
518
+ const flipX = node.flipX === true,
519
+ flipY = node.flipY === true;
301
520
  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))
521
+ canvas.save();
522
+ canvas.translate(absX + absW / 2, absY + absH / 2);
523
+ canvas.scale(flipX ? -1 : 1, flipY ? -1 : 1);
524
+ canvas.translate(-(absX + absW / 2), -(absY + absH / 2));
306
525
  }
307
526
 
308
527
  // Apply rotation
309
- const rotation = node.rotation ?? 0
528
+ const rotation = node.rotation ?? 0;
310
529
  if (rotation !== 0) {
311
- canvas.save()
312
- canvas.rotate(rotation, absX + absW / 2, absY + absH / 2)
530
+ canvas.save();
531
+ canvas.rotate(rotation, absX + absW / 2, absY + absH / 2);
313
532
  }
314
533
 
315
534
  // Apply shadow (text uses glyph-shaped shadow, not rectangle)
316
- const effects = 'effects' in node ? (node as PenNode & { effects?: PenEffect[] }).effects : undefined
535
+ const effects =
536
+ 'effects' in node ? (node as PenNode & { effects?: PenEffect[] }).effects : undefined;
317
537
  if (node.type !== 'text') {
318
- this.applyShadowDirect(canvas, effects, absX, absY, absW, absH)
538
+ this.applyShadowDirect(canvas, effects, absX, absY, absW, absH);
319
539
  }
320
540
 
321
541
  switch (node.type) {
322
- case 'frame': case 'rectangle': case 'group':
323
- this.drawRect(canvas, node, absX, absY, absW, absH, opacity); break
542
+ case 'frame':
543
+ case 'rectangle':
544
+ case 'group':
545
+ this.drawRect(canvas, node, absX, absY, absW, absH, opacity);
546
+ break;
324
547
  case 'ellipse':
325
- this.drawEllipse(canvas, node, absX, absY, absW, absH, opacity); break
548
+ this.drawEllipse(canvas, node, absX, absY, absW, absH, opacity);
549
+ break;
326
550
  case 'line':
327
- this.drawLine(canvas, node, absX, absY, opacity); break
551
+ this.drawLine(canvas, node, absX, absY, opacity);
552
+ break;
328
553
  case 'polygon':
329
- this.drawPolygon(canvas, node, absX, absY, absW, absH, opacity); break
554
+ this.drawPolygon(canvas, node, absX, absY, absW, absH, opacity);
555
+ break;
330
556
  case 'path':
331
- this.drawPath(canvas, node, absX, absY, absW, absH, opacity); break
557
+ this.drawPath(canvas, node, absX, absY, absW, absH, opacity);
558
+ break;
332
559
  case 'icon_font':
333
- this.drawIconFont(canvas, node, absX, absY, absW, absH, opacity); break
560
+ this.drawIconFont(canvas, node, absX, absY, absW, absH, opacity);
561
+ break;
334
562
  case 'text':
335
- this.textRenderer.drawText(canvas, node, absX, absY, absW, absH, opacity, effects); break
563
+ this.textRenderer.drawText(canvas, node, absX, absY, absW, absH, opacity, effects);
564
+ break;
336
565
  case 'image':
337
- this.drawImage(canvas, node, absX, absY, absW, absH, opacity); break
566
+ this.drawImage(canvas, node, absX, absY, absW, absH, opacity);
567
+ break;
338
568
  }
339
569
 
340
- if (rotation !== 0) canvas.restore()
341
- if (flipX || flipY) canvas.restore()
342
- if (clipped) canvas.restore()
570
+ if (rotation !== 0) canvas.restore();
571
+ if (flipX || flipY) canvas.restore();
572
+ if (clipped) canvas.restore();
343
573
  }
344
574
 
345
575
  // ---------------------------------------------------------------------------
346
576
  // Shape drawing
347
577
  // ---------------------------------------------------------------------------
348
578
 
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)
579
+ private drawRect(
580
+ canvas: Canvas,
581
+ node: PenNode,
582
+ x: number,
583
+ y: number,
584
+ w: number,
585
+ h: number,
586
+ opacity: number,
587
+ ) {
588
+ const ck = this.ck;
589
+ const container = node as PenNode & ContainerProps;
590
+ const cr = cornerRadii(container.cornerRadius);
591
+ const fills = container.fill;
592
+ const stroke = container.stroke;
593
+ const hasFill = fills && fills.length > 0;
594
+ const isContainer = node.type === 'frame' || node.type === 'group';
595
+
596
+ const { paint: fillPaint, imageFillDraw } = this.makeFillPaint(
597
+ hasFill || !shouldUseTransparentFallbackFill(fills, stroke, isContainer)
598
+ ? fills
599
+ : 'transparent',
600
+ w,
601
+ h,
602
+ opacity,
603
+ x,
604
+ y,
605
+ );
606
+ const hasRoundedCorners = cr.some((r) => r > 0);
360
607
  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)
608
+ const maxR = Math.min(w / 2, h / 2);
609
+ canvas.drawRRect(
610
+ ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)),
611
+ fillPaint,
612
+ );
363
613
  } else {
364
- canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fillPaint)
614
+ canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fillPaint);
365
615
  }
366
- fillPaint.delete()
616
+ fillPaint.delete();
367
617
 
368
618
  if (imageFillDraw) {
369
- canvas.save()
619
+ canvas.save();
370
620
  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)
621
+ const maxR = Math.min(w / 2, h / 2);
622
+ canvas.clipRRect(
623
+ ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)),
624
+ ck.ClipOp.Intersect,
625
+ true,
626
+ );
373
627
  } else {
374
- canvas.clipRect(ck.LTRBRect(x, y, x + w, y + h), ck.ClipOp.Intersect, true)
628
+ canvas.clipRect(ck.LTRBRect(x, y, x + w, y + h), ck.ClipOp.Intersect, true);
375
629
  }
376
- this.drawImageFillRect(canvas, imageFillDraw.fill, imageFillDraw.w, imageFillDraw.h, imageFillDraw.absX, imageFillDraw.absY, imageFillDraw.opacity)
377
- canvas.restore()
630
+ this.drawImageFillRect(
631
+ canvas,
632
+ imageFillDraw.fill,
633
+ imageFillDraw.w,
634
+ imageFillDraw.h,
635
+ imageFillDraw.absX,
636
+ imageFillDraw.absY,
637
+ imageFillDraw.opacity,
638
+ );
639
+ canvas.restore();
378
640
  }
379
641
 
380
- const strokePaint = this.makeStrokePaint(stroke, opacity)
642
+ const strokePaint = this.makeStrokePaint(stroke, opacity);
381
643
  if (strokePaint) {
382
644
  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)
645
+ const maxR = Math.min(w / 2, h / 2);
646
+ canvas.drawRRect(
647
+ ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)),
648
+ strokePaint,
649
+ );
385
650
  } else {
386
- canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), strokePaint)
651
+ canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), strokePaint);
387
652
  }
388
- strokePaint.delete()
653
+ strokePaint.delete();
389
654
  }
390
655
  }
391
656
 
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)
657
+ private drawEllipse(
658
+ canvas: Canvas,
659
+ node: PenNode,
660
+ x: number,
661
+ y: number,
662
+ w: number,
663
+ h: number,
664
+ opacity: number,
665
+ ) {
666
+ const ck = this.ck;
667
+ const eNode = node as EllipseNode;
668
+ const fills = eNode.fill,
669
+ stroke = eNode.stroke;
670
+ const cr = cornerRadiusValue(eNode.cornerRadius);
671
+ const fillSource = shouldUseTransparentFallbackFill(fills, stroke) ? 'transparent' : fills;
397
672
 
398
673
  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)
674
+ const arcD = buildEllipseArcPath(
675
+ w,
676
+ h,
677
+ eNode.startAngle ?? 0,
678
+ eNode.sweepAngle ?? 360,
679
+ eNode.innerRadius ?? 0,
680
+ );
681
+ const path = ck.Path.MakeFromSVGString(arcD);
401
682
  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()
683
+ path.offset(x, y);
684
+ const { paint: fillPaint } = this.makeFillPaint(fillSource, w, h, opacity, x, y);
685
+ if (cr > 0) {
686
+ const effect = ck.PathEffect.MakeCorner(cr);
687
+ if (effect) fillPaint.setPathEffect(effect);
688
+ }
689
+ canvas.drawPath(path, fillPaint);
690
+ fillPaint.delete();
691
+ const strokePaint = this.makeStrokePaint(stroke, opacity);
692
+ if (strokePaint) {
693
+ if (cr > 0) {
694
+ const effect = ck.PathEffect.MakeCorner(cr);
695
+ if (effect) strokePaint.setPathEffect(effect);
696
+ }
697
+ canvas.drawPath(path, strokePaint);
698
+ strokePaint.delete();
699
+ }
700
+ path.delete();
409
701
  }
410
- return
702
+ return;
411
703
  }
412
704
 
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() }
705
+ const { paint: fillPaint } = this.makeFillPaint(fillSource, w, h, opacity, x, y);
706
+ canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), fillPaint);
707
+ fillPaint.delete();
708
+ const strokePaint = this.makeStrokePaint(stroke, opacity);
709
+ if (strokePaint) {
710
+ canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), strokePaint);
711
+ strokePaint.delete();
712
+ }
417
713
  }
418
714
 
419
715
  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()
716
+ const ck = this.ck;
717
+ const lNode = node as LineNode;
718
+ const x2 = lNode.x2 ?? x + 100,
719
+ y2 = lNode.y2 ?? y;
720
+ const strokeColor = resolveStrokeColor(lNode.stroke) ?? DEFAULT_STROKE;
721
+ const strokeWidth = resolveStrokeWidth(lNode.stroke) || DEFAULT_STROKE_WIDTH;
722
+ const paint = new ck.Paint();
723
+ paint.setStyle(ck.PaintStyle.Stroke);
724
+ paint.setAntiAlias(true);
725
+ paint.setStrokeWidth(strokeWidth);
726
+ const c = parseColor(ck, strokeColor);
727
+ c[3] *= opacity;
728
+ paint.setColor(c);
729
+ canvas.drawLine(x, y, x2, y2, paint);
730
+ paint.delete();
429
731
  }
430
732
 
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][] = []
733
+ private drawPolygon(
734
+ canvas: Canvas,
735
+ node: PenNode,
736
+ x: number,
737
+ y: number,
738
+ w: number,
739
+ h: number,
740
+ opacity: number,
741
+ ) {
742
+ const ck = this.ck;
743
+ const pNode = node as PolygonNode;
744
+ const count = pNode.polygonCount || 6;
745
+ const fills = pNode.fill,
746
+ stroke = pNode.stroke;
747
+ const cr = cornerRadiusValue(pNode.cornerRadius);
748
+ const fillSource = shouldUseTransparentFallbackFill(fills, stroke) ? 'transparent' : fills;
749
+
750
+ const raw: [number, number][] = [];
439
751
  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)])
752
+ const angle = (i * 2 * Math.PI) / count - Math.PI / 2;
753
+ raw.push([Math.cos(angle), Math.sin(angle)]);
754
+ }
755
+ let minX = Infinity,
756
+ maxX = -Infinity,
757
+ minY = Infinity,
758
+ maxY = -Infinity;
759
+ for (const [rx, ry] of raw) {
760
+ if (rx < minX) minX = rx;
761
+ if (rx > maxX) maxX = rx;
762
+ if (ry < minY) minY = ry;
763
+ if (ry > maxY) maxY = ry;
442
764
  }
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
765
+ const rawW = maxX - minX,
766
+ rawH = maxY - minY;
446
767
 
447
- const path = new ck.Path()
768
+ const path = new ck.Path();
448
769
  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)
770
+ const px = x + ((raw[i][0] - minX) / rawW) * w,
771
+ py = y + ((raw[i][1] - minY) / rawH) * h;
772
+ if (i === 0) path.moveTo(px, py);
773
+ else path.lineTo(px, py);
451
774
  }
452
- path.close()
775
+ path.close();
453
776
 
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()
777
+ const { paint: fillPaint } = this.makeFillPaint(fillSource, w, h, opacity, x, y);
778
+ if (cr > 0) {
779
+ const effect = ck.PathEffect.MakeCorner(cr);
780
+ if (effect) fillPaint.setPathEffect(effect);
781
+ }
782
+ canvas.drawPath(path, fillPaint);
783
+ fillPaint.delete();
784
+ const strokePaint = this.makeStrokePaint(stroke, opacity);
785
+ if (strokePaint) {
786
+ if (cr > 0) {
787
+ const effect = ck.PathEffect.MakeCorner(cr);
788
+ if (effect) strokePaint.setPathEffect(effect);
789
+ }
790
+ canvas.drawPath(path, strokePaint);
791
+ strokePaint.delete();
792
+ }
793
+ path.delete();
460
794
  }
461
795
 
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)
796
+ private drawPath(
797
+ canvas: Canvas,
798
+ node: PenNode,
799
+ x: number,
800
+ y: number,
801
+ w: number,
802
+ h: number,
803
+ opacity: number,
804
+ ) {
805
+ const ck = this.ck;
806
+ const pNode = node as PathNode;
807
+ const rawD = typeof pNode.d === 'string' && pNode.d.trim().length > 0 ? pNode.d : 'M0 0 L0 0';
808
+ const fills = pNode.fill,
809
+ stroke = pNode.stroke;
810
+
811
+ let path: ReturnType<typeof ck.Path.MakeFromSVGString> = null;
812
+ if (hasInvalidNumbers(rawD)) {
813
+ path = tryManualPathParse(ck, rawD);
814
+ } else {
815
+ const d = sanitizeSvgPath(rawD);
816
+ path = ck.Path.MakeFromSVGString(d);
817
+ if (!path && d !== rawD) path = ck.Path.MakeFromSVGString(rawD);
818
+ if (!path) path = tryManualPathParse(ck, rawD);
475
819
  }
476
820
  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
821
+ if (w > 0 && h > 0) {
822
+ const { paint: fp } = this.makeFillPaint(fills, w, h, opacity, x, y);
823
+ canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fp);
824
+ fp.delete();
825
+ }
826
+ return;
479
827
  }
480
828
 
481
- const bounds = path.getBounds()
482
- const nativeW = bounds[2] - bounds[0], nativeH = bounds[3] - bounds[1]
829
+ const parsedAnchors = pNode.anchors
830
+ ? {
831
+ anchors: pNode.anchors,
832
+ closed: pNode.closed ?? /[Zz]\s*$/.test(rawD),
833
+ }
834
+ : pathDataToAnchors(rawD);
835
+ const geometryBounds = parsedAnchors
836
+ ? getPathBoundsFromAnchors(parsedAnchors.anchors, parsedAnchors.closed)
837
+ : null;
838
+ const bounds = geometryBounds
839
+ ? Float32Array.of(
840
+ geometryBounds.x,
841
+ geometryBounds.y,
842
+ geometryBounds.x + geometryBounds.width,
843
+ geometryBounds.y + geometryBounds.height,
844
+ )
845
+ : path.getBounds();
846
+ const nativeW = bounds[2] - bounds[0],
847
+ nativeH = bounds[3] - bounds[1];
483
848
  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)))
849
+ const isIcon = !!pNode.iconId;
850
+ const sx = isIcon ? Math.min(w / nativeW, h / nativeH) : w / nativeW;
851
+ const sy = isIcon ? sx : h / nativeH;
852
+ path.transform(
853
+ ck.Matrix.multiply(
854
+ ck.Matrix.translated(x - bounds[0] * sx, y - bounds[1] * sy),
855
+ ck.Matrix.scaled(sx, sy),
856
+ ),
857
+ );
488
858
  } 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) }
859
+ const sx = nativeW > 0.01 && w > 0 ? w / nativeW : 1,
860
+ sy = nativeH > 0.01 && h > 0 ? h / nativeH : 1;
861
+ path.transform(
862
+ ck.Matrix.multiply(
863
+ ck.Matrix.translated(x - bounds[0] * sx, y - bounds[1] * sy),
864
+ ck.Matrix.scaled(sx, sy),
865
+ ),
866
+ );
867
+ } else {
868
+ path.offset(x, y);
869
+ }
492
870
 
493
- const hasExplicitFill = fills && fills.length > 0
494
- const strokeColor = resolveStrokeColor(stroke), strokeWidth = resolveStrokeWidth(stroke)
495
- const hasVisibleStroke = strokeWidth > 0 && !!strokeColor
871
+ const hasExplicitFill = fills && fills.length > 0;
872
+ const strokeColor = resolveStrokeColor(stroke),
873
+ strokeWidth = resolveStrokeWidth(stroke);
874
+ const hasVisibleStroke = strokeWidth > 0 && !!strokeColor;
496
875
 
497
876
  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()
877
+ const { paint: fillPaint } = this.makeFillPaint(
878
+ hasExplicitFill ? fills : undefined,
879
+ w,
880
+ h,
881
+ opacity,
882
+ x,
883
+ y,
884
+ );
885
+ const closeCount = (rawD.match(/Z/gi) || []).length;
886
+ path.setFillType(closeCount > 1 ? ck.FillType.EvenOdd : ck.FillType.Winding);
887
+ canvas.drawPath(path, fillPaint);
888
+ fillPaint.delete();
889
+ }
890
+ if (hasVisibleStroke) {
891
+ const sp = this.makeStrokePaint(stroke, opacity);
892
+ if (sp) {
893
+ canvas.drawPath(path, sp);
894
+ sp.delete();
895
+ }
502
896
  }
503
- if (hasVisibleStroke) { const sp = this.makeStrokePaint(stroke, opacity); if (sp) { canvas.drawPath(path, sp); sp.delete() } }
504
- path.delete()
897
+ path.delete();
505
898
  }
506
899
 
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]
900
+ private drawIconFont(
901
+ canvas: Canvas,
902
+ node: PenNode,
903
+ x: number,
904
+ y: number,
905
+ w: number,
906
+ h: number,
907
+ opacity: number,
908
+ ) {
909
+ const ck = this.ck;
910
+ const iNode = node as IconFontNode;
911
+ const iconName = iNode.iconFontName ?? iNode.name ?? '';
912
+ const iconMatch = this.iconLookup?.(iconName) ?? null;
913
+ const iconD = iconMatch?.d ?? FALLBACK_ICON_D;
914
+ const iconStyle = iconMatch?.style ?? 'stroke';
915
+
916
+ const rawFill = iNode.fill;
917
+ const iconFillColor =
918
+ typeof rawFill === 'string'
919
+ ? rawFill
920
+ : Array.isArray(iNode.fill) && iNode.fill.length > 0
921
+ ? resolveFillColor(iNode.fill)
922
+ : '#64748B';
923
+
924
+ const sanitizedIconD = sanitizeSvgPath(iconD);
925
+ let path = ck.Path.MakeFromSVGString(sanitizedIconD);
926
+ if (!path && sanitizedIconD !== iconD) path = ck.Path.MakeFromSVGString(iconD);
927
+ if (!path) path = tryManualPathParse(ck, iconD);
928
+ if (!path) return;
929
+
930
+ const bounds = path.getBounds();
931
+ const nativeW = bounds[2] - bounds[0],
932
+ nativeH = bounds[3] - bounds[1];
527
933
  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) }
934
+ const s = Math.min(w / nativeW, h / nativeH);
935
+ path.transform(
936
+ ck.Matrix.multiply(
937
+ ck.Matrix.translated(x - bounds[0] * s, y - bounds[1] * s),
938
+ ck.Matrix.scaled(s, s),
939
+ ),
940
+ );
941
+ } else {
942
+ path.offset(x, y);
943
+ }
531
944
 
532
- const paint = new ck.Paint()
533
- paint.setAntiAlias(true)
534
- const c = parseColor(ck, iconFillColor); c[3] *= opacity; paint.setColor(c)
945
+ const paint = new ck.Paint();
946
+ paint.setAntiAlias(true);
947
+ const c = parseColor(ck, iconFillColor);
948
+ c[3] *= opacity;
949
+ paint.setColor(c);
535
950
  if (iconStyle === 'stroke') {
536
- paint.setStyle(ck.PaintStyle.Stroke); paint.setStrokeWidth(2)
537
- paint.setStrokeCap(ck.StrokeCap.Round); paint.setStrokeJoin(ck.StrokeJoin.Round)
951
+ paint.setStyle(ck.PaintStyle.Stroke);
952
+ paint.setStrokeWidth(2);
953
+ paint.setStrokeCap(ck.StrokeCap.Round);
954
+ paint.setStrokeJoin(ck.StrokeJoin.Round);
538
955
  } else {
539
- paint.setStyle(ck.PaintStyle.Fill); path.setFillType(ck.FillType.EvenOdd)
956
+ paint.setStyle(ck.PaintStyle.Fill);
957
+ path.setFillType(ck.FillType.EvenOdd);
540
958
  }
541
- canvas.drawPath(path, paint); paint.delete(); path.delete()
959
+ canvas.drawPath(path, paint);
960
+ paint.delete();
961
+ path.delete();
542
962
  }
543
963
 
544
964
  // ---------------------------------------------------------------------------
545
965
  // Image drawing
546
966
  // ---------------------------------------------------------------------------
547
967
 
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 }
968
+ private drawImage(
969
+ canvas: Canvas,
970
+ node: PenNode,
971
+ x: number,
972
+ y: number,
973
+ w: number,
974
+ h: number,
975
+ opacity: number,
976
+ ) {
977
+ const ck = this.ck;
978
+ const iNode = node as ImageNode;
979
+ const src: string | undefined = iNode.src;
980
+ const cr = cornerRadiusValue(iNode.cornerRadius);
981
+
982
+ if (!src) {
983
+ this.drawImageFallback(canvas, x, y, w, h, cr, opacity, false);
984
+ return;
985
+ }
559
986
 
560
- const imgW = cached.width(), imgH = cached.height()
987
+ const cached = this.imageLoader.get(src);
988
+ if (cached === undefined) {
989
+ this.imageLoader.request(src);
990
+ this.drawImageFallback(canvas, x, y, w, h, cr, opacity, false);
991
+ return;
992
+ }
993
+ if (!cached) {
994
+ const status = this.imageLoader.getStatus(src);
995
+ this.drawImageFallback(
996
+ canvas,
997
+ x,
998
+ y,
999
+ w,
1000
+ h,
1001
+ cr,
1002
+ opacity,
1003
+ status?.state === 'missing' || status?.state === 'error',
1004
+ );
1005
+ return;
1006
+ }
561
1007
 
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) }
1008
+ const imgW = cached.width(),
1009
+ imgH = cached.height();
1010
+
1011
+ if (cr > 0) {
1012
+ canvas.save();
1013
+ const maxR = Math.min(cr, w / 2, h / 2);
1014
+ canvas.clipRRect(
1015
+ ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), maxR, maxR),
1016
+ ck.ClipOp.Intersect,
1017
+ true,
1018
+ );
1019
+ } else {
1020
+ canvas.save();
1021
+ canvas.clipRect(ck.LTRBRect(x, y, x + w, y + h), ck.ClipOp.Intersect, true);
1022
+ }
564
1023
 
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)
1024
+ const paint = new ck.Paint();
1025
+ paint.setAntiAlias(true);
1026
+ if (opacity < 1) paint.setAlphaf(opacity);
1027
+ const adjFilter = this.buildImageAdjustmentFilter(iNode);
1028
+ if (adjFilter) paint.setColorFilter(adjFilter);
570
1029
 
571
- const fit = iNode.objectFit ?? 'fill'
1030
+ const fit = iNode.objectFit ?? 'fill';
572
1031
  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) }
1032
+ const tileMatrix = Float32Array.of(1, 0, -x, 0, 1, -y, 0, 0, 1);
1033
+ const shader = cached.makeShaderOptions(
1034
+ ck.TileMode.Repeat,
1035
+ ck.TileMode.Repeat,
1036
+ ck.FilterMode.Linear,
1037
+ ck.MipmapMode.None,
1038
+ tileMatrix,
1039
+ );
1040
+ if (shader) {
1041
+ paint.setShader(shader);
1042
+ canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), paint);
1043
+ }
576
1044
  } 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)
1045
+ const bgPaint = new ck.Paint();
1046
+ bgPaint.setStyle(ck.PaintStyle.Fill);
1047
+ bgPaint.setColor(parseColor(ck, '#f3f4f6'));
1048
+ if (opacity < 1) bgPaint.setAlphaf(opacity * 0.3);
1049
+ else bgPaint.setAlphaf(0.3);
1050
+ canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), bgPaint);
1051
+ bgPaint.delete();
1052
+ const scale = Math.min(w / imgW, h / imgH),
1053
+ dw = imgW * scale,
1054
+ dh = imgH * scale;
1055
+ const dx = x + (w - dw) / 2,
1056
+ dy = y + (h - dh) / 2;
1057
+ canvas.drawImageRect(
1058
+ cached,
1059
+ ck.LTRBRect(0, 0, imgW, imgH),
1060
+ ck.LTRBRect(dx, dy, dx + dw, dy + dh),
1061
+ paint,
1062
+ );
583
1063
  } 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)
1064
+ const scale = Math.max(w / imgW, h / imgH),
1065
+ dw = imgW * scale,
1066
+ dh = imgH * scale;
1067
+ const dx = x + (w - dw) / 2,
1068
+ dy = y + (h - dh) / 2;
1069
+ canvas.drawImageRect(
1070
+ cached,
1071
+ ck.LTRBRect(0, 0, imgW, imgH),
1072
+ ck.LTRBRect(dx, dy, dx + dw, dy + dh),
1073
+ paint,
1074
+ );
587
1075
  }
588
- paint.delete(); canvas.restore()
1076
+ paint.delete();
1077
+ canvas.restore();
589
1078
  }
590
1079
 
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()
1080
+ private drawImageFallback(
1081
+ canvas: Canvas,
1082
+ x: number,
1083
+ y: number,
1084
+ w: number,
1085
+ h: number,
1086
+ cr: number,
1087
+ opacity: number,
1088
+ emphasizeMissing: boolean,
1089
+ ) {
1090
+ const ck = this.ck;
1091
+ const rect = ck.LTRBRect(x, y, x + w, y + h);
1092
+ const maxR = Math.min(cr, w / 2, h / 2);
1093
+
1094
+ const fillPaint = new ck.Paint();
1095
+ fillPaint.setStyle(ck.PaintStyle.Fill);
1096
+ fillPaint.setAntiAlias(true);
1097
+ const fillColor = parseColor(ck, emphasizeMissing ? '#f8d7da' : '#e5e7eb');
1098
+ fillColor[3] *= opacity;
1099
+ fillPaint.setColor(fillColor);
1100
+
1101
+ if (cr > 0) {
1102
+ canvas.drawRRect(ck.RRectXY(rect, maxR, maxR), fillPaint);
1103
+ } else {
1104
+ canvas.drawRect(rect, fillPaint);
1105
+ }
1106
+ fillPaint.delete();
1107
+
1108
+ const strokePaint = new ck.Paint();
1109
+ strokePaint.setStyle(ck.PaintStyle.Stroke);
1110
+ strokePaint.setAntiAlias(true);
1111
+ strokePaint.setStrokeWidth(Math.max(1, Math.min(w, h) * 0.02));
1112
+ const strokeColor = parseColor(ck, emphasizeMissing ? '#c2410c' : '#94a3b8');
1113
+ strokeColor[3] *= opacity;
1114
+ strokePaint.setColor(strokeColor);
1115
+ const dash = ck.PathEffect.MakeDash([8, 6], 0);
1116
+ if (dash) strokePaint.setPathEffect(dash);
1117
+
1118
+ if (cr > 0) {
1119
+ canvas.drawRRect(ck.RRectXY(rect, maxR, maxR), strokePaint);
1120
+ } else {
1121
+ canvas.drawRect(rect, strokePaint);
1122
+ }
1123
+
1124
+ const iconPaint = new ck.Paint();
1125
+ iconPaint.setStyle(ck.PaintStyle.Stroke);
1126
+ iconPaint.setAntiAlias(true);
1127
+ iconPaint.setStrokeWidth(Math.max(1.25, Math.min(w, h) * 0.03));
1128
+ iconPaint.setStrokeCap(ck.StrokeCap.Round);
1129
+ iconPaint.setStrokeJoin(ck.StrokeJoin.Round);
1130
+ iconPaint.setColor(strokeColor);
1131
+
1132
+ const inset = Math.max(10, Math.min(w, h) * 0.18);
1133
+ const iconLeft = x + inset;
1134
+ const iconTop = y + inset;
1135
+ const iconRight = x + w - inset;
1136
+ const iconBottom = y + h - inset;
1137
+ const iconPath = new ck.Path();
1138
+ iconPath.moveTo(iconLeft, iconBottom);
1139
+ iconPath.lineTo(x + w * 0.42, y + h * 0.58);
1140
+ iconPath.lineTo(x + w * 0.58, y + h * 0.72);
1141
+ iconPath.lineTo(iconRight, y + h * 0.42);
1142
+ iconPath.moveTo(iconLeft, iconTop);
1143
+ iconPath.lineTo(iconRight, iconTop);
1144
+ iconPath.lineTo(iconRight, iconBottom);
1145
+ iconPath.lineTo(iconLeft, iconBottom);
1146
+ iconPath.close();
1147
+ canvas.drawPath(iconPath, iconPaint);
1148
+ iconPath.delete();
1149
+
1150
+ const dotPaint = new ck.Paint();
1151
+ dotPaint.setStyle(ck.PaintStyle.Fill);
1152
+ dotPaint.setAntiAlias(true);
1153
+ dotPaint.setColor(strokeColor);
1154
+ canvas.drawCircle(x + w * 0.35, y + h * 0.38, Math.max(3, Math.min(w, h) * 0.05), dotPaint);
1155
+
1156
+ if (emphasizeMissing) {
1157
+ canvas.drawLine(
1158
+ x + inset * 0.9,
1159
+ y + h - inset * 0.9,
1160
+ x + w - inset * 0.9,
1161
+ y + inset * 0.9,
1162
+ iconPaint,
1163
+ );
1164
+ }
1165
+
1166
+ strokePaint.delete();
1167
+ iconPaint.delete();
1168
+ dotPaint.delete();
598
1169
  }
599
1170
  }