@zseven-w/pen-renderer 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/package.json +31 -0
- package/src/document-flattener.ts +340 -0
- package/src/font-manager.ts +401 -0
- package/src/image-loader.ts +93 -0
- package/src/index.ts +60 -0
- package/src/init.ts +44 -0
- package/src/node-renderer.ts +599 -0
- package/src/paint-utils.ts +148 -0
- package/src/path-utils.ts +225 -0
- package/src/renderer.ts +374 -0
- package/src/spatial-index.ts +89 -0
- package/src/text-renderer.ts +531 -0
- package/src/types.ts +40 -0
- package/src/viewport.ts +102 -0
|
@@ -0,0 +1,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
|
+
}
|
package/src/viewport.ts
ADDED
|
@@ -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
|
+
}
|