@solidtv/renderer 1.6.1 → 1.6.3
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/dist/exports/index.d.ts +1 -0
- package/dist/exports/index.js +3 -0
- package/dist/exports/index.js.map +1 -1
- package/dist/src/core/lib/ImageWorker.js +29 -20
- package/dist/src/core/lib/ImageWorker.js.map +1 -1
- package/dist/src/core/lib/validateImageBitmap.d.ts +13 -7
- package/dist/src/core/lib/validateImageBitmap.js +31 -10
- package/dist/src/core/lib/validateImageBitmap.js.map +1 -1
- package/dist/src/core/text-rendering/CanvasFontHandler.js +51 -16
- package/dist/src/core/text-rendering/CanvasFontHandler.js.map +1 -1
- package/dist/src/core/text-rendering/FontPrefetch.d.ts +55 -0
- package/dist/src/core/text-rendering/FontPrefetch.js +140 -0
- package/dist/src/core/text-rendering/FontPrefetch.js.map +1 -0
- package/dist/src/core/text-rendering/SdfFontHandler.js +91 -30
- package/dist/src/core/text-rendering/SdfFontHandler.js.map +1 -1
- package/dist/src/core/text-rendering/TextRenderer.d.ts +10 -1
- package/dist/src/core/textures/ImageTexture.js +4 -2
- package/dist/src/core/textures/ImageTexture.js.map +1 -1
- package/dist/src/main-api/Renderer.d.ts +8 -4
- package/dist/src/main-api/Renderer.js.map +1 -1
- package/dist/tsconfig.dist.tsbuildinfo +1 -1
- package/exports/index.ts +7 -0
- package/package.json +1 -1
- package/src/core/lib/ImageWorker.test.ts +19 -0
- package/src/core/lib/ImageWorker.ts +34 -40
- package/src/core/lib/validateImageBitmap.test.ts +17 -10
- package/src/core/lib/validateImageBitmap.ts +32 -14
- package/src/core/text-rendering/CanvasFontHandler.ts +57 -16
- package/src/core/text-rendering/FontPrefetch.ts +195 -0
- package/src/core/text-rendering/SdfFontHandler.ts +102 -32
- package/src/core/text-rendering/TextRenderer.ts +10 -1
- package/src/core/text-rendering/tests/FontPrefetch.test.ts +341 -0
- package/src/core/text-rendering/tests/SdfFontHandler.test.ts +108 -1
- package/src/core/textures/ImageTexture.test.ts +94 -26
- package/src/core/textures/ImageTexture.ts +4 -2
- package/src/main-api/Renderer.ts +8 -4
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
prefetchFont,
|
|
4
|
+
clearFontPrefetch,
|
|
5
|
+
takeSdfPrefetch,
|
|
6
|
+
takeCanvasPrefetch,
|
|
7
|
+
} from '../FontPrefetch.js';
|
|
8
|
+
import { loadFont as loadSdfFont, isFontLoaded } from '../SdfFontHandler.js';
|
|
9
|
+
import {
|
|
10
|
+
loadFont as loadCanvasFont,
|
|
11
|
+
isFontLoaded as isCanvasFontLoaded,
|
|
12
|
+
} from '../CanvasFontHandler.js';
|
|
13
|
+
import { EventEmitter } from '../../../common/EventEmitter.js';
|
|
14
|
+
import type { Stage } from '../../Stage.js';
|
|
15
|
+
import type { ImageTextureProps } from '../../textures/ImageTexture.js';
|
|
16
|
+
|
|
17
|
+
const VALID_FONT_DATA = {
|
|
18
|
+
chars: [{}],
|
|
19
|
+
kernings: [],
|
|
20
|
+
lightningMetrics: {
|
|
21
|
+
ascender: 800,
|
|
22
|
+
descender: -200,
|
|
23
|
+
lineGap: 200,
|
|
24
|
+
unitsPerEm: 1000,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Records every request so tests can assert what was fetched, when, and in
|
|
29
|
+
// what order. Responses are keyed by URL and delivered on demand, letting a
|
|
30
|
+
// test hold a request open to observe concurrency.
|
|
31
|
+
class RecordingXHR {
|
|
32
|
+
static requests: RecordingXHR[] = [];
|
|
33
|
+
static responses: Record<string, unknown> = {};
|
|
34
|
+
|
|
35
|
+
status = 200;
|
|
36
|
+
response: unknown = null;
|
|
37
|
+
responseType = '';
|
|
38
|
+
url = '';
|
|
39
|
+
onload: (() => void) | null = null;
|
|
40
|
+
onerror: (() => void) | null = null;
|
|
41
|
+
onreadystatechange: (() => void) | null = null;
|
|
42
|
+
readyState = 0;
|
|
43
|
+
|
|
44
|
+
open(_method: string, url: string): void {
|
|
45
|
+
this.url = url;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
send(): void {
|
|
49
|
+
RecordingXHR.requests.push(this);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Deliver the configured response for this request's URL. */
|
|
53
|
+
respond(status = 200): void {
|
|
54
|
+
this.status = status;
|
|
55
|
+
this.response = RecordingXHR.responses[this.url] ?? null;
|
|
56
|
+
this.readyState = 4;
|
|
57
|
+
if (this.onreadystatechange !== null) this.onreadystatechange();
|
|
58
|
+
if (this.onload !== null) this.onload();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Deliver every outstanding request. */
|
|
62
|
+
static respondAll(): void {
|
|
63
|
+
RecordingXHR.requests.forEach((r) => r.respond());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static reset(): void {
|
|
67
|
+
RecordingXHR.requests = [];
|
|
68
|
+
RecordingXHR.responses = {};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// `XMLHttpRequest.DONE` is read by fetchJson; the class stand-in must carry it.
|
|
73
|
+
(RecordingXHR as unknown as { DONE: number }).DONE = 4;
|
|
74
|
+
|
|
75
|
+
function makeFakeTexture() {
|
|
76
|
+
const tex = new EventEmitter() as unknown as EventEmitter & {
|
|
77
|
+
state: string;
|
|
78
|
+
preventCleanup: boolean;
|
|
79
|
+
setRenderableOwner: (owner: string, val: boolean) => void;
|
|
80
|
+
};
|
|
81
|
+
tex.state = 'loading';
|
|
82
|
+
tex.preventCleanup = false;
|
|
83
|
+
tex.setRenderableOwner = () => {};
|
|
84
|
+
return tex;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type FakeTexture = ReturnType<typeof makeFakeTexture>;
|
|
88
|
+
|
|
89
|
+
// Stage stub that records the props each atlas texture was created with, so
|
|
90
|
+
// tests can assert whether the prefetched blob (or the URL) was used.
|
|
91
|
+
function makeStage(): {
|
|
92
|
+
stage: Stage;
|
|
93
|
+
textures: FakeTexture[];
|
|
94
|
+
textureProps: ImageTextureProps[];
|
|
95
|
+
} {
|
|
96
|
+
const textures: FakeTexture[] = [];
|
|
97
|
+
const textureProps: ImageTextureProps[] = [];
|
|
98
|
+
const stage = {
|
|
99
|
+
txManager: {
|
|
100
|
+
createTexture: (_type: string, props: ImageTextureProps) => {
|
|
101
|
+
textureProps.push(props);
|
|
102
|
+
const t = makeFakeTexture();
|
|
103
|
+
textures.push(t);
|
|
104
|
+
return t;
|
|
105
|
+
},
|
|
106
|
+
removeTextureFromCache: () => {},
|
|
107
|
+
},
|
|
108
|
+
} as unknown as Stage;
|
|
109
|
+
return { stage, textures, textureProps };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
|
113
|
+
|
|
114
|
+
const sdfOpts = (fontFamily: string) => ({
|
|
115
|
+
fontFamily,
|
|
116
|
+
atlasUrl: `${fontFamily}.png`,
|
|
117
|
+
atlasDataUrl: `${fontFamily}.json`,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('FontPrefetch — SDF', () => {
|
|
121
|
+
let originalXHR: unknown;
|
|
122
|
+
let originalBlob: unknown;
|
|
123
|
+
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
originalXHR = (globalThis as unknown as { XMLHttpRequest: unknown })
|
|
126
|
+
.XMLHttpRequest;
|
|
127
|
+
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
|
|
128
|
+
RecordingXHR;
|
|
129
|
+
// jsdom provides Blob; guard for environments that do not.
|
|
130
|
+
originalBlob = (globalThis as unknown as { Blob: unknown }).Blob;
|
|
131
|
+
RecordingXHR.reset();
|
|
132
|
+
clearFontPrefetch();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
|
|
137
|
+
originalXHR;
|
|
138
|
+
(globalThis as unknown as { Blob: unknown }).Blob = originalBlob;
|
|
139
|
+
clearFontPrefetch();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('requests the atlas description and the atlas image concurrently', () => {
|
|
143
|
+
prefetchFont(sdfOpts('ConcurrentFont'));
|
|
144
|
+
|
|
145
|
+
// Both are in flight before either has responded — the handler's own path
|
|
146
|
+
// only starts the image fetch after the JSON resolves.
|
|
147
|
+
expect(RecordingXHR.requests.map((r) => r.url).sort()).toEqual([
|
|
148
|
+
'ConcurrentFont.json',
|
|
149
|
+
'ConcurrentFont.png',
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('starts no request for a font it has already prefetched', () => {
|
|
154
|
+
prefetchFont(sdfOpts('DedupeFont'));
|
|
155
|
+
expect(RecordingXHR.requests.length).toBe(2);
|
|
156
|
+
|
|
157
|
+
prefetchFont(sdfOpts('DedupeFont'));
|
|
158
|
+
expect(RecordingXHR.requests.length).toBe(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('hands the prefetched payload to loadFont instead of refetching', async () => {
|
|
162
|
+
const font = sdfOpts('PrefetchHit');
|
|
163
|
+
const blob = new Blob(['atlas-bytes']);
|
|
164
|
+
RecordingXHR.responses['PrefetchHit.json'] = VALID_FONT_DATA;
|
|
165
|
+
RecordingXHR.responses['PrefetchHit.png'] = blob;
|
|
166
|
+
|
|
167
|
+
prefetchFont(font);
|
|
168
|
+
RecordingXHR.respondAll();
|
|
169
|
+
const prefetchRequestCount = RecordingXHR.requests.length;
|
|
170
|
+
|
|
171
|
+
const { stage, textures, textureProps } = makeStage();
|
|
172
|
+
const promise = loadSdfFont(
|
|
173
|
+
stage,
|
|
174
|
+
font as Parameters<typeof loadSdfFont>[1],
|
|
175
|
+
);
|
|
176
|
+
await flush();
|
|
177
|
+
textures[0]!.emit('loaded');
|
|
178
|
+
await promise;
|
|
179
|
+
|
|
180
|
+
// No second trip to the network for either resource.
|
|
181
|
+
expect(RecordingXHR.requests.length).toBe(prefetchRequestCount);
|
|
182
|
+
// The atlas texture was built from the prefetched bytes...
|
|
183
|
+
expect(textureProps[0]!.src).toBe(blob);
|
|
184
|
+
// ...while still keying the texture cache by URL, so the blob path and the
|
|
185
|
+
// URL path resolve to the same cache entry.
|
|
186
|
+
expect(textureProps[0]!.key).toBe('PrefetchHit.png');
|
|
187
|
+
expect(isFontLoaded('PrefetchHit')).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('falls back to fetching when the prefetch failed', async () => {
|
|
191
|
+
const font = sdfOpts('PrefetchFail');
|
|
192
|
+
// No responses registered -> fetchJson resolves null -> unusable prefetch.
|
|
193
|
+
prefetchFont(font);
|
|
194
|
+
RecordingXHR.respondAll();
|
|
195
|
+
RecordingXHR.reset();
|
|
196
|
+
|
|
197
|
+
RecordingXHR.responses['PrefetchFail.json'] = VALID_FONT_DATA;
|
|
198
|
+
|
|
199
|
+
const { stage, textures, textureProps } = makeStage();
|
|
200
|
+
const promise = loadSdfFont(
|
|
201
|
+
stage,
|
|
202
|
+
font as Parameters<typeof loadSdfFont>[1],
|
|
203
|
+
);
|
|
204
|
+
// The handler runs its own XHR for the atlas description.
|
|
205
|
+
await flush();
|
|
206
|
+
RecordingXHR.respondAll();
|
|
207
|
+
await flush();
|
|
208
|
+
textures[0]!.emit('loaded');
|
|
209
|
+
await promise;
|
|
210
|
+
|
|
211
|
+
expect(RecordingXHR.requests.map((r) => r.url)).toEqual([
|
|
212
|
+
'PrefetchFail.json',
|
|
213
|
+
]);
|
|
214
|
+
// Atlas falls back to the URL rather than a blob.
|
|
215
|
+
expect(textureProps[0]!.src).toBe('PrefetchFail.png');
|
|
216
|
+
expect(isFontLoaded('PrefetchFail')).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('rejects a malformed atlas description rather than loading it', async () => {
|
|
220
|
+
RecordingXHR.responses['Malformed.json'] = { not: 'font data' };
|
|
221
|
+
prefetchFont(sdfOpts('Malformed'));
|
|
222
|
+
RecordingXHR.respondAll();
|
|
223
|
+
|
|
224
|
+
await expect(takeSdfPrefetch('Malformed')!.data).resolves.toBe(null);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('parses an atlas description that arrives as a string', async () => {
|
|
228
|
+
RecordingXHR.responses['StringJson.json'] = JSON.stringify(VALID_FONT_DATA);
|
|
229
|
+
prefetchFont(sdfOpts('StringJson'));
|
|
230
|
+
RecordingXHR.respondAll();
|
|
231
|
+
|
|
232
|
+
await expect(takeSdfPrefetch('StringJson')!.data).resolves.toMatchObject({
|
|
233
|
+
chars: [{}],
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('claims a prefetch only once', () => {
|
|
238
|
+
prefetchFont(sdfOpts('OnceOnly'));
|
|
239
|
+
|
|
240
|
+
expect(takeSdfPrefetch('OnceOnly')).not.toBe(undefined);
|
|
241
|
+
expect(takeSdfPrefetch('OnceOnly')).toBe(undefined);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('ignores a canvas-typed descriptor even when it carries atlas urls', () => {
|
|
245
|
+
prefetchFont({ ...sdfOpts('CanvasTyped'), type: 'canvas' });
|
|
246
|
+
|
|
247
|
+
expect(takeSdfPrefetch('CanvasTyped')).toBe(undefined);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('FontPrefetch — web fonts', () => {
|
|
252
|
+
class FakeFontFace {
|
|
253
|
+
static created: FakeFontFace[] = [];
|
|
254
|
+
constructor(public family: string, public source: string) {
|
|
255
|
+
FakeFontFace.created.push(this);
|
|
256
|
+
}
|
|
257
|
+
load(): Promise<FakeFontFace> {
|
|
258
|
+
return Promise.resolve(this);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let originalFontFace: unknown;
|
|
263
|
+
|
|
264
|
+
beforeEach(() => {
|
|
265
|
+
originalFontFace = (globalThis as unknown as { FontFace: unknown })
|
|
266
|
+
.FontFace;
|
|
267
|
+
(globalThis as unknown as { FontFace: unknown }).FontFace = FakeFontFace;
|
|
268
|
+
FakeFontFace.created = [];
|
|
269
|
+
clearFontPrefetch();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
afterEach(() => {
|
|
273
|
+
(globalThis as unknown as { FontFace: unknown }).FontFace =
|
|
274
|
+
originalFontFace;
|
|
275
|
+
clearFontPrefetch();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('starts the download for every registered name', async () => {
|
|
279
|
+
prefetchFont({
|
|
280
|
+
fontFamily: ['WebPrimary', 'WebAlias'],
|
|
281
|
+
fontUrl: 'web.woff2',
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
expect(FakeFontFace.created.map((f) => f.family)).toEqual([
|
|
285
|
+
'WebPrimary',
|
|
286
|
+
'WebAlias',
|
|
287
|
+
]);
|
|
288
|
+
await expect(takeCanvasPrefetch('WebPrimary')).resolves.toHaveLength(2);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('resolves to null when the download fails, so the handler can retry', async () => {
|
|
292
|
+
vi.spyOn(FakeFontFace.prototype, 'load').mockRejectedValueOnce(
|
|
293
|
+
new Error('network'),
|
|
294
|
+
);
|
|
295
|
+
prefetchFont({ fontFamily: 'WebFail', fontUrl: 'web.woff2' });
|
|
296
|
+
|
|
297
|
+
await expect(takeCanvasPrefetch('WebFail')).resolves.toBe(null);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('does nothing when the engine has no FontFace', () => {
|
|
301
|
+
(globalThis as unknown as { FontFace: unknown }).FontFace = undefined;
|
|
302
|
+
prefetchFont({ fontFamily: 'NoFontFace', fontUrl: 'web.woff2' });
|
|
303
|
+
|
|
304
|
+
expect(takeCanvasPrefetch('NoFontFace')).toBe(undefined);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('registers prefetched faces with the platform without reloading them', async () => {
|
|
308
|
+
prefetchFont({ fontFamily: 'CanvasHit', fontUrl: 'web.woff2' });
|
|
309
|
+
const createdByPrefetch = FakeFontFace.created.length;
|
|
310
|
+
|
|
311
|
+
const added: FakeFontFace[] = [];
|
|
312
|
+
const stage = {
|
|
313
|
+
platform: { addFont: (f: FakeFontFace) => added.push(f) },
|
|
314
|
+
} as unknown as Stage;
|
|
315
|
+
|
|
316
|
+
await loadCanvasFont(stage, {
|
|
317
|
+
fontFamily: 'CanvasHit',
|
|
318
|
+
fontUrl: 'web.woff2',
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// No extra FontFace was constructed — the prefetched one was used.
|
|
322
|
+
expect(FakeFontFace.created.length).toBe(createdByPrefetch);
|
|
323
|
+
expect(added).toEqual(FakeFontFace.created);
|
|
324
|
+
expect(isCanvasFontLoaded('CanvasHit')).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('loads normally when there is no prefetch to claim', async () => {
|
|
328
|
+
const added: FakeFontFace[] = [];
|
|
329
|
+
const stage = {
|
|
330
|
+
platform: { addFont: (f: FakeFontFace) => added.push(f) },
|
|
331
|
+
} as unknown as Stage;
|
|
332
|
+
|
|
333
|
+
await loadCanvasFont(stage, {
|
|
334
|
+
fontFamily: 'CanvasMiss',
|
|
335
|
+
fontUrl: 'web.woff2',
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
expect(FakeFontFace.created.map((f) => f.family)).toEqual(['CanvasMiss']);
|
|
339
|
+
expect(isCanvasFontLoaded('CanvasMiss')).toBe(true);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
@@ -3,11 +3,14 @@ import {
|
|
|
3
3
|
loadFont,
|
|
4
4
|
waitingForFont,
|
|
5
5
|
isFontLoaded,
|
|
6
|
+
getFontData,
|
|
7
|
+
canRenderFont,
|
|
6
8
|
MAX_FONT_LOAD_RETRIES,
|
|
7
9
|
} from '../SdfFontHandler.js';
|
|
8
10
|
import { EventEmitter } from '../../../common/EventEmitter.js';
|
|
9
11
|
import { TextureError, TextureErrorCode } from '../../TextureError.js';
|
|
10
12
|
import type { Stage } from '../../Stage.js';
|
|
13
|
+
import type { TrProps } from '../TextRenderer.js';
|
|
11
14
|
|
|
12
15
|
// Minimal XHR stand-in: synchronously delivers valid SDF font JSON so loadFont
|
|
13
16
|
// proceeds to create the atlas texture and attach its event listeners.
|
|
@@ -71,7 +74,7 @@ function makeStage(): { stage: Stage; textures: FakeTexture[] } {
|
|
|
71
74
|
return { stage, textures };
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
const opts = (fontFamily: string) =>
|
|
77
|
+
const opts = (fontFamily: string | string[]) =>
|
|
75
78
|
({
|
|
76
79
|
fontFamily,
|
|
77
80
|
atlasUrl: 'atlas.png',
|
|
@@ -256,3 +259,107 @@ describe('SdfFontHandler loadFont — automatic retry', () => {
|
|
|
256
259
|
expect(node.setUpdateType).toHaveBeenCalledTimes(1);
|
|
257
260
|
});
|
|
258
261
|
});
|
|
262
|
+
|
|
263
|
+
describe('SdfFontHandler loadFont — multiple names for one font', () => {
|
|
264
|
+
let originalXHR: unknown;
|
|
265
|
+
|
|
266
|
+
beforeEach(() => {
|
|
267
|
+
originalXHR = (globalThis as unknown as { XMLHttpRequest: unknown })
|
|
268
|
+
.XMLHttpRequest;
|
|
269
|
+
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
|
|
270
|
+
FakeXHR;
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
afterEach(() => {
|
|
274
|
+
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
|
|
275
|
+
originalXHR;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('fetches once and registers every name against the same font data', async () => {
|
|
279
|
+
const { stage, textures } = makeStage();
|
|
280
|
+
const names = ['Roboto', 'Roboto500', 'RobotoMedium'];
|
|
281
|
+
|
|
282
|
+
const promise = loadFont(stage, opts(names));
|
|
283
|
+
await flush();
|
|
284
|
+
textures[0]!.emit('loaded');
|
|
285
|
+
await promise;
|
|
286
|
+
|
|
287
|
+
// A single atlas texture was created for all three names.
|
|
288
|
+
expect(textures.length).toBe(1);
|
|
289
|
+
|
|
290
|
+
// Every name reports loaded and resolves to the identical cache entry.
|
|
291
|
+
const primaryData = getFontData(names[0]!);
|
|
292
|
+
expect(primaryData).not.toBe(undefined);
|
|
293
|
+
for (let i = 0; i < names.length; i++) {
|
|
294
|
+
expect(isFontLoaded(names[i]!)).toBe(true);
|
|
295
|
+
expect(getFontData(names[i]!)).toBe(primaryData);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('reports every name as renderable while the font is still loading', async () => {
|
|
300
|
+
const { stage, textures } = makeStage();
|
|
301
|
+
const names = ['MidLoadPrimary', 'MidLoadAlias'];
|
|
302
|
+
|
|
303
|
+
const promise = loadFont(stage, opts(names));
|
|
304
|
+
|
|
305
|
+
// Mid-load: nothing is in fontCache yet, so this is answered purely by the
|
|
306
|
+
// in-flight markers. An alias that answers `false` here makes the stage
|
|
307
|
+
// resolve a *different* text renderer for the node — silently substituting
|
|
308
|
+
// a Canvas face, or throwing "No compatible text renderer found" when SDF
|
|
309
|
+
// is the only engine. Both names must answer the same.
|
|
310
|
+
for (let i = 0; i < names.length; i++) {
|
|
311
|
+
expect(canRenderFont({ fontFamily: names[i]! } as TrProps)).toBe(true);
|
|
312
|
+
}
|
|
313
|
+
expect(isFontLoaded(names[1]!)).toBe(false);
|
|
314
|
+
|
|
315
|
+
await flush();
|
|
316
|
+
textures[0]!.emit('loaded');
|
|
317
|
+
await promise;
|
|
318
|
+
|
|
319
|
+
for (let i = 0; i < names.length; i++) {
|
|
320
|
+
expect(canRenderFont({ fontFamily: names[i]! } as TrProps)).toBe(true);
|
|
321
|
+
expect(isFontLoaded(names[i]!)).toBe(true);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('dedupes a load requested under an alias onto the in-flight load', async () => {
|
|
326
|
+
const { stage, textures } = makeStage();
|
|
327
|
+
|
|
328
|
+
const first = loadFont(stage, opts(['DedupePrimary', 'DedupeAlias']));
|
|
329
|
+
const second = loadFont(stage, opts('DedupeAlias'));
|
|
330
|
+
|
|
331
|
+
expect(second).toBe(first);
|
|
332
|
+
|
|
333
|
+
await flush();
|
|
334
|
+
// A single atlas fetch served both calls.
|
|
335
|
+
expect(textures.length).toBe(1);
|
|
336
|
+
textures[0]!.emit('loaded');
|
|
337
|
+
await first;
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('wakes nodes parked under an alias, not just the primary name', async () => {
|
|
341
|
+
const { stage, textures } = makeStage();
|
|
342
|
+
const names = ['MultiWakePrimary', 'MultiWakeAlias'];
|
|
343
|
+
|
|
344
|
+
const promise = loadFont(stage, opts(names));
|
|
345
|
+
|
|
346
|
+
// Park one node under the primary name and one under the alias.
|
|
347
|
+
const primaryNode = { id: 1, setUpdateType: vi.fn() };
|
|
348
|
+
const aliasNode = { id: 2, setUpdateType: vi.fn() };
|
|
349
|
+
waitingForFont(
|
|
350
|
+
names[0]!,
|
|
351
|
+
primaryNode as unknown as Parameters<typeof waitingForFont>[1],
|
|
352
|
+
);
|
|
353
|
+
waitingForFont(
|
|
354
|
+
names[1]!,
|
|
355
|
+
aliasNode as unknown as Parameters<typeof waitingForFont>[1],
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
await flush();
|
|
359
|
+
textures[0]!.emit('loaded');
|
|
360
|
+
await promise;
|
|
361
|
+
|
|
362
|
+
expect(primaryNode.setUpdateType).toHaveBeenCalledTimes(1);
|
|
363
|
+
expect(aliasNode.setUpdateType).toHaveBeenCalledTimes(1);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
@@ -2,26 +2,70 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
2
2
|
import { ImageTexture } from './ImageTexture.js';
|
|
3
3
|
import type { CoreTextureManager } from '../CoreTextureManager.js';
|
|
4
4
|
|
|
5
|
+
const makeTxManager = (
|
|
6
|
+
imageBitmapSupported: {
|
|
7
|
+
basic: boolean;
|
|
8
|
+
options: boolean;
|
|
9
|
+
full: boolean;
|
|
10
|
+
premultiplyHonored: boolean | null;
|
|
11
|
+
},
|
|
12
|
+
createImageBitmapMock: () => Promise<unknown>,
|
|
13
|
+
) =>
|
|
14
|
+
({
|
|
15
|
+
imageBitmapSupported,
|
|
16
|
+
platform: {
|
|
17
|
+
createImageBitmap: createImageBitmapMock,
|
|
18
|
+
},
|
|
19
|
+
} as unknown as CoreTextureManager);
|
|
20
|
+
|
|
5
21
|
describe('ImageTexture.createImageBitmap', () => {
|
|
6
22
|
beforeEach(() => {
|
|
7
23
|
vi.stubGlobal('ImageBitmap', class {});
|
|
8
24
|
});
|
|
9
25
|
|
|
10
|
-
it('
|
|
26
|
+
it('requests a premultiplied bitmap when options are supported and the option is honored', async () => {
|
|
27
|
+
const createImageBitmapMock = vi.fn(() =>
|
|
28
|
+
Promise.resolve({ close: () => {} }),
|
|
29
|
+
);
|
|
30
|
+
const txManager = makeTxManager(
|
|
31
|
+
{ basic: true, options: true, full: true, premultiplyHonored: true },
|
|
32
|
+
createImageBitmapMock,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const props = ImageTexture.resolveDefaults({
|
|
36
|
+
src: 'test.png',
|
|
37
|
+
premultiplyAlpha: true,
|
|
38
|
+
});
|
|
39
|
+
const texture = new ImageTexture(txManager, props);
|
|
40
|
+
|
|
41
|
+
const blob = new Blob([], { type: 'image/png' });
|
|
42
|
+
const result = await texture.createImageBitmap(
|
|
43
|
+
blob,
|
|
44
|
+
true,
|
|
45
|
+
null,
|
|
46
|
+
null,
|
|
47
|
+
null,
|
|
48
|
+
null,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(createImageBitmapMock).toHaveBeenCalledWith(blob, {
|
|
52
|
+
premultiplyAlpha: 'premultiply',
|
|
53
|
+
colorSpaceConversion: 'none',
|
|
54
|
+
imageOrientation: 'none',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The bitmap is already premultiplied; WebGL must not premultiply again
|
|
58
|
+
expect(result.premultiplyAlpha).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('requests a straight bitmap and defers premultiply to WebGL when the option is not honored', async () => {
|
|
11
62
|
const createImageBitmapMock = vi.fn(() =>
|
|
12
63
|
Promise.resolve({ close: () => {} }),
|
|
13
64
|
);
|
|
14
|
-
const txManager =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
full: true,
|
|
19
|
-
premultiplyHonored: false,
|
|
20
|
-
},
|
|
21
|
-
platform: {
|
|
22
|
-
createImageBitmap: createImageBitmapMock,
|
|
23
|
-
},
|
|
24
|
-
} as unknown as CoreTextureManager;
|
|
65
|
+
const txManager = makeTxManager(
|
|
66
|
+
{ basic: true, options: true, full: true, premultiplyHonored: false },
|
|
67
|
+
createImageBitmapMock,
|
|
68
|
+
);
|
|
25
69
|
|
|
26
70
|
const props = ImageTexture.resolveDefaults({
|
|
27
71
|
src: 'test.png',
|
|
@@ -39,14 +83,13 @@ describe('ImageTexture.createImageBitmap', () => {
|
|
|
39
83
|
null,
|
|
40
84
|
);
|
|
41
85
|
|
|
42
|
-
// It should have called createImageBitmap with the options object
|
|
43
86
|
expect(createImageBitmapMock).toHaveBeenCalledWith(blob, {
|
|
44
87
|
premultiplyAlpha: 'none',
|
|
45
88
|
colorSpaceConversion: 'none',
|
|
46
89
|
imageOrientation: 'none',
|
|
47
90
|
});
|
|
48
91
|
|
|
49
|
-
// Since premultiplyHonored is false,
|
|
92
|
+
// Since premultiplyHonored is false, WebGL premultiplies on upload
|
|
50
93
|
expect(result.premultiplyAlpha).toBe(true);
|
|
51
94
|
});
|
|
52
95
|
|
|
@@ -54,17 +97,10 @@ describe('ImageTexture.createImageBitmap', () => {
|
|
|
54
97
|
const createImageBitmapMock = vi.fn(() =>
|
|
55
98
|
Promise.resolve({ close: () => {} }),
|
|
56
99
|
);
|
|
57
|
-
const txManager =
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
full: false,
|
|
62
|
-
premultiplyHonored: null,
|
|
63
|
-
},
|
|
64
|
-
platform: {
|
|
65
|
-
createImageBitmap: createImageBitmapMock,
|
|
66
|
-
},
|
|
67
|
-
} as unknown as CoreTextureManager;
|
|
100
|
+
const txManager = makeTxManager(
|
|
101
|
+
{ basic: true, options: false, full: false, premultiplyHonored: null },
|
|
102
|
+
createImageBitmapMock,
|
|
103
|
+
);
|
|
68
104
|
|
|
69
105
|
const props = ImageTexture.resolveDefaults({
|
|
70
106
|
src: 'test.png',
|
|
@@ -85,7 +121,39 @@ describe('ImageTexture.createImageBitmap', () => {
|
|
|
85
121
|
// It should have called basic createImageBitmap without options
|
|
86
122
|
expect(createImageBitmapMock).toHaveBeenCalledWith(blob);
|
|
87
123
|
|
|
88
|
-
//
|
|
124
|
+
// The browser default is assumed to premultiply; WebGL must not premultiply again
|
|
89
125
|
expect(result.premultiplyAlpha).toBe(false);
|
|
90
126
|
});
|
|
127
|
+
|
|
128
|
+
it('defers premultiply to WebGL on basic-only devices when premultiplyAlphaHonored is false', async () => {
|
|
129
|
+
const createImageBitmapMock = vi.fn(() =>
|
|
130
|
+
Promise.resolve({ close: () => {} }),
|
|
131
|
+
);
|
|
132
|
+
const txManager = makeTxManager(
|
|
133
|
+
{ basic: true, options: false, full: false, premultiplyHonored: false },
|
|
134
|
+
createImageBitmapMock,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const props = ImageTexture.resolveDefaults({
|
|
138
|
+
src: 'test.png',
|
|
139
|
+
premultiplyAlpha: true,
|
|
140
|
+
});
|
|
141
|
+
const texture = new ImageTexture(txManager, props);
|
|
142
|
+
|
|
143
|
+
const blob = new Blob([], { type: 'image/png' });
|
|
144
|
+
const result = await texture.createImageBitmap(
|
|
145
|
+
blob,
|
|
146
|
+
true,
|
|
147
|
+
null,
|
|
148
|
+
null,
|
|
149
|
+
null,
|
|
150
|
+
null,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Still the basic call — no options are supported on these devices
|
|
154
|
+
expect(createImageBitmapMock).toHaveBeenCalledWith(blob);
|
|
155
|
+
|
|
156
|
+
// Device default returns straight alpha; WebGL premultiplies on upload
|
|
157
|
+
expect(result.premultiplyAlpha).toBe(true);
|
|
158
|
+
});
|
|
91
159
|
});
|
|
@@ -225,10 +225,12 @@ export class ImageTexture extends Texture {
|
|
|
225
225
|
) {
|
|
226
226
|
// basic createImageBitmap without options or crop
|
|
227
227
|
// this is supported for Chrome v50 to v52/54 that doesn't support options.
|
|
228
|
-
// The browser default premultiplies, so WebGL must not premultiply again
|
|
228
|
+
// The browser default premultiplies, so WebGL must not premultiply again —
|
|
229
|
+
// except on devices whose default returns straight alpha
|
|
230
|
+
// (premultiplyAlphaHonored: false); there WebGL premultiplies on upload.
|
|
229
231
|
return {
|
|
230
232
|
data: await this.platform.createImageBitmap(blob),
|
|
231
|
-
premultiplyAlpha:
|
|
233
|
+
premultiplyAlpha: useGlPremultiply,
|
|
232
234
|
};
|
|
233
235
|
}
|
|
234
236
|
|
package/src/main-api/Renderer.ts
CHANGED
|
@@ -654,10 +654,14 @@ export type RendererMainSettings = RendererRuntimeSettings & {
|
|
|
654
654
|
* ignore it, returning straight (non-premultiplied) alpha. This causes edge
|
|
655
655
|
* "ghosting" on images with transparency.
|
|
656
656
|
*
|
|
657
|
-
* Set to `'auto'` to detect via a cheap startup probe (one 1×1
|
|
658
|
-
* upload + framebuffer readback).
|
|
659
|
-
*
|
|
660
|
-
*
|
|
657
|
+
* Set to `'auto'` to detect via a cheap startup probe (one 1×1 PNG decode +
|
|
658
|
+
* texture upload + framebuffer readback). On devices that honor the option —
|
|
659
|
+
* the vast majority — the probe returns `true` and behavior is identical to
|
|
660
|
+
* assuming it honored; only devices that actually ignore it (e.g. the
|
|
661
|
+
* Movistar STB) switch to the WebGL-side premultiply fallback. Set to a
|
|
662
|
+
* boolean to force the value (`false` forces the GL-premultiply fallback;
|
|
663
|
+
* `true` skips it). Leave unset to assume the option is honored — the
|
|
664
|
+
* default, which preserves existing behavior with no probe overhead.
|
|
661
665
|
*
|
|
662
666
|
* @defaultValue `true` (assume honored; no probe)
|
|
663
667
|
*/
|