@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,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
- : (tNode as unknown as Record<string, unknown>).text as string ?? ''
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,315 +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
318
+ const surfaceH = para.getHeight() + 2;
290
319
 
291
320
  // Try paragraph image cache: drawImageRect is far cheaper than drawParagraph per frame.
292
321
  // Skip cache when zoomed in (> 1x) or significantly zoomed out (< 0.5x) — cached
293
322
  // bitmaps are at fixed DPR resolution and produce jagged edges when scaled by the
294
323
  // viewport transform. At normal zoom (0.5–1x), bitmap cache is safe and fast.
295
- const useParaImageCache = this.zoom >= 0.5 && this.zoom <= 1
324
+ const useParaImageCache = this.zoom >= 0.5 && this.zoom <= 1;
296
325
  // Always rasterize at 2x minimum — 1x bitmaps produce jagged text on low-DPR displays
297
- const imgScale = Math.max(this._dpr, 2)
298
- let cachedImg: any = useParaImageCache ? this.paraImageCache.get(cacheKey) : null
326
+ const imgScale = Math.max(this._dpr, 2);
327
+ let cachedImg: any = useParaImageCache ? this.paraImageCache.get(cacheKey) : null;
299
328
  if (useParaImageCache && cachedImg === undefined) {
300
- cachedImg = null
301
- const sw = Math.min(Math.ceil(surfaceW * imgScale), 4096)
302
- const sh = Math.min(Math.ceil(surfaceH * imgScale), 4096)
329
+ cachedImg = null;
330
+ const sw = Math.min(Math.ceil(surfaceW * imgScale), 4096);
331
+ const sh = Math.min(Math.ceil(surfaceH * imgScale), 4096);
303
332
  if (sw > 0 && sh > 0) {
304
- const surf: any = (ck as any).MakeSurface?.(sw, sh)
333
+ const surf: any = (ck as any).MakeSurface?.(sw, sh);
305
334
  if (surf) {
306
- const offCanvas = surf.getCanvas()
307
- offCanvas.scale(imgScale, imgScale)
308
- offCanvas.drawParagraph(para, 0, 0)
309
- cachedImg = (surf.makeImageSnapshot() as SkImage | null) ?? null
310
- 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();
311
340
  if (cachedImg) {
312
- const imgBytes = sw * sh * 4
313
- this.evictParaImageCache(imgBytes)
314
- this.paraImageCacheBytes += imgBytes
341
+ const imgBytes = sw * sh * 4;
342
+ this.evictParaImageCache(imgBytes);
343
+ this.paraImageCacheBytes += imgBytes;
315
344
  }
316
345
  }
317
346
  }
318
- if (useParaImageCache) this.paraImageCache.set(cacheKey, cachedImg)
347
+ if (useParaImageCache) this.paraImageCache.set(cacheKey, cachedImg);
319
348
  }
320
349
 
321
350
  if (cachedImg) {
322
- const imgW = cachedImg.width() / imgScale
323
- const imgH = cachedImg.height() / imgScale
324
- const paint = new ck.Paint()
325
- paint.setAntiAlias(true)
326
- 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);
327
356
  canvas.drawImageRect(
328
357
  cachedImg,
329
358
  ck.LTRBRect(0, 0, cachedImg.width(), cachedImg.height()),
330
359
  ck.LTRBRect(drawX, y, drawX + imgW, y + imgH),
331
360
  paint,
332
- )
333
- paint.delete()
334
- return true
361
+ );
362
+ paint.delete();
363
+ return true;
335
364
  }
336
365
 
337
366
  // Fallback: surface creation failed, draw directly
338
367
  if (opacity < 1) {
339
- const paint = new ck.Paint()
340
- paint.setAlphaf(opacity)
341
- canvas.saveLayer(paint)
342
- paint.delete()
343
- canvas.drawParagraph(para, drawX, y)
344
- 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();
345
374
  } else {
346
- canvas.drawParagraph(para, drawX, y)
375
+ canvas.drawParagraph(para, drawX, y);
347
376
  }
348
377
 
349
- return true
378
+ return true;
350
379
  }
351
380
 
352
381
  /**
353
382
  * Draw text shadow as a blurred copy of the actual text glyphs.
354
383
  */
