@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.
- package/README.md +6 -6
- package/package.json +9 -9
- package/src/__tests__/document-flattener.test.ts +277 -0
- package/src/__tests__/font-manager.test.ts +65 -0
- package/src/__tests__/image-loader.test.ts +136 -0
- package/src/__tests__/paint-utils.test.ts +61 -0
- package/src/__tests__/render-node-thumbnail.test.ts +312 -0
- package/src/document-flattener.ts +228 -159
- package/src/font-manager.ts +221 -190
- package/src/image-loader.ts +138 -51
- package/src/index.ts +18 -17
- package/src/init.ts +50 -21
- package/src/node-renderer.ts +957 -386
- package/src/paint-utils.ts +99 -71
- package/src/path-utils.ts +235 -115
- package/src/render-node-thumbnail.ts +155 -0
- package/src/renderer.ts +196 -175
- package/src/spatial-index.ts +139 -27
- package/src/text-renderer.ts +367 -304
- package/src/types.ts +18 -22
- package/src/viewport.ts +28 -29
package/src/text-renderer.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 (
|
|
92
|
-
|
|
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 (
|
|
103
|
-
|
|
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,
|
|
123
|
-
|
|
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,
|
|
147
|
-
|
|
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 =
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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 =
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
else if (weightNum <=
|
|
205
|
-
else if (weightNum <=
|
|
206
|
-
else if (weightNum <=
|
|
207
|
-
else if (weightNum <=
|
|
208
|
-
else if (weightNum <=
|
|
209
|
-
else if (weightNum <=
|
|
210
|
-
else ckWeight = ck.FontWeight.
|
|
211
|
-
|
|
212
|
-
|
|
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 (
|
|
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
|
-
?
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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,
|
|
352
|
-
|
|
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,
|
|
393
|
-
|
|
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 =
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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) {
|
|
432
|
-
|
|
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(
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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) {
|
|
462
|
-
|
|
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) {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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 =
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
}
|