@zseven-w/pen-renderer 0.5.2 → 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,9 +1,9 @@
1
- import type { CanvasKit, Canvas, Image as SkImage, Paragraph } from 'canvaskit-wasm'
2
- import type { PenNode, TextNode } from '@zseven-w/pen-types'
3
- import type { PenEffect, ShadowEffect } from '@zseven-w/pen-types'
4
- import { defaultLineHeight, cssFontFamily } from '@zseven-w/pen-core'
5
- import { parseColor, resolveFillColor, wrapLine } from './paint-utils.js'
6
- import { SkiaFontManager, type FontManagerOptions } from './font-manager.js'
1
+ import type { CanvasKit, Canvas, Image as SkImage, Paragraph } from 'canvaskit-wasm';
2
+ import type { PenNode, TextNode } from '@zseven-w/pen-types';
3
+ import type { PenEffect, ShadowEffect } from '@zseven-w/pen-types';
4
+ import { defaultLineHeight, cssFontFamily } from '@zseven-w/pen-core';
5
+ import { parseColor, resolveFillColor, wrapLine } from './paint-utils.js';
6
+ import { SkiaFontManager, type FontManagerOptions } from './font-manager.js';
7
7
 
8
8
  /**
9
9
  * Text rendering sub-system for SkiaNodeRenderer.
@@ -11,131 +11,146 @@ import { SkiaFontManager, type FontManagerOptions } from './font-manager.js'
11
11
  * with caching for performance.
12
12
  */