355
384
  private drawTextShadow(
356
- canvas: Canvas, node: PenNode,
357
- 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,
358
391
  opacity: number,
359
392
  shadow: ShadowEffect,
360
393
  ) {
361
- const ck = this.ck
362
- const tNode = node as TextNode
363
- const shadowFillColor = shadow.color ?? '#00000066'
394
+ const ck = this.ck;
395
+ const tNode = node as TextNode;
396
+ const shadowFillColor = shadow.color ?? '#00000066';
364
397
  const shadowNode = {
365
398
  ...tNode,
366
399
  fill: [{ type: 'solid' as const, color: shadowFillColor }],
367
- } as PenNode
400
+ } as PenNode;
368
401
 
369
- const sx = x + shadow.offsetX
370
- const sy = y + shadow.offsetY
402
+ const sx = x + shadow.offsetX;
403
+ const sy = y + shadow.offsetY;
371
404
 
372
405
  if (shadow.blur > 0) {
373
- const paint = new ck.Paint()
374
- if (opacity < 1) paint.setAlphaf(opacity)
375
- const sigma = shadow.blur / 2
376
- const filter = ck.ImageFilter.MakeBlur(sigma, sigma, ck.TileMode.Decal, null)
377
- paint.setImageFilter(filter)
378
- canvas.saveLayer(paint)
379
- paint.delete()
380
-
381
- 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);
382
415
  if (!vectorOk) {
383
- this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, 1)
416
+ this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, 1);
384
417
  }
385
418
 
386
- canvas.restore()
419
+ canvas.restore();
387
420
  } else {
388
- const vectorOk = this.drawTextVector(canvas, shadowNode, sx, sy, w, h, opacity)
421
+ const vectorOk = this.drawTextVector(canvas, shadowNode, sx, sy, w, h, opacity);
389
422
  if (!vectorOk) {
390
- this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, opacity)
423
+ this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, opacity);
391
424
  }
392
425
  }
393
426
  }
394
427
 
395
428
  /** Bitmap text rendering fallback — supports all system fonts via Canvas 2D API. */
396
429
  drawTextBitmap(
397
- canvas: Canvas, node: PenNode,
398
- 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,
399
436
  opacity: number,
400
437
  ) {
401
- const ck = this.ck
402
- const tNode = node as TextNode
403
- const content = typeof tNode.content === 'string'
404
- ? tNode.content
405
- : Array.isArray(tNode.content)
406
- ? tNode.content.map((s) => s.text ?? '').join('')
407
- : (tNode as unknown as Record<string, unknown>).text as string ?? ''
408
-
409
- if (!content) return
410
-
411
- const fontSize = tNode.fontSize ?? 16
412
- const fillColor = resolveFillColor(tNode.fill)
413
- const fontWeight = tNode.fontWeight ?? '400'
414
- const fontFamily = tNode.fontFamily ?? 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif'
415
- const textAlign: string = tNode.textAlign ?? 'left'
416
- const lineHeightMul = tNode.lineHeight ?? defaultLineHeight(fontSize)
417
- const lineHeight = lineHeightMul * fontSize
418
- const textGrowth = tNode.textGrowth
419
-
420
- const isFixedWidth = textGrowth === 'fixed-width' || textGrowth === 'fixed-width-height'
421
- || (textGrowth !== 'auto' && textAlign !== 'left' && textAlign !== undefined)
422
- const shouldWrap = isFixedWidth && w > 0
423
-
424
- const measureCanvas = document.createElement('canvas')
425
- const mCtx = measureCanvas.getContext('2d')!
426
- mCtx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}`
427
-
428
- const rawLines = content.split('\n')
429
- let wrappedLines: string[]
430
- 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;
431
473
 
432
474
  if (shouldWrap) {
433
- renderW = Math.max(w + fontSize * 0.2, 10)
434
- wrappedLines = []
475
+ renderW = Math.max(w + fontSize * 0.2, 10);
476
+ wrappedLines = [];
435
477
  for (const raw of rawLines) {
436
- if (!raw) { wrappedLines.push(''); continue }
437
- wrapLine(mCtx, raw, renderW, wrappedLines)
478
+ if (!raw) {
479
+ wrappedLines.push('');
480
+ continue;
481
+ }
482
+ wrapLine(mCtx, raw, renderW, wrappedLines);
438
483
  }
439
484
  } else {
440
- wrappedLines = rawLines.length > 0 ? rawLines : ['']
441
- let maxLineWidth = 0
485
+ wrappedLines = rawLines.length > 0 ? rawLines : [''];
486
+ let maxLineWidth = 0;
442
487
  for (const line of wrappedLines) {
443
- if (line) maxLineWidth = Math.max(maxLineWidth, mCtx.measureText(line).width)
488
+ if (line) maxLineWidth = Math.max(maxLineWidth, mCtx.measureText(line).width);
444
489
  }
445
- renderW = Math.max(maxLineWidth + 2, w, 10)
490
+ renderW = Math.max(maxLineWidth + 2, w, 10);
446
491
  }
447
492
 
448
- const FABRIC_FONT_MULT = 1.13
449
- const glyphH = fontSize * FABRIC_FONT_MULT
450
- const textH = Math.max(h,
451
- wrappedLines.length <= 1
452
- ? glyphH + 2
453
- : (wrappedLines.length - 1) * lineHeight + glyphH + 2,
454
- )
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
+ );
455
499
 
456
- const rawScale = this.zoom * this._dpr
457
- 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;
458
502
 
459
- 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}`;
460
504
 
