@zseven-w/pen-renderer 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,531 @@
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
+
8
+ /**
9
+ * Text rendering sub-system for SkiaNodeRenderer.
10
+ * Handles both vector (Paragraph API) and bitmap (Canvas 2D) text rendering
11
+ * with caching for performance.
12
+ */
13
+ export class SkiaTextRenderer {
14
+ private ck: CanvasKit
15
+
16
+ // Text rasterization cache (Canvas 2D -> CanvasKit Image)
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
20
+ // 256 MB — each bitmap entry is ~cw*ch*4 bytes (RGBA pixels)
21
+ private static TEXT_CACHE_BYTE_LIMIT = 256 * 1024 * 1024
22
+
23
+ // Paragraph cache for vector text (keyed by content+style)
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
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
29
+
30
+ // Pre-rasterized paragraph image cache (SkImage, same key as paraCache, zoom-independent)
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
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
36
+
37
+ private static estimateParaBytes(content: string): number {
38
+ return content.length * 64 + 4096
39
+ }
40
+
41
+ // Current viewport zoom (set by engine before each render frame)
42
+ zoom = 1
43
+
44
+ // Device pixel ratio override
45
+ devicePixelRatio: number | undefined
46
+
47
+ private get _dpr(): number {
48
+ return this.devicePixelRatio ?? (typeof window !== 'undefined' ? window.devicePixelRatio : 1) ?? 1
49
+ }
50
+
51
+ // Font manager for vector text rendering
52
+ fontManager: SkiaFontManager
53
+
54
+ constructor(ck: CanvasKit, fontOptions?: FontManagerOptions) {
55
+ this.ck = ck
56
+ this.fontManager = new SkiaFontManager(ck, fontOptions)
57
+ }
58
+
59
+ clearTextCache() {
60
+ for (const img of this.textCache.values()) {
61
+ img?.delete()
62
+ }
63
+ this.textCache.clear()
64
+ this.textCacheBytes = 0
65
+ }
66
+
67
+ clearParaCache() {
68
+ for (const p of this.paraCache.values()) {
69
+ p?.delete()
70
+ }
71
+ this.paraCache.clear()
72
+ this.paraCacheBytes = 0
73
+ for (const img of this.paraImageCache.values()) {
74
+ img?.delete()
75
+ }
76
+ this.paraImageCache.clear()
77
+ this.paraImageCacheBytes = 0
78
+ }
79
+
80
+ // Evict oldest entries (Map head = first inserted) until there is room for `incoming` bytes.
81
+ 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] ?? '')
87
+ }
88
+ }
89
+
90
+ 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!
93
+ if (img) {
94
+ this.paraImageCacheBytes -= img.width() * img.height() * 4
95
+ img.delete()
96
+ }
97
+ this.paraImageCache.delete(key)
98
+ }
99
+ }
100
+
101
+ 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!
104
+ if (img) {
105
+ this.textCacheBytes -= img.width() * img.height() * 4
106
+ img.delete()
107
+ }
108
+ this.textCache.delete(key)
109
+ }
110
+ }
111
+
112
+ dispose() {
113
+ this.clearTextCache()
114
+ this.clearParaCache()
115
+ this.fontManager.dispose()
116
+ }
117
+
118
+ /**
119
+ * Main text drawing entry — tries vector, falls back to bitmap.
120
+ */
121
+ drawText(
122
+ canvas: Canvas, node: PenNode,
123
+ x: number, y: number, w: number, h: number,
124
+ opacity: number,
125
+ effects?: PenEffect[],
126
+ ) {
127
+ // 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')
129
+ if (shadow) {
130
+ this.drawTextShadow(canvas, node, x, y, w, h, opacity, shadow)
131
+ }
132
+
133
+ // Try vector text first (true Skia Paragraph API)
134
+ const vectorOk = this.drawTextVector(canvas, node, x, y, w, h, opacity)
135
+ if (vectorOk) return
136
+
137
+ // Fallback to bitmap text rendering
138
+ this.drawTextBitmap(canvas, node, x, y, w, h, opacity)
139
+ }
140
+
141
+ /**
142
+ * Render text as true vector glyphs using CanvasKit's Paragraph API.
143
+ * Returns true if rendered, false if font not available (caller should fallback).
144
+ */
145
+ drawTextVector(
146
+ canvas: Canvas, node: PenNode,
147
+ x: number, y: number, w: number, _h: number,
148
+ opacity: number,
149
+ ): 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
+ if (!this.fontManager.isFontReady(primaryFamily)) {
170
+ if (this.fontManager.isSystemFont(primaryFamily)) {
171
+ return false
172
+ }
173
+ this.fontManager.ensureFont(primaryFamily).then((ok) => {
174
+ if (ok) {
175
+ this.clearParaCache()
176
+ ;(this as any)._onFontLoaded?.()
177
+ }
178
+ })
179
+ if (!this.fontManager.hasAnyFallback(primaryFamily)) {
180
+ return false
181
+ }
182
+ }
183
+
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'
188
+
189
+ const cacheKey = `p|${content}|${fontSize}|${fillColor}|${fontWeight}|${fontFamily}|${effectiveAlign}|${Math.round(layoutWidth)}|${letterSpacing}|${lineHeightMul}`
190
+
191
+ let para = this.paraCache.get(cacheKey)
192
+ 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
+
214
+ const paraStyle = new ck.ParagraphStyle({
215
+ textAlign: ckAlign,
216
+ textStyle: {
217
+ color,
218
+ fontSize,
219
+ fontFamilies: fallbackFamilies,
220
+ fontStyle: { weight: ckWeight },
221
+ letterSpacing,
222
+ heightMultiplier: lineHeightMul,
223
+ halfLeading: true,
224
+ },
225
+ })
226
+
227
+ try {
228
+ const builder = ck.ParagraphBuilder.MakeFromFontProvider(
229
+ paraStyle,
230
+ this.fontManager.getProvider(),
231
+ )
232
+
233
+ // Handle styled segments
234
+ if (Array.isArray(tNode.content) && tNode.content.some(s => s.fontFamily || s.fontSize || s.fontWeight || s.fill)) {
235
+ for (const seg of tNode.content) {
236
+ if (seg.fontFamily || seg.fontSize || seg.fontWeight || seg.fill) {
237
+ const segColor = seg.fill ? parseColor(ck, seg.fill) : color
238
+ 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()
253
+ } else {
254
+ builder.addText(seg.text ?? '')
255
+ }
256
+ }
257
+ } else {
258
+ builder.addText(content)
259
+ }
260
+
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
267
+ } catch {
268
+ para = null
269
+ }
270
+
271
+ this.paraCache.set(cacheKey, para ?? null)
272
+ }
273
+
274
+ if (!para) return false
275
+
276
+ // Compute drawX and surface dimensions
277
+ let drawX = x
278
+ let surfaceW: number
279
+ if (!isFixedWidth) {
280
+ const longestLine = para.getLongestLine()
281
+ surfaceW = longestLine + 2
282
+ 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)
285
+ }
286
+ } else {
287
+ surfaceW = layoutWidth
288
+ }
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)
298
+ if (sw > 0 && sh > 0) {
299
+ const surf: any = (ck as any).MakeSurface?.(sw, sh)
300
+ 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()
306
+ if (cachedImg) {
307
+ const imgBytes = sw * sh * 4
308
+ this.evictParaImageCache(imgBytes)
309
+ this.paraImageCacheBytes += imgBytes
310
+ }
311
+ }
312
+ }
313
+ this.paraImageCache.set(cacheKey, cachedImg)
314
+ }
315
+
316
+ 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)
322
+ canvas.drawImageRect(
323
+ cachedImg,
324
+ ck.LTRBRect(0, 0, cachedImg.width(), cachedImg.height()),
325
+ ck.LTRBRect(drawX, y, drawX + imgW, y + imgH),
326
+ paint,
327
+ )
328
+ paint.delete()
329
+ return true
330
+ }
331
+
332
+ // Fallback: surface creation failed, draw directly
333
+ 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()
340
+ } else {
341
+ canvas.drawParagraph(para, drawX, y)
342
+ }
343
+
344
+ return true
345
+ }
346
+
347
+ /**
348
+ * Draw text shadow as a blurred copy of the actual text glyphs.
349
+ */
350
+ private drawTextShadow(
351
+ canvas: Canvas, node: PenNode,
352
+ x: number, y: number, w: number, h: number,
353
+ opacity: number,
354
+ shadow: ShadowEffect,
355
+ ) {
356
+ const ck = this.ck
357
+ const tNode = node as TextNode
358
+ const shadowFillColor = shadow.color ?? '#00000066'
359
+ const shadowNode = {
360
+ ...tNode,
361
+ fill: [{ type: 'solid' as const, color: shadowFillColor }],
362
+ } as PenNode
363
+
364
+ const sx = x + shadow.offsetX
365
+ const sy = y + shadow.offsetY
366
+
367
+ 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)
377
+ if (!vectorOk) {
378
+ this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, 1)
379
+ }
380
+
381
+ canvas.restore()
382
+ } else {
383
+ const vectorOk = this.drawTextVector(canvas, shadowNode, sx, sy, w, h, opacity)
384
+ if (!vectorOk) {
385
+ this.drawTextBitmap(canvas, shadowNode, sx, sy, w, h, opacity)
386
+ }
387
+ }
388
+ }
389
+
390
+ /** Bitmap text rendering fallback — supports all system fonts via Canvas 2D API. */
391
+ drawTextBitmap(
392
+ canvas: Canvas, node: PenNode,
393
+ x: number, y: number, w: number, h: number,
394
+ opacity: number,
395
+ ) {
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
426
+
427
+ if (shouldWrap) {
428
+ renderW = Math.max(w + fontSize * 0.2, 10)
429
+ wrappedLines = []
430
+ for (const raw of rawLines) {
431
+ if (!raw) { wrappedLines.push(''); continue }
432
+ wrapLine(mCtx, raw, renderW, wrappedLines)
433
+ }
434
+ } else {
435
+ wrappedLines = rawLines.length > 0 ? rawLines : ['']
436
+ let maxLineWidth = 0
437
+ for (const line of wrappedLines) {
438
+ if (line) maxLineWidth = Math.max(maxLineWidth, mCtx.measureText(line).width)
439
+ }
440
+ renderW = Math.max(maxLineWidth + 2, w, 10)
441
+ }
442
+
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
+ )
450
+
451
+ const rawScale = this.zoom * this._dpr
452
+ const scale = rawScale <= 2 ? 2 : rawScale <= 4 ? 4 : 8
453
+
454
+ const cacheKey = `${content}|${fontSize}|${fillColor}|${fontWeight}|${fontFamily}|${textAlign}|${Math.round(renderW)}|${Math.round(textH)}|${scale}`
455
+
456
+ let img = this.textCache.get(cacheKey)
457
+ 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
463
+ 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)
467
+ }
468
+
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
480
+ 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
487
+ }
488
+
489
+ const imageData = ctx.getImageData(0, 0, cw, ch)
490
+ // Premultiply alpha for correct CanvasKit texture blending
491
+ const premul = new Uint8Array(imageData.data.length)
492
+ for (let p = 0; p < premul.length; p += 4) {
493
+ const a = imageData.data[p + 3]
494
+ 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
499
+ } 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
505
+ }
506
+ }
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
516
+ }
517
+
518
+ if (!img) return
519
+
520
+ const paint = new ck.Paint()
521
+ paint.setAntiAlias(true)
522
+ if (opacity < 1) paint.setAlphaf(opacity)
523
+ canvas.drawImageRect(
524
+ img,
525
+ ck.LTRBRect(0, 0, img.width(), img.height()),
526
+ ck.LTRBRect(x, y, x + renderW, y + textH),
527
+ paint,
528
+ )
529
+ paint.delete()
530
+ }
531
+ }
package/src/types.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { PenNode } from '@zseven-w/pen-types'
2
+
3
+ export interface RenderNode {
4
+ node: PenNode
5
+ absX: number
6
+ absY: number
7
+ absW: number
8
+ absH: number
9
+ clipRect?: { x: number; y: number; w: number; h: number; rx: number }
10
+ }
11
+
12
+ export interface ViewportState {
13
+ zoom: number
14
+ panX: number
15
+ panY: number
16
+ }
17
+
18
+ /** Injectable icon lookup function for resolving icon names to SVG path data. */
19
+ export interface IconLookupFn {
20
+ (name: string): { d: string; iconId: string; style: 'stroke' | 'fill' } | null
21
+ }
22
+
23
+ export interface PenRendererOptions {
24
+ /** URL pattern for CanvasKit WASM files. Default: '/canvaskit/' */
25
+ canvasKitPath?: string | ((file: string) => string)
26
+ /** Base URL for bundled font files. Default: '/fonts/' */
27
+ fontBasePath?: string
28
+ /** Custom Google Fonts CSS endpoint. Default: 'https://fonts.googleapis.com/css2' */
29
+ googleFontsCssUrl?: string
30
+ /** Icon lookup function. Default: null (icons render as fallback circle) */
31
+ iconLookup?: IconLookupFn
32
+ /** Theme variant to use for variable resolution. Default: first variant per axis */
33
+ themeVariant?: Record<string, string>
34
+ /** Background color. Default: '#1a1a1a' */
35
+ backgroundColor?: string
36
+ /** Device pixel ratio override. Default: window.devicePixelRatio */
37
+ devicePixelRatio?: number
38
+ /** Default fonts to preload. Default: ['Inter', 'Noto Sans SC'] */
39
+ defaultFonts?: string[]
40
+ }
@@ -0,0 +1,102 @@
1
+ import { MIN_ZOOM, MAX_ZOOM } from '@zseven-w/pen-core'
2
+ import type { ViewportState } from './types.js'
3
+
4
+ export type { ViewportState } from './types.js'
5
+
6
+ /**
7
+ * Compute the 3x3 transform matrix for CanvasKit from viewport state.
8
+ * CanvasKit uses column-major [scaleX, skewX, transX, skewY, scaleY, transY, pers0, pers1, pers2]
9
+ */
10
+ export function viewportMatrix(vp: ViewportState): number[] {
11
+ return [
12
+ vp.zoom, 0, vp.panX,
13
+ 0, vp.zoom, vp.panY,
14
+ 0, 0, 1,
15
+ ]
16
+ }
17
+
18
+ /**
19
+ * Convert screen (client) coordinates to scene coordinates.
20
+ */
21
+ export function screenToScene(
22
+ clientX: number, clientY: number,
23
+ canvasRect: DOMRect,
24
+ vp: ViewportState,
25
+ ): { x: number; y: number } {
26
+ const sx = clientX - canvasRect.left
27
+ const sy = clientY - canvasRect.top
28
+ return {
29
+ x: (sx - vp.panX) / vp.zoom,
30
+ y: (sy - vp.panY) / vp.zoom,
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Convert scene coordinates to screen coordinates.
36
+ */
37
+ export function sceneToScreen(
38
+ sceneX: number, sceneY: number,
39
+ canvasRect: DOMRect,
40
+ vp: ViewportState,
41
+ ): { x: number; y: number } {
42
+ return {
43
+ x: sceneX * vp.zoom + vp.panX + canvasRect.left,
44
+ y: sceneY * vp.zoom + vp.panY + canvasRect.top,
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Zoom towards a point (in screen coordinates).
50
+ */
51
+ export function zoomToPoint(
52
+ vp: ViewportState,
53
+ screenX: number, screenY: number,
54
+ canvasRect: DOMRect,
55
+ newZoom: number,
56
+ ): ViewportState {
57
+ const clampedZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom))
58
+ const sx = screenX - canvasRect.left
59
+ const sy = screenY - canvasRect.top
60
+
61
+ // The scene point under the cursor should stay fixed
62
+ const sceneX = (sx - vp.panX) / vp.zoom
63
+ const sceneY = (sy - vp.panY) / vp.zoom
64
+
65
+ return {
66
+ zoom: clampedZoom,
67
+ panX: sx - sceneX * clampedZoom,
68
+ panY: sy - sceneY * clampedZoom,
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Get viewport bounds in scene coordinates.
74
+ */
75
+ export function getViewportBounds(
76
+ vp: ViewportState,
77
+ canvasWidth: number,
78
+ canvasHeight: number,
79
+ margin = 0,
80
+ ) {
81
+ return {
82
+ left: (-vp.panX) / vp.zoom - margin,
83
+ top: (-vp.panY) / vp.zoom - margin,
84
+ right: (-vp.panX + canvasWidth) / vp.zoom + margin,
85
+ bottom: (-vp.panY + canvasHeight) / vp.zoom + margin,
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Check if a rect is within the viewport bounds.
91
+ */
92
+ export function isRectInViewport(
93
+ rect: { x: number; y: number; w: number; h: number },
94
+ vpBounds: ReturnType<typeof getViewportBounds>,
95
+ ): boolean {
96
+ return !(
97
+ rect.x + rect.w < vpBounds.left
98
+ || rect.x > vpBounds.right
99
+ || rect.y + rect.h < vpBounds.top
100
+ || rect.y > vpBounds.bottom
101
+ )
102
+ }