13
13
  export class SkiaTextRenderer {
14
- private ck: CanvasKit
14
+ private ck: CanvasKit;
15
15
 
16
16
  // Text rasterization cache (Canvas 2D -> CanvasKit Image)
17
17
  // FIFO eviction via Map insertion order; bytes tracked separately against TEXT_CACHE_BYTE_LIMIT.
18
- private textCache = new Map<string, SkImage | null>()
19
- private textCacheBytes = 0
18
+ private textCache = new Map<string, SkImage | null>();
19
+ private textCacheBytes = 0;
20
20
  // 256 MB — each bitmap entry is ~cw*ch*4 bytes (RGBA pixels)
21
- private static TEXT_CACHE_BYTE_LIMIT = 256 * 1024 * 1024
21
+ private static TEXT_CACHE_BYTE_LIMIT = 256 * 1024 * 1024;
22
22
 
23
23
  // Paragraph cache for vector text (keyed by content+style)
24
24
  // FIFO eviction via Map insertion order; bytes estimated from content length against PARA_CACHE_BYTE_LIMIT.
25
- private paraCache = new Map<string, Paragraph | null>()
26
- private paraCacheBytes = 0
25
+ private paraCache = new Map<string, Paragraph | null>();
26
+ private paraCacheBytes = 0;
27
27
  // 64 MB — each entry is estimated as content.length*64+4096 bytes (WASM heap approximation)
28
- private static PARA_CACHE_BYTE_LIMIT = 64 * 1024 * 1024
28
+ private static PARA_CACHE_BYTE_LIMIT = 64 * 1024 * 1024;
29
29
 
30
30
  // Pre-rasterized paragraph image cache (SkImage, same key as paraCache, zoom-independent)
31
31
  // Allows drawImageRect instead of drawParagraph on every frame — avoids per-frame glyph rasterization.
32
- private paraImageCache = new Map<string, SkImage | null>()
33
- private paraImageCacheBytes = 0
32
+ private paraImageCache = new Map<string, SkImage | null>();
33
+ private paraImageCacheBytes = 0;
34
34
  // 128 MB — each entry is sw*sh*4 bytes (RGBA pixels at up to 2x DPR scale)
35
- private static PARA_IMAGE_CACHE_BYTE_LIMIT = 128 * 1024 * 1024
35
+ private static PARA_IMAGE_CACHE_BYTE_LIMIT = 128 * 1024 * 1024;
36
36
 
37
37
  private static estimateParaBytes(content: string): number {
38
- return content.length * 64 + 4096
38
+ return content.length * 64 + 4096;
39
39
  }
40
40
 
41
41
  // Current viewport zoom (set by engine before each render frame)
42
- zoom = 1
42
+ zoom = 1;
43
43
 
44
44
  // Device pixel ratio override
45
- devicePixelRatio: number | undefined
45
+ devicePixelRatio: number | undefined;
46
46
 
47
47
  private get _dpr(): number {
48
- return this.devicePixelRatio ?? (typeof window !== 'undefined' ? window.devicePixelRatio : 1) ?? 1
48
+ return (
49
+ this.devicePixelRatio ?? (typeof window !== 'undefined' ? window.devicePixelRatio : 1) ?? 1
50
+ );
49
51
  }
50
52
 
51
53
  // Font manager for vector text rendering
52
- fontManager: SkiaFontManager
54
+ fontManager: SkiaFontManager;
53
55
 
54
56
  constructor(ck: CanvasKit, fontOptions?: FontManagerOptions) {
55
- this.ck = ck
56
- this.fontManager = new SkiaFontManager(ck, fontOptions)
57
+ this.ck = ck;
58
+ this.fontManager = new SkiaFontManager(ck, fontOptions);
57
59
  }
58
60
 
59
61
  clearTextCache() {
60
62
  for (const img of this.textCache.values()) {
61
- img?.delete()
63
+ img?.delete();
62
64
  }
63
- this.textCache.clear()
64
- this.textCacheBytes = 0
65
+ this.textCache.clear();
66
+ this.textCacheBytes = 0;
65
67
  }
66
68
 
67
69
  clearParaCache() {
68
70
  for (const p of this.paraCache.values()) {
69
- p?.delete()
71
+ p?.delete();
70
72
  }
71
- this.paraCache.clear()
72
- this.paraCacheBytes = 0
73
+ this.paraCache.clear();
74
+ this.paraCacheBytes = 0;
73
75
  for (const img of this.paraImageCache.values()) {
74
- img?.delete()
76
+ img?.delete();
75
77
  }
76
- this.paraImageCache.clear()
77
- this.paraImageCacheBytes = 0
78
+ this.paraImageCache.clear();
79
+ this.paraImageCacheBytes = 0;
78
80
  }
79
81
 
80
82
  // Evict oldest entries (Map head = first inserted) until there is room for `incoming` bytes.
81
83
  private evictParaCache(incoming: number) {
82
- while (this.paraCacheBytes + incoming > SkiaTextRenderer.PARA_CACHE_BYTE_LIMIT && this.paraCache.size > 0) {
83
- const [key, para] = this.paraCache.entries().next().value!
84
- para?.delete()
85
- this.paraCache.delete(key)
86
- this.paraCacheBytes -= SkiaTextRenderer.estimateParaBytes(key.split('|')[1] ?? '')
84
+ while (
85
+ this.paraCacheBytes + incoming > SkiaTextRenderer.PARA_CACHE_BYTE_LIMIT &&
86
+ this.paraCache.size > 0
87
+ ) {
88
+ const [key, para] = this.paraCache.entries().next().value!;
89
+ para?.delete();
90
+ this.paraCache.delete(key);
91
+ this.paraCacheBytes -= SkiaTextRenderer.estimateParaBytes(key.split('|')[1] ?? '');
87
92
  }
88
93
  }
89
94
 
90
95
  private evictParaImageCache(incoming: number) {
91
- while (this.paraImageCacheBytes + incoming > SkiaTextRenderer.PARA_IMAGE_CACHE_BYTE_LIMIT && this.paraImageCache.size > 0) {
92
- const [key, img] = this.paraImageCache.entries().next().value!
96
+ while (
97
+ this.paraImageCacheBytes + incoming > SkiaTextRenderer.PARA_IMAGE_CACHE_BYTE_LIMIT &&
98
+ this.paraImageCache.size > 0
99
+ ) {
100
+ const [key, img] = this.paraImageCache.entries().next().value!;
93
101
  if (img) {
94
- this.paraImageCacheBytes -= img.width() * img.height() * 4
95
- img.delete()
102
+ this.paraImageCacheBytes -= img.width() * img.height() * 4;
103
+ img.delete();
96
104
  }
97
- this.paraImageCache.delete(key)
105
+ this.paraImageCache.delete(key);
98
106
  }
99
107
  }
100
108
 
101
109
  private evictTextCache(incoming: number) {
102
- while (this.textCacheBytes + incoming > SkiaTextRenderer.TEXT_CACHE_BYTE_LIMIT && this.textCache.size > 0) {
103
- const [key, img] = this.textCache.entries().next().value!
110
+ while (
111
+ this.textCacheBytes + incoming > SkiaTextRenderer.TEXT_CACHE_BYTE_LIMIT &&
112
+ this.textCache.size > 0
113
+ ) {
114
+ const [key, img] = this.textCache.entries().next().value!;
104
115
  if (img) {
105
- this.textCacheBytes -= img.width() * img.height() * 4
106
- img.delete()
116
+ this.textCacheBytes -= img.width() * img.height() * 4;
117
+ img.delete();
107
118
  }
108
- this.textCache.delete(key)
119
+ this.textCache.delete(key);
109
120
  }
110
121
  }
111
122
 
112
123
  dispose() {
113
- this.clearTextCache()
114
- this.clearParaCache()
115
- this.fontManager.dispose()
124
+ this.clearTextCache();
125
+ this.clearParaCache();
126
+ this.fontManager.dispose();
116
127
  }
117
128
 
118
129
  /**
119
130
  * Main text drawing entry — tries vector, falls back to bitmap.
120
131
  */
121
132
  drawText(
122
- canvas: Canvas, node: PenNode,
123
- x: number, y: number, w: number, h: number,
133
+ canvas: Canvas,
134
+ node: PenNode,
135
+ x: number,
136
+ y: number,
137
+ w: number,
138
+ h: number,
124
139
  opacity: number,
125
140
  effects?: PenEffect[],
126
141
  ) {
127
142
  // Draw text shadow as blurred copy of the text glyphs (not a rectangle)
128
- const shadow = effects?.find((e): e is ShadowEffect => e.type === 'shadow')
143
+ const shadow = effects?.find((e): e is ShadowEffect => e.type === 'shadow');
129
144
  if (shadow) {
130
- this.drawTextShadow(canvas, node, x, y, w, h, opacity, shadow)
145
+ this.drawTextShadow(canvas, node, x, y, w, h, opacity, shadow);
131
146
  }
132
147
 
133
148
  // Try vector text first (true Skia Paragraph API)
134
- const vectorOk = this.drawTextVector(canvas, node, x, y, w, h, opacity)
135
- if (vectorOk) return
149
+ const vectorOk = this.drawTextVector(canvas, node, x, y, w, h, opacity);
150
+ if (vectorOk) return;
136
151
 
137
152
  // Fallback to bitmap text rendering
138
- this.drawTextBitmap(canvas, node, x, y, w, h, opacity)
153
+ this.drawTextBitmap(canvas, node, x, y, w, h, opacity);
139
154
  }
140
155
 
141
156
  /**
@@ -143,73 +158,79 @@ export class SkiaTextRenderer {
143
158
  * Returns true if rendered, false if font not available (caller should fallback).
144
159
  */
145
160
  drawTextVector(
146
- canvas: Canvas, node: PenNode,
147
- x: number, y: number, w: number, _h: number,
161
+ canvas: Canvas,
162
+ node: PenNode,
163
+ x: number,
164
+ y: number,
165
+ w: number,
166
+ _h: number,
148
167
  opacity: number,
149
168
  ): boolean {
150
- const ck = this.ck
151
- const tNode = node as TextNode
152
- const content = typeof tNode.content === 'string'
153
- ? tNode.content
154
- : Array.isArray(tNode.content)
155
- ? tNode.content.map((s) => s.text ?? '').join('')
156
- : ''
157
- if (!content) return true
158
-
159
- const fontSize = tNode.fontSize ?? 16
160
- const fillColor = resolveFillColor(tNode.fill)
161
- const fontWeight = tNode.fontWeight ?? '400'
162
- const fontFamily = tNode.fontFamily ?? 'Inter'
163
- const textAlign: string = tNode.textAlign ?? 'left'
164
- const lineHeightMul = tNode.lineHeight ?? defaultLineHeight(fontSize)
165
- const textGrowth = tNode.textGrowth
166
- const letterSpacing = tNode.letterSpacing ?? 0
167
-
168
- const primaryFamily = fontFamily.split(',')[0].trim().replace(/['"]/g, '')
169
+ const ck = this.ck;
170
+ const tNode = node as TextNode;
171
+ const content =
172
+ typeof tNode.content === 'string'
173
+ ? tNode.content
174
+ : Array.isArray(tNode.content)
175
+ ? tNode.content.map((s) => s.text ?? '').join('')
176
+ : (((tNode as unknown as Record<string, unknown>).text as string) ?? '');
177
+ if (!content) return true;
178
+
179
+ const fontSize = tNode.fontSize ?? 16;
180
+ const fillColor = resolveFillColor(tNode.fill);
181
+ const fontWeight = tNode.fontWeight ?? '400';
182
+ const fontFamily = tNode.fontFamily ?? 'Inter';
183
+ const textAlign: string = tNode.textAlign ?? 'left';
184
+ const lineHeightMul = tNode.lineHeight ?? defaultLineHeight(fontSize);
185
+ const textGrowth = tNode.textGrowth;
186
+ const letterSpacing = tNode.letterSpacing ?? 0;
187
+
188
+ const primaryFamily = fontFamily.split(',')[0].trim().replace(/['"]/g, '');
169
189
  if (!this.fontManager.isFontReady(primaryFamily)) {
170
190
  if (this.fontManager.isSystemFont(primaryFamily)) {
171
- return false
191
+ return false;
172
192
  }
173
193
  this.fontManager.ensureFont(primaryFamily).then((ok) => {
174
194
  if (ok) {
175
- this.clearParaCache()
176
- ;(this as any)._onFontLoaded?.()
195
+ this.clearParaCache();
196
+ (this as any)._onFontLoaded?.();
177
197
  }
178
- })
198
+ });
179
199
  if (!this.fontManager.hasAnyFallback(primaryFamily)) {
180
- return false
200
+ return false;
181
201
  }
182
202
  }
183
203
 
184
- const isFixedWidth = textGrowth === 'fixed-width' || textGrowth === 'fixed-width-height'
185
- const fwTolerance = isFixedWidth ? Math.min(Math.ceil(w * 0.05), Math.ceil(fontSize * 0.5)) : 0
186
- const layoutWidth = isFixedWidth && w > 0 ? w + fwTolerance : 1e6
187
- const effectiveAlign = isFixedWidth ? textAlign : 'left'
204
+ const isFixedWidth = textGrowth === 'fixed-width' || textGrowth === 'fixed-width-height';
205
+ const fwTolerance = isFixedWidth ? Math.min(Math.ceil(w * 0.05), Math.ceil(fontSize * 0.5)) : 0;
206
+ const layoutWidth = isFixedWidth && w > 0 ? w + fwTolerance : 1e6;
207
+ const effectiveAlign = isFixedWidth ? textAlign : 'left';
188
208
 
189
- const cacheKey = `p|${content}|${fontSize}|${fillColor}|${fontWeight}|${fontFamily}|${effectiveAlign}|${Math.round(layoutWidth)}|${letterSpacing}|${lineHeightMul}`
209
+ const cacheKey = `p|${content}|${fontSize}|${fillColor}|${fontWeight}|${fontFamily}|${effectiveAlign}|${Math.round(layoutWidth)}|${letterSpacing}|${lineHeightMul}`;
190
210
 
191
- let para = this.paraCache.get(cacheKey)
211
+ let para = this.paraCache.get(cacheKey);
192
212
  if (para === undefined) {
193
- const color = parseColor(ck, fillColor)
194
-
195
- let ckAlign = ck.TextAlign.Left
196
- if (effectiveAlign === 'center') ckAlign = ck.TextAlign.Center
197
- else if (effectiveAlign === 'right') ckAlign = ck.TextAlign.Right
198
- else if (effectiveAlign === 'justify') ckAlign = ck.TextAlign.Justify
199
-
200
- const weightNum = typeof fontWeight === 'number' ? fontWeight : parseInt(fontWeight as string, 10) || 400
201
- let ckWeight = ck.FontWeight.Normal
202
- if (weightNum <= 100) ckWeight = ck.FontWeight.Thin
203
- else if (weightNum <= 200) ckWeight = ck.FontWeight.ExtraLight
204
- else if (weightNum <= 300) ckWeight = ck.FontWeight.Light
205
- else if (weightNum <= 400) ckWeight = ck.FontWeight.Normal
206
- else if (weightNum <= 500) ckWeight = ck.FontWeight.Medium
207
- else if (weightNum <= 600) ckWeight = ck.FontWeight.SemiBold
208
- else if (weightNum <= 700) ckWeight = ck.FontWeight.Bold
209
- else if (weightNum <= 800) ckWeight = ck.FontWeight.ExtraBold
210
- else ckWeight = ck.FontWeight.Black
211
-
212
- const fallbackFamilies = this.fontManager.getFallbackChain(primaryFamily)
213
+ const color = parseColor(ck, fillColor);
214
+
215
+ let ckAlign = ck.TextAlign.Left;
216
+ if (effectiveAlign === 'center') ckAlign = ck.TextAlign.Center;
217
+ else if (effectiveAlign === 'right') ckAlign = ck.TextAlign.Right;
218
+ else if (effectiveAlign === 'justify') ckAlign = ck.TextAlign.Justify;
219
+
220
+ const weightNum =
221
+ typeof fontWeight === 'number' ? fontWeight : parseInt(fontWeight as string, 10) || 400;
222
+ let ckWeight = ck.FontWeight.Normal;
223
+ if (weightNum <= 100) ckWeight = ck.FontWeight.Thin;
224
+ else if (weightNum <= 200) ckWeight = ck.FontWeight.ExtraLight;
225
+ else if (weightNum <= 300) ckWeight = ck.FontWeight.Light;
226
+ else if (weightNum <= 400) ckWeight = ck.FontWeight.Normal;
227
+ else if (weightNum <= 500) ckWeight = ck.FontWeight.Medium;
228
+ else if (weightNum <= 600) ckWeight = ck.FontWeight.SemiBold;
229
+ else if (weightNum <= 700) ckWeight = ck.FontWeight.Bold;
230
+ else if (weightNum <= 800) ckWeight = ck.FontWeight.ExtraBold;
231
+ else ckWeight = ck.FontWeight.Black;
232
+
233
+ const fallbackFamilies = this.fontManager.getFallbackChain(primaryFamily);
213
234
 
214
235
  const paraStyle = new ck.ParagraphStyle({
215
236
  textAlign: ckAlign,
@@ -222,310 +243,352 @@ export class SkiaTextRenderer {
222
243
  heightMultiplier: lineHeightMul,
223
244
  halfLeading: true,
224
245
  },
225
- })
246
+ });
226
247
 
227
248
  try {
228
249
  const builder = ck.ParagraphBuilder.MakeFromFontProvider(
229
250
  paraStyle,
230
251
  this.fontManager.getProvider(),
231
- )
252
+ );
232
253
 
233
254
  // Handle styled segments
234
- if (Array.isArray(tNode.content) && tNode.content.some(s => s.fontFamily || s.fontSize || s.fontWeight || s.fill)) {
255
+ if (
256
+ Array.isArray(tNode.content) &&
257
+ tNode.content.some((s) => s.fontFamily || s.fontSize || s.fontWeight || s.fill)
258
+ ) {
235
259
  for (const seg of tNode.content) {
236
260
  if (seg.fontFamily || seg.fontSize || seg.fontWeight || seg.fill) {
237
- const segColor = seg.fill ? parseColor(ck, seg.fill) : color
261
+ const segColor = seg.fill ? parseColor(ck, seg.fill) : color;
238
262
  const segWeight = seg.fontWeight
239
- ? (typeof seg.fontWeight === 'number' ? seg.fontWeight : parseInt(seg.fontWeight as string, 10) || weightNum)
240
- : weightNum
241
- const segPrimary = seg.fontFamily?.split(',')[0].trim().replace(/['"]/g, '') ?? primaryFamily
242
- builder.pushStyle(new ck.TextStyle({
243
- color: segColor,
244
- fontSize: seg.fontSize ?? fontSize,
245
- fontFamilies: this.fontManager.getFallbackChain(segPrimary),
246
- fontStyle: { weight: segWeight as any },
247
- letterSpacing,
248
- heightMultiplier: lineHeightMul,
249
- halfLeading: true,
250
- }))
251
- builder.addText(seg.text ?? '')
252
- builder.pop()
263
+ ? typeof seg.fontWeight === 'number'
264
+ ? seg.fontWeight
265
+ : parseInt(seg.fontWeight as string, 10) || weightNum
266
+ : weightNum;
267
+ const segPrimary =
268
+ seg.fontFamily?.split(',')[0].trim().replace(/['"]/g, '') ?? primaryFamily;
269
+ builder.pushStyle(
270
+ new ck.TextStyle({
271
+ color: segColor,
272
+ fontSize: seg.fontSize ?? fontSize,
273
+ fontFamilies: this.fontManager.getFallbackChain(segPrimary),
274
+ fontStyle: { weight: segWeight as any },
275
+ letterSpacing,
276
+ heightMultiplier: lineHeightMul,
277
+ halfLeading: true,
278
+ }),
279
+ );
280
+ builder.addText(seg.text ?? '');
281
+ builder.pop();
253
282
  } else {
254
- builder.addText(seg.text ?? '')
283
+ builder.addText(seg.text ?? '');
255
284
  }
256
285
  }
257
286
  } else {
258
- builder.addText(content)
287
+ builder.addText(content);
259
288
  }
260
289
 
261
- para = builder.build()
262
- para.layout(layoutWidth)
263
- builder.delete()
264
- const entryBytes = SkiaTextRenderer.estimateParaBytes(content)
265
- this.evictParaCache(entryBytes)
266
- this.paraCacheBytes += entryBytes
290
+ para = builder.build();
291
+ para.layout(layoutWidth);
292
+ builder.delete();
293
+ const entryBytes = SkiaTextRenderer.estimateParaBytes(content);
294
+ this.evictParaCache(entryBytes);
295
+ this.paraCacheBytes += entryBytes;
267
296
  } catch {
268
- para = null
297
+ para = null;
269
298
  }
270
299
 
271
- this.paraCache.set(cacheKey, para ?? null)
300
+ this.paraCache.set(cacheKey, para ?? null);
272
301
  }
273
302
 
274
- if (!para) return false
303
+ if (!para) return false;
275
304
 
276
305
  // Compute drawX and surface dimensions
277
- let drawX = x
278
- let surfaceW: number
306
+ let drawX = x;
307
+ let surfaceW: number;
279
308
  if (!isFixedWidth) {
280
- const longestLine = para.getLongestLine()
281
- surfaceW = longestLine + 2
309
+ const longestLine = para.getLongestLine();
310
+ surfaceW = longestLine + 2;
282
311
  if (w > 0 && textAlign !== 'left') {
283
- if (textAlign === 'center') drawX = x + Math.max(0, (w - longestLine) / 2)
284
- else if (textAlign === 'right') drawX = x + Math.max(0, w - longestLine)
312
+ if (textAlign === 'center') drawX = x + Math.max(0, (w - longestLine) / 2);
313
+ else if (textAlign === 'right') drawX = x + Math.max(0, w - longestLine);
285
314
  }
286
315
  } else {
287
- surfaceW = layoutWidth
316
+ surfaceW = layoutWidth;
288
317
  }
289
- const surfaceH = para.getHeight() + 2
290
-
291
- // Try paragraph image cache: drawImageRect is far cheaper than drawParagraph per frame
292
- const imgScale = Math.min(this._dpr, 2)
293
- let cachedImg = this.paraImageCache.get(cacheKey)
294
- if (cachedImg === undefined) {
295
- cachedImg = null
296
- const sw = Math.min(Math.ceil(surfaceW * imgScale), 4096)
297
- const sh = Math.min(Math.ceil(surfaceH * imgScale), 4096)
318
+ const surfaceH = para.getHeight() + 2;
319
+
320
+ // Try paragraph image cache: drawImageRect is far cheaper than drawParagraph per frame.
321
+ // Skip cache when zoomed in (> 1x) or significantly zoomed out (< 0.5x) — cached
322
+ // bitmaps are at fixed DPR resolution and produce jagged edges when scaled by the
323
+ // viewport transform. At normal zoom (0.5–1x), bitmap cache is safe and fast.
324
+ const useParaImageCache = this.zoom >= 0.5 && this.zoom <= 1;
325
+ // Always rasterize at 2x minimum — 1x bitmaps produce jagged text on low-DPR displays
326
+ const imgScale = Math.max(this._dpr, 2);
327
+ let cachedImg: any = useParaImageCache ? this.paraImageCache.get(cacheKey) : null;
328
+ if (useParaImageCache && cachedImg === undefined) {
329
+ cachedImg = null;
330
+ const sw = Math.min(Math.ceil(surfaceW * imgScale), 4096);
331
+ const sh = Math.min(Math.ceil(surfaceH * imgScale), 4096);
298
332
  if (sw > 0 && sh > 0) {
299
- const surf: any = (ck as any).MakeSurface?.(sw, sh)
333
+ const surf: any = (ck as any).MakeSurface?.(sw, sh);
300
334
  if (surf) {
301
- const offCanvas = surf.getCanvas()
302
- offCanvas.scale(imgScale, imgScale)
303
- offCanvas.drawParagraph(para, 0, 0)
304
- cachedImg = (surf.makeImageSnapshot() as SkImage | null) ?? null
305
- surf.delete()
335
+ const offCanvas = surf.getCanvas();
336
+ offCanvas.scale(imgScale, imgScale);
337
+ offCanvas.drawParagraph(para, 0, 0);
338
+ cachedImg = (surf.makeImageSnapshot() as SkImage | null) ?? null;
339
+ surf.delete();
306
340
  if (cachedImg) {
307
- const imgBytes = sw * sh * 4
308
- this.evictParaImageCache(imgBytes)
309
- this.paraImageCacheBytes += imgBytes
341
+ const imgBytes = sw * sh * 4;
342
+ this.evictParaImageCache(imgBytes);
343
+ this.paraImageCacheBytes += imgBytes;
310
344
  }
311
345
  }
312
346
  }
313
- this.paraImageCache.set(cacheKey, cachedImg)
347
+ if (useParaImageCache) this.paraImageCache.set(cacheKey, cachedImg);
314
348
  }
315
349
 
316
350
  if (cachedImg) {
317
- const imgW = cachedImg.width() / imgScale
318
- const imgH = cachedImg.height() / imgScale
319
- const paint = new ck.Paint()
320
- paint.setAntiAlias(true)
321
- if (opacity < 1) paint.setAlphaf(opacity)
351
+ const imgW = cachedImg.width() / imgScale;
352
+ const imgH = cachedImg.height() / imgScale;
353
+ const paint = new ck.Paint();
354
+ paint.setAntiAlias(true);
355
+ if (opacity < 1) paint.setAlphaf(opacity);
322
356
  canvas.drawImageRect(
323
357
  cachedImg,
324
358
  ck.LTRBRect(0, 0, cachedImg.width(), cachedImg.height()),
325
359
  ck.LTRBRect(drawX, y, drawX + imgW, y + imgH),
326
360
  paint,
327
- )
328
- paint.delete()
329
- return true
361
+ );
362
+ paint.delete();
363
+ return true;
330
364
  }
331
365
 
332
366
  // Fallback: surface creation failed, draw directly
333
367
  if (opacity < 1) {
334
- const paint = new ck.Paint()
335
- paint.setAlphaf(opacity)
336
- canvas.saveLayer(paint)
337
- paint.delete()
338
- canvas.drawParagraph(para, drawX, y)
339
- canvas.restore()
368
+ const paint = new ck.Paint();
369
+ paint.setAlphaf(opacity);
370
+ canvas.saveLayer(paint);
371
+ paint.delete();
372
+ canvas.drawParagraph(para, drawX, y);
373
+ canvas.restore();
340
374
  } else {
341
- canvas.drawParagraph(para, drawX, y)
375
+ canvas.drawParagraph(para, drawX, y);
342
376
  }
343
377
 
344
- return true
378
+ return true;
345
379
  }
346
380
 
347
381
  /**
348
382
  * Draw text shadow as a blurred copy of the actual text glyphs.
349
383
  */
350
384
  private drawTextShadow(
351
- canvas: Canvas, node: PenNode,
352
- x: number, y: number, w: number, h: number,
385
+ canvas: Canvas,
386
+ node: PenNode,
387
+ x: number,
388
+ y: number,
389
+ w: number,
390
+ h: number,
353
391
  opacity: number,
354
392
  shadow: ShadowEffect,
355
393
  ) {
356
- const ck = this.ck
357
- const tNode = node as TextNode
358
- const shadowFillColor = shadow.color ?? '#00000066'
394
+ const ck = this.ck;
395
+ const tNode = node as TextNode;
396
+ const shadowFillColor = shadow.color ?? '#00000066';
359
397
  const shadowNode = {
360
398
  ...tNode,
361
399
  fill: [{ type: 'solid' as const, color: shadowFillColor }],
362
- } as PenNode
400
+ } as PenNode;
363
401
 
364
- const sx = x + shadow.offsetX
365
- const sy = y + shadow.offsetY
402
+ const sx = x + shadow.offsetX;
403
+ const sy = y + shadow.offsetY;
366
404
 
367
405
  if (shadow.blur > 0) {
368
- const paint = new ck.Paint()
369
- if (opacity < 1) paint.setAlphaf(opacity)
370
- const sigma = shadow.blur / 2
371
- const filter = ck.ImageFilter.MakeBlur(sigma, sigma, ck.TileMode.Decal, null)
372
- paint.setImageFilter(filter)
373
- canvas.saveLayer(paint)
374
- paint.delete()
375
-
376
- const vectorOk = this.drawTextVector(canvas, shadowNode, sx, sy, w, h, 1)
406
+ const paint = new ck.Paint();
407
+ if (opacity < 1) paint.setAlphaf(opacity);
408
+ const sigma = shadow.blur / 2;
409
+ const filter = ck.ImageFilter.MakeBlur(sigma, sigma, ck.TileMode.Decal, null);
410
+ paint.setImageFilter(filter);
411
+ canvas.saveLayer(paint);
412
+ paint.delete();
413
+
414
+ const vectorOk = this.drawTextVector(canvas, shadowNode, sx, sy, w, h, 1);
377
415
  if (!vectorOk) {
378
- this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, 1)
416
+ this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, 1);
379
417
  }
380
418
 
381
- canvas.restore()
419
+ canvas.restore();
382
420
  } else {
383
- const vectorOk = this.drawTextVector(canvas, shadowNode, sx, sy, w, h, opacity)
421
+ const vectorOk = this.drawTextVector(canvas, shadowNode, sx, sy, w, h, opacity);
384
422
  if (!vectorOk) {
385
- this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, opacity)
423
+ this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, opacity);
386
424
  }
387
425
  }
388
426
  }
389
427
 
390
428
  /** Bitmap text rendering fallback — supports all system fonts via Canvas 2D API. */
391
429
  drawTextBitmap(
392
- canvas: Canvas, node: PenNode,
393
- x: number, y: number, w: number, h: number,
430
+ canvas: Canvas,
431
+ node: PenNode,
432
+ x: number,
433
+ y: number,
434
+ w: number,
435
+ h: number,
394
436
  opacity: number,
395
437
  ) {
396
- const ck = this.ck
397
- const tNode = node as TextNode
398
- const content = typeof tNode.content === 'string'
399
- ? tNode.content
400
- : Array.isArray(tNode.content)
401
- ? tNode.content.map((s) => s.text ?? '').join('')
402
- : ''
403
-
404
- if (!content) return
405
-
406
- const fontSize = tNode.fontSize ?? 16
407
- const fillColor = resolveFillColor(tNode.fill)
408
- const fontWeight = tNode.fontWeight ?? '400'
409
- const fontFamily = tNode.fontFamily ?? 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif'
410
- const textAlign: string = tNode.textAlign ?? 'left'
411
- const lineHeightMul = tNode.lineHeight ?? defaultLineHeight(fontSize)
412
- const lineHeight = lineHeightMul * fontSize
413
- const textGrowth = tNode.textGrowth
414
-
415
- const isFixedWidth = textGrowth === 'fixed-width' || textGrowth === 'fixed-width-height'
416
- || (textGrowth !== 'auto' && textAlign !== 'left' && textAlign !== undefined)
417
- const shouldWrap = isFixedWidth && w > 0
418
-
419
- const measureCanvas = document.createElement('canvas')
420
- const mCtx = measureCanvas.getContext('2d')!
421
- mCtx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}`
422
-
423
- const rawLines = content.split('\n')
424
- let wrappedLines: string[]
425
- let renderW: number
438
+ const ck = this.ck;
439
+ const tNode = node as TextNode;
440
+ const content =
441
+ typeof tNode.content === 'string'
442
+ ? tNode.content
443
+ : Array.isArray(tNode.content)
444
+ ? tNode.content.map((s) => s.text ?? '').join('')
445
+ : (((tNode as unknown as Record<string, unknown>).text as string) ?? '');
446
+
447
+ if (!content) return;
448
+
449
+ const fontSize = tNode.fontSize ?? 16;
450
+ const fillColor = resolveFillColor(tNode.fill);
451
+ const fontWeight = tNode.fontWeight ?? '400';
452
+ const fontFamily =
453
+ tNode.fontFamily ??
454
+ 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif';
455
+ const textAlign: string = tNode.textAlign ?? 'left';
456
+ const lineHeightMul = tNode.lineHeight ?? defaultLineHeight(fontSize);
457
+ const lineHeight = lineHeightMul * fontSize;
458
+ const textGrowth = tNode.textGrowth;
459
+
460
+ const isFixedWidth =
461
+ textGrowth === 'fixed-width' ||
462
+ textGrowth === 'fixed-width-height' ||
463
+ (textGrowth !== 'auto' && textAlign !== 'left' && textAlign !== undefined);
464
+ const shouldWrap = isFixedWidth && w > 0;
465
+
466
+ const measureCanvas = document.createElement('canvas');
467
+ const mCtx = measureCanvas.getContext('2d')!;
468
+ mCtx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}`;
469
+
470
+ const rawLines = content.split('\n');
471
+ let wrappedLines: string[];
472
+ let renderW: number;
426
473
 
427
474
  if (shouldWrap) {
428
- renderW = Math.max(w + fontSize * 0.2, 10)
429
- wrappedLines = []
475
+ renderW = Math.max(w + fontSize * 0.2, 10);
476
+ wrappedLines = [];
430
477
  for (const raw of rawLines) {
431
- if (!raw) { wrappedLines.push(''); continue }
432
- wrapLine(mCtx, raw, renderW, wrappedLines)
478
+ if (!raw) {
479
+ wrappedLines.push('');
480
+ continue;
481
+ }
482
+ wrapLine(mCtx, raw, renderW, wrappedLines);
433
483
  }
434
484
  } else {
435
- wrappedLines = rawLines.length > 0 ? rawLines : ['']
436
- let maxLineWidth = 0
485
+ wrappedLines = rawLines.length > 0 ? rawLines : [''];
486
+ let maxLineWidth = 0;
437
487
  for (const line of wrappedLines) {
438
- if (line) maxLineWidth = Math.max(maxLineWidth, mCtx.measureText(line).width)
488
+ if (line) maxLineWidth = Math.max(maxLineWidth, mCtx.measureText(line).width);
439
489
  }
440
- renderW = Math.max(maxLineWidth + 2, w, 10)
490
+ renderW = Math.max(maxLineWidth + 2, w, 10);
441
491
  }
442
492
 
443
- const FABRIC_FONT_MULT = 1.13
444
- const glyphH = fontSize * FABRIC_FONT_MULT
445
- const textH = Math.max(h,
446
- wrappedLines.length <= 1
447
- ? glyphH + 2
448
- : (wrappedLines.length - 1) * lineHeight + glyphH + 2,
449
- )
493
+ const FABRIC_FONT_MULT = 1.13;
494
+ const glyphH = fontSize * FABRIC_FONT_MULT;
495
+ const textH = Math.max(
496
+ h,
497
+ wrappedLines.length <= 1 ? glyphH + 2 : (wrappedLines.length - 1) * lineHeight + glyphH + 2,
498
+ );
450
499
 
451
- const rawScale = this.zoom * this._dpr
452
- const scale = rawScale <= 2 ? 2 : rawScale <= 4 ? 4 : 8
500
+ const rawScale = this.zoom * this._dpr;
501
+ const scale = rawScale <= 2 ? 2 : rawScale <= 4 ? 4 : 8;
453
502
 
454
- const cacheKey = `${content}|${fontSize}|${fillColor}|${fontWeight}|${fontFamily}|${textAlign}|${Math.round(renderW)}|${Math.round(textH)}|${scale}`
503
+ const cacheKey = `${content}|${fontSize}|${fillColor}|${fontWeight}|${fontFamily}|${textAlign}|${Math.round(renderW)}|${Math.round(textH)}|${scale}`;
455
504
 
456
- let img = this.textCache.get(cacheKey)
505
+ let img = this.textCache.get(cacheKey);
457
506
  if (img === undefined) {
458
- let effectiveScale = scale
459
- let cw = Math.ceil(renderW * effectiveScale)
460
- let ch = Math.ceil(textH * effectiveScale)
461
- if (cw <= 0 || ch <= 0) { this.textCache.set(cacheKey, null); return }
462
- const MAX_TEX = 4096
507
+ let effectiveScale = scale;
508
+ let cw = Math.ceil(renderW * effectiveScale);
509
+ let ch = Math.ceil(textH * effectiveScale);
510
+ if (cw <= 0 || ch <= 0) {
511
+ this.textCache.set(cacheKey, null);
512
+ return;
513
+ }
514
+ const MAX_TEX = 4096;
463
515
  if (cw > MAX_TEX || ch > MAX_TEX) {
464
- effectiveScale = Math.min(MAX_TEX / renderW, MAX_TEX / textH, effectiveScale)
465
- cw = Math.ceil(renderW * effectiveScale)
466
- ch = Math.ceil(textH * effectiveScale)
516
+ effectiveScale = Math.min(MAX_TEX / renderW, MAX_TEX / textH, effectiveScale);
517
+ cw = Math.ceil(renderW * effectiveScale);
518
+ ch = Math.ceil(textH * effectiveScale);
467
519
  }
468
520
 
469
- const tmp = document.createElement('canvas')
470
- tmp.width = cw
471
- tmp.height = ch
472
- const ctx = tmp.getContext('2d')!
473
- ctx.scale(effectiveScale, effectiveScale)
474
- ctx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}`
475
- ctx.fillStyle = fillColor
476
- ctx.textBaseline = 'top'
477
- ctx.textAlign = (textAlign || 'left') as CanvasTextAlign
478
-
479
- let cy = 0
521
+ const tmp = document.createElement('canvas');
522
+ tmp.width = cw;
523
+ tmp.height = ch;
524
+ const ctx = tmp.getContext('2d')!;
525
+ ctx.scale(effectiveScale, effectiveScale);
526
+ ctx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}`;
527
+ ctx.fillStyle = fillColor;
528
+ ctx.textBaseline = 'top';
529
+ ctx.textAlign = (textAlign || 'left') as CanvasTextAlign;
530
+
531
+ let cy = 0;
480
532
  for (const line of wrappedLines) {
481
- if (!line) { cy += lineHeight; continue }
482
- let tx = 0
483
- if (textAlign === 'center') tx = renderW / 2
484
- else if (textAlign === 'right') tx = renderW
485
- ctx.fillText(line, tx, cy)
486
- cy += lineHeight
533
+ if (!line) {
534
+ cy += lineHeight;
535
+ continue;
536
+ }
537
+ let tx = 0;
538
+ if (textAlign === 'center') tx = renderW / 2;
539
+ else if (textAlign === 'right') tx = renderW;
540
+ ctx.fillText(line, tx, cy);
541
+ cy += lineHeight;
487
542
  }
488
543
 
489
- const imageData = ctx.getImageData(0, 0, cw, ch)
544
+ const imageData = ctx.getImageData(0, 0, cw, ch);
490
545
  // Premultiply alpha for correct CanvasKit texture blending
491
- const premul = new Uint8Array(imageData.data.length)
546
+ const premul = new Uint8Array(imageData.data.length);
492
547
  for (let p = 0; p < premul.length; p += 4) {
493
- const a = imageData.data[p + 3]
548
+ const a = imageData.data[p + 3];
494
549
  if (a === 255) {
495
- premul[p] = imageData.data[p]
496
- premul[p + 1] = imageData.data[p + 1]
497
- premul[p + 2] = imageData.data[p + 2]
498
- premul[p + 3] = 255
550
+ premul[p] = imageData.data[p];
551
+ premul[p + 1] = imageData.data[p + 1];
552
+ premul[p + 2] = imageData.data[p + 2];
553
+ premul[p + 3] = 255;
499
554
  } else if (a > 0) {
500
- const f = a / 255
501
- premul[p] = Math.round(imageData.data[p] * f)
502
- premul[p + 1] = Math.round(imageData.data[p + 1] * f)
503
- premul[p + 2] = Math.round(imageData.data[p + 2] * f)
504
- premul[p + 3] = a
555
+ const f = a / 255;
556
+ premul[p] = Math.round(imageData.data[p] * f);
557
+ premul[p + 1] = Math.round(imageData.data[p + 1] * f);
558
+ premul[p + 2] = Math.round(imageData.data[p + 2] * f);
559
+ premul[p + 3] = a;
505
560
  }
506
561
  }
507
- img = ck.MakeImage(
508
- { width: cw, height: ch, alphaType: ck.AlphaType.Premul, colorType: ck.ColorType.RGBA_8888, colorSpace: ck.ColorSpace.SRGB },
509
- premul, cw * 4,
510
- ) ?? null
511
-
512
- const imgBytes = img ? cw * ch * 4 : 0
513
- this.evictTextCache(imgBytes)
514
- this.textCache.set(cacheKey, img)
515
- this.textCacheBytes += imgBytes
562
+ img =
563
+ ck.MakeImage(
564
+ {
565
+ width: cw,
566
+ height: ch,
567
+ alphaType: ck.AlphaType.Premul,
568
+ colorType: ck.ColorType.RGBA_8888,
569
+ colorSpace: ck.ColorSpace.SRGB,
570
+ },
571
+ premul,
572
+ cw * 4,
573
+ ) ?? null;
574
+
575
+ const imgBytes = img ? cw * ch * 4 : 0;
576
+ this.evictTextCache(imgBytes);
577
+ this.textCache.set(cacheKey, img);
578
+ this.textCacheBytes += imgBytes;
516
579
  }
517
580
 
518
- if (!img) return
581
+ if (!img) return;
519
582
 
520
- const paint = new ck.Paint()
521
- paint.setAntiAlias(true)
522
- if (opacity < 1) paint.setAlphaf(opacity)
583
+ const paint = new ck.Paint();
584
+ paint.setAntiAlias(true);
585
+ if (opacity < 1) paint.setAlphaf(opacity);
523
586
  canvas.drawImageRect(
524
587
  img,
525
588
  ck.LTRBRect(0, 0, img.width(), img.height()),
526
589
  ck.LTRBRect(x, y, x + renderW, y + textH),
527
590
  paint,
528
- )
529
- paint.delete()
591
+ );
592
+ paint.delete();
530
593
  }
531
594
  }