461
- let img = this.textCache.get(cacheKey)
505
+ let img = this.textCache.get(cacheKey);
462
506
  if (img === undefined) {
463
- let effectiveScale = scale
464
- let cw = Math.ceil(renderW * effectiveScale)
465
- let ch = Math.ceil(textH * effectiveScale)
466
- if (cw <= 0 || ch <= 0) { this.textCache.set(cacheKey, null); return }
467
- 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;
468
515
  if (cw > MAX_TEX || ch > MAX_TEX) {
469
- effectiveScale = Math.min(MAX_TEX / renderW, MAX_TEX / textH, effectiveScale)
470
- cw = Math.ceil(renderW * effectiveScale)
471
- 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);
472
519
  }
473
520
 
474
- const tmp = document.createElement('canvas')
475
- tmp.width = cw
476
- tmp.height = ch
477
- const ctx = tmp.getContext('2d')!
478
- ctx.scale(effectiveScale, effectiveScale)
479
- ctx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}`
480
- ctx.fillStyle = fillColor
481
- ctx.textBaseline = 'top'
482
- ctx.textAlign = (textAlign || 'left') as CanvasTextAlign
483
-
484
- 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;
485
532
  for (const line of wrappedLines) {
486
- if (!line) { cy += lineHeight; continue }
487
- let tx = 0
488
- if (textAlign === 'center') tx = renderW / 2
489
- else if (textAlign === 'right') tx = renderW
490
- ctx.fillText(line, tx, cy)
491
- 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;
492
542
  }
493
543
 
494
- const imageData = ctx.getImageData(0, 0, cw, ch)
544
+ const imageData = ctx.getImageData(0, 0, cw, ch);
495
545
  // Premultiply alpha for correct CanvasKit texture blending
496
- const premul = new Uint8Array(imageData.data.length)
546
+ const premul = new Uint8Array(imageData.data.length);
497
547
  for (let p = 0; p < premul.length; p += 4) {
498
- const a = imageData.data[p + 3]
548
+ const a = imageData.data[p + 3];
499
549
  if (a === 255) {
500
- premul[p] = imageData.data[p]
501
- premul[p + 1] = imageData.data[p + 1]
502
- premul[p + 2] = imageData.data[p + 2]
503
- 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;
504
554
  } else if (a > 0) {
505
- const f = a / 255
506
- premul[p] = Math.round(imageData.data[p] * f)
507
- premul[p + 1] = Math.round(imageData.data[p + 1] * f)
508
- premul[p + 2] = Math.round(imageData.data[p + 2] * f)
509
- 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;
510
560
  }
511
561
  }
512
- img = ck.MakeImage(
513
- { width: cw, height: ch, alphaType: ck.AlphaType.Premul, colorType: ck.ColorType.RGBA_8888, colorSpace: ck.ColorSpace.SRGB },
514
- premul, cw * 4,
515
- ) ?? null
516
-
517
- const imgBytes = img ? cw * ch * 4 : 0
518
- this.evictTextCache(imgBytes)
519
- this.textCache.set(cacheKey, img)
520
- 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;
521
579
  }
522
580
 
523
- if (!img) return
581
+ if (!img) return;
524
582
 
525
- const paint = new ck.Paint()
526
- paint.setAntiAlias(true)
527
- if (opacity < 1) paint.setAlphaf(opacity)
583
+ const paint = new ck.Paint();
584
+ paint.setAntiAlias(true);
585
+ if (opacity < 1) paint.setAlphaf(opacity);
528
586
  canvas.drawImageRect(
529
587
  img,
530
588
  ck.LTRBRect(0, 0, img.width(), img.height()),
531
589
  ck.LTRBRect(x, y, x + renderW, y + textH),
532
590
  paint,
533
- )
534
- paint.delete()
591
+ );
592
+ paint.delete();
535
593
  }
536
594
  }