@forgeax/engine-font 0.1.2
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/LICENSE +202 -0
- package/README.md +100 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/font-importer.test.d.ts +2 -0
- package/dist/__tests__/font-importer.test.d.ts.map +1 -0
- package/dist/__tests__/font-local-artifacts.test.d.ts +2 -0
- package/dist/__tests__/font-local-artifacts.test.d.ts.map +1 -0
- package/dist/__tests__/font.unit.test.d.ts +2 -0
- package/dist/__tests__/font.unit.test.d.ts.map +1 -0
- package/dist/cli-font.d.ts +96 -0
- package/dist/cli-font.d.ts.map +1 -0
- package/dist/cli-font.mjs +465 -0
- package/dist/cli-font.mjs.map +1 -0
- package/dist/font-importer.d.ts +18 -0
- package/dist/font-importer.d.ts.map +1 -0
- package/dist/font-importer.mjs +637 -0
- package/dist/font-importer.mjs.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +54 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-msdf-worker.mjs +35 -0
- package/dist/node-msdf-worker.mjs.map +1 -0
- package/dist/node-worker-adapter.d.ts +17 -0
- package/dist/node-worker-adapter.d.ts.map +1 -0
- package/dist/runtime/font-decoder.d.ts +3 -0
- package/dist/runtime/font-decoder.d.ts.map +1 -0
- package/package.json +80 -0
- package/src/__tests__/font-importer.test.ts +24 -0
- package/src/__tests__/font-local-artifacts.test.ts +280 -0
- package/src/__tests__/font.unit.test.ts +1039 -0
- package/src/cli-font.ts +634 -0
- package/src/font-importer.ts +254 -0
- package/src/index.ts +7 -0
- package/src/node-msdf-worker.mjs +38 -0
- package/src/node-worker-adapter.ts +41 -0
- package/src/runtime/font-decoder.ts +70 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { ImporterRegistry, type RunImportMeta, runImport } from '@forgeax/engine-import';
|
|
2
|
+
import type { ImportSubAsset } from '@forgeax/engine-types';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import type { BakeAtlas, MsdfGenerator } from '../cli-font.js';
|
|
5
|
+
import { fontImporter } from '../font-importer.js';
|
|
6
|
+
import { fontContribution } from '../runtime/font-decoder.js';
|
|
7
|
+
|
|
8
|
+
const ATLAS_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d45c';
|
|
9
|
+
const SAMPLER_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d45e';
|
|
10
|
+
const FONT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d45d';
|
|
11
|
+
const DUPLICATE_ATLAS_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d460';
|
|
12
|
+
const DUPLICATE_SAMPLER_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d461';
|
|
13
|
+
const DUPLICATE_FONT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d462';
|
|
14
|
+
const EXPECTED_TOPOLOGY = 'exactly one texture, one sampler, and one font subAsset';
|
|
15
|
+
|
|
16
|
+
const atlas: BakeAtlas = {
|
|
17
|
+
texture: { width: 1, height: 1, data: new Uint8Array([1, 2, 3, 4]) },
|
|
18
|
+
glyphs: [],
|
|
19
|
+
metrics: { lineHeight: 16, ascender: 12 },
|
|
20
|
+
textureSize: [1, 1],
|
|
21
|
+
fieldRange: 4,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
interface Lifecycle {
|
|
25
|
+
readSource: number;
|
|
26
|
+
factory: number;
|
|
27
|
+
generateAtlas: number;
|
|
28
|
+
dispose: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function declaration(
|
|
32
|
+
guid: string,
|
|
33
|
+
kind: string,
|
|
34
|
+
sourceIndex: number,
|
|
35
|
+
sourceKey = `font:${kind}`,
|
|
36
|
+
): ImportSubAsset {
|
|
37
|
+
return { guid, sourceIndex, sourceKey, kind };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validSubAssets(): readonly ImportSubAsset[] {
|
|
41
|
+
return [
|
|
42
|
+
declaration(ATLAS_GUID, 'texture', 0),
|
|
43
|
+
declaration(SAMPLER_GUID, 'sampler', 1),
|
|
44
|
+
declaration(FONT_GUID, 'font', 2),
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function registry(): ImporterRegistry {
|
|
49
|
+
const importers = new ImporterRegistry();
|
|
50
|
+
importers.register(fontImporter);
|
|
51
|
+
return importers;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function meta(subAssets: readonly ImportSubAsset[], lifecycle: Lifecycle): RunImportMeta {
|
|
55
|
+
const generatorFactory = async (): Promise<MsdfGenerator> => {
|
|
56
|
+
lifecycle.factory += 1;
|
|
57
|
+
return {
|
|
58
|
+
generateAtlas: async () => {
|
|
59
|
+
lifecycle.generateAtlas += 1;
|
|
60
|
+
return atlas;
|
|
61
|
+
},
|
|
62
|
+
dispose: async () => {
|
|
63
|
+
lifecycle.dispose += 1;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
importer: 'font',
|
|
69
|
+
source: 'fonts/test.ttf',
|
|
70
|
+
subAssets,
|
|
71
|
+
importSettings: { generatorFactory },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fs(lifecycle: Lifecycle, shouldRefuse: () => boolean = () => false) {
|
|
76
|
+
return {
|
|
77
|
+
readSource: async () => {
|
|
78
|
+
lifecycle.readSource += 1;
|
|
79
|
+
if (lifecycle.readSource === 2 && shouldRefuse()) {
|
|
80
|
+
return { ok: false as const, error: new Error('transient font source refusal') };
|
|
81
|
+
}
|
|
82
|
+
return { ok: true as const, value: new Uint8Array([0, 1, 0, 0]) };
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function expectTopologyFailure(
|
|
88
|
+
result: Awaited<ReturnType<typeof runImport>>,
|
|
89
|
+
actual: string,
|
|
90
|
+
): void {
|
|
91
|
+
expect(result.ok).toBe(false);
|
|
92
|
+
if (result.ok) return;
|
|
93
|
+
expect(result.error.code).toBe('source-validation-failed');
|
|
94
|
+
if (!('diagnostics' in result.error.detail)) return;
|
|
95
|
+
expect(result.error.detail.diagnostics).toHaveLength(1);
|
|
96
|
+
expect(result.error.detail.diagnostics[0]).toMatchObject({
|
|
97
|
+
code: 'font-required-subasset-topology',
|
|
98
|
+
severity: 'error',
|
|
99
|
+
sourcePath: 'fonts/test.ttf#subAssets',
|
|
100
|
+
rule: 'font-required-subasset-topology',
|
|
101
|
+
expected: EXPECTED_TOPOLOGY,
|
|
102
|
+
actual,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe('font asset-local artifacts', () => {
|
|
107
|
+
it('exposes the font owner contribution for glyph layout', () => {
|
|
108
|
+
expect(fontContribution.kind.kind).toBe('font');
|
|
109
|
+
expect(fontContribution.consumer).toBe('GlyphTextLayout');
|
|
110
|
+
});
|
|
111
|
+
const invalidTopologyCases = [
|
|
112
|
+
{
|
|
113
|
+
name: 'missing texture',
|
|
114
|
+
actual: 'texture=0, sampler=1, font=1',
|
|
115
|
+
subAssets: [declaration(SAMPLER_GUID, 'sampler', 1), declaration(FONT_GUID, 'font', 2)],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'missing sampler',
|
|
119
|
+
actual: 'texture=1, sampler=0, font=1',
|
|
120
|
+
subAssets: [declaration(ATLAS_GUID, 'texture', 0), declaration(FONT_GUID, 'font', 2)],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'missing font',
|
|
124
|
+
actual: 'texture=1, sampler=1, font=0',
|
|
125
|
+
subAssets: [declaration(ATLAS_GUID, 'texture', 0), declaration(SAMPLER_GUID, 'sampler', 1)],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'duplicate texture',
|
|
129
|
+
actual: 'texture=2, sampler=1, font=1',
|
|
130
|
+
subAssets: [
|
|
131
|
+
declaration(ATLAS_GUID, 'texture', 0, 'font:texture:0'),
|
|
132
|
+
declaration(DUPLICATE_ATLAS_GUID, 'texture', 1, 'font:texture:1'),
|
|
133
|
+
declaration(SAMPLER_GUID, 'sampler', 2),
|
|
134
|
+
declaration(FONT_GUID, 'font', 3),
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'duplicate sampler',
|
|
139
|
+
actual: 'texture=1, sampler=2, font=1',
|
|
140
|
+
subAssets: [
|
|
141
|
+
declaration(ATLAS_GUID, 'texture', 0),
|
|
142
|
+
declaration(SAMPLER_GUID, 'sampler', 1, 'font:sampler:0'),
|
|
143
|
+
declaration(DUPLICATE_SAMPLER_GUID, 'sampler', 2, 'font:sampler:1'),
|
|
144
|
+
declaration(FONT_GUID, 'font', 3),
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: 'duplicate font',
|
|
149
|
+
actual: 'texture=1, sampler=1, font=2',
|
|
150
|
+
subAssets: [
|
|
151
|
+
declaration(ATLAS_GUID, 'texture', 0),
|
|
152
|
+
declaration(SAMPLER_GUID, 'sampler', 1),
|
|
153
|
+
declaration(FONT_GUID, 'font', 2, 'font:font:0'),
|
|
154
|
+
declaration(DUPLICATE_FONT_GUID, 'font', 3, 'font:font:1'),
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
] as const;
|
|
158
|
+
|
|
159
|
+
for (const topologyCase of invalidTopologyCases) {
|
|
160
|
+
it(`refuses ${topologyCase.name} before source and generator work`, async () => {
|
|
161
|
+
const lifecycle: Lifecycle = { readSource: 0, factory: 0, generateAtlas: 0, dispose: 0 };
|
|
162
|
+
const result = await runImport(
|
|
163
|
+
meta(topologyCase.subAssets, lifecycle),
|
|
164
|
+
registry(),
|
|
165
|
+
fs(lifecycle),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expectTopologyFailure(result, topologyCase.actual);
|
|
169
|
+
expect(lifecycle).toEqual({ readSource: 1, factory: 0, generateAtlas: 0, dispose: 0 });
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
it('repairs the same meta in one registry/process and publishes complete local artifacts', async () => {
|
|
174
|
+
const lifecycle: Lifecycle = { readSource: 0, factory: 0, generateAtlas: 0, dispose: 0 };
|
|
175
|
+
const importers = registry();
|
|
176
|
+
const input = meta(
|
|
177
|
+
[declaration(ATLAS_GUID, 'texture', 0), declaration(FONT_GUID, 'font', 2)],
|
|
178
|
+
lifecycle,
|
|
179
|
+
);
|
|
180
|
+
const refused = await runImport(input, importers, fs(lifecycle));
|
|
181
|
+
expectTopologyFailure(refused, 'texture=1, sampler=0, font=1');
|
|
182
|
+
|
|
183
|
+
const retry = await runImport(
|
|
184
|
+
{ ...input, subAssets: validSubAssets() },
|
|
185
|
+
importers,
|
|
186
|
+
fs(lifecycle),
|
|
187
|
+
);
|
|
188
|
+
expect(retry.ok).toBe(true);
|
|
189
|
+
if (!retry.ok || 'skipped' in retry.value) return;
|
|
190
|
+
|
|
191
|
+
const assets = retry.value.pack.assets;
|
|
192
|
+
expect(assets.map((asset) => asset.kind)).toEqual(['texture', 'sampler', 'font']);
|
|
193
|
+
const atlasAsset = assets.find((asset) => asset.guid === ATLAS_GUID);
|
|
194
|
+
expect(atlasAsset).toBeDefined();
|
|
195
|
+
if (atlasAsset === undefined) return;
|
|
196
|
+
const samplerAsset = assets.find((asset) => asset.guid === SAMPLER_GUID);
|
|
197
|
+
expect(samplerAsset?.kind).toBe('sampler');
|
|
198
|
+
const fontAsset = assets.find((asset) => asset.guid === FONT_GUID);
|
|
199
|
+
const body = atlasAsset.artifacts.atlas;
|
|
200
|
+
expect(body).toBeDefined();
|
|
201
|
+
if (body === undefined) return;
|
|
202
|
+
expect(body.mediaType).toBe('application/octet-stream');
|
|
203
|
+
expect(body).not.toHaveProperty('assetCodec');
|
|
204
|
+
expect(body.bytes).toBeInstanceOf(Uint8Array);
|
|
205
|
+
expect(body.bytes).toEqual(atlas.texture.data);
|
|
206
|
+
expect(body).not.toHaveProperty('path');
|
|
207
|
+
expect(body).not.toHaveProperty('integrity');
|
|
208
|
+
expect(fontAsset?.refs).toEqual([ATLAS_GUID, SAMPLER_GUID]);
|
|
209
|
+
expect(fontAsset?.payload).toMatchObject({
|
|
210
|
+
atlasGuid: ATLAS_GUID,
|
|
211
|
+
samplerGuid: SAMPLER_GUID,
|
|
212
|
+
});
|
|
213
|
+
expect(lifecycle).toEqual({ readSource: 3, factory: 1, generateAtlas: 1, dispose: 1 });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('preserves importer source-read failure and retries the same Meta without stale publication', async () => {
|
|
217
|
+
const lifecycle: Lifecycle = { readSource: 0, factory: 0, generateAtlas: 0, dispose: 0 };
|
|
218
|
+
const importers = registry();
|
|
219
|
+
let refuseSecondRead = true;
|
|
220
|
+
const input = meta(validSubAssets(), lifecycle);
|
|
221
|
+
const reader = fs(lifecycle, () => refuseSecondRead);
|
|
222
|
+
|
|
223
|
+
const refused = await runImport(input, importers, reader);
|
|
224
|
+
|
|
225
|
+
expect(refused.ok).toBe(false);
|
|
226
|
+
expect(refused).not.toHaveProperty('value');
|
|
227
|
+
if (refused.ok) return;
|
|
228
|
+
expect(refused.error).toMatchObject({
|
|
229
|
+
code: 'source-read-failed',
|
|
230
|
+
expected: 'readable source file at meta.source "fonts/test.ttf"',
|
|
231
|
+
detail: {
|
|
232
|
+
source: 'fonts/test.ttf',
|
|
233
|
+
reason: 'transient font source refusal',
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
expect(refused.error.code).not.toBe('import-internal-error');
|
|
237
|
+
expect(lifecycle).toEqual({ readSource: 2, factory: 0, generateAtlas: 0, dispose: 0 });
|
|
238
|
+
|
|
239
|
+
refuseSecondRead = false;
|
|
240
|
+
const retry = await runImport(input, importers, reader);
|
|
241
|
+
|
|
242
|
+
expect(retry.ok).toBe(true);
|
|
243
|
+
if (!retry.ok || 'skipped' in retry.value) return;
|
|
244
|
+
expect(retry.value.product.sourceDependencies).toEqual(['fonts/test.ttf']);
|
|
245
|
+
expect(retry.value.pack.assets).toHaveLength(3);
|
|
246
|
+
expect(
|
|
247
|
+
retry.value.pack.assets.map(({ guid, kind, sourceKey, sourceIndex }) => ({
|
|
248
|
+
guid,
|
|
249
|
+
kind,
|
|
250
|
+
sourceKey,
|
|
251
|
+
sourceIndex,
|
|
252
|
+
})),
|
|
253
|
+
).toEqual([
|
|
254
|
+
{ guid: ATLAS_GUID, kind: 'texture', sourceKey: 'font:texture', sourceIndex: 0 },
|
|
255
|
+
{ guid: SAMPLER_GUID, kind: 'sampler', sourceKey: 'font:sampler', sourceIndex: 1 },
|
|
256
|
+
{ guid: FONT_GUID, kind: 'font', sourceKey: 'font:font', sourceIndex: 2 },
|
|
257
|
+
]);
|
|
258
|
+
const atlasAsset = retry.value.pack.assets[0];
|
|
259
|
+
expect(atlasAsset?.artifacts.atlas).toMatchObject({
|
|
260
|
+
mediaType: 'application/octet-stream',
|
|
261
|
+
bytes: atlas.texture.data,
|
|
262
|
+
});
|
|
263
|
+
expect(retry.value.pack.assets[2]).toMatchObject({
|
|
264
|
+
refs: [ATLAS_GUID, SAMPLER_GUID],
|
|
265
|
+
payload: { atlasGuid: ATLAS_GUID, samplerGuid: SAMPLER_GUID },
|
|
266
|
+
});
|
|
267
|
+
expect(retry.value.cookProducts).toHaveLength(3);
|
|
268
|
+
expect(new Set(retry.value.cookProducts.map(({ guid }) => guid)).size).toBe(3);
|
|
269
|
+
|
|
270
|
+
const repeated = await runImport(input, importers, reader);
|
|
271
|
+
|
|
272
|
+
expect(repeated.ok).toBe(true);
|
|
273
|
+
if (!repeated.ok || 'skipped' in repeated.value) return;
|
|
274
|
+
expect(repeated.value.product.assets).toEqual(retry.value.product.assets);
|
|
275
|
+
expect(repeated.value.pack.assets).toEqual(retry.value.pack.assets);
|
|
276
|
+
expect(repeated.value.cookProducts).toEqual(retry.value.cookProducts);
|
|
277
|
+
expect(repeated.value.pack.assets.filter(({ guid }) => guid === FONT_GUID)).toHaveLength(1);
|
|
278
|
+
expect(lifecycle).toEqual({ readSource: 6, factory: 2, generateAtlas: 2, dispose: 2 });
|
|
279
|
+
});
|
|
280
|
+
});
|