@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,1039 @@
|
|
|
1
|
+
// Consolidated by feat-20260609-test-pool-startup-reduction-merge-tiny-test-files
|
|
2
|
+
// biome-ignore-all lint/complexity/noUselessLoneBlockStatements: scope isolation between merged source files
|
|
3
|
+
//
|
|
4
|
+
// Source files (N=3):
|
|
5
|
+
// - packages/font/src/__tests__/bake-real.test.ts
|
|
6
|
+
// - packages/font/src/__tests__/font-importer.test.ts
|
|
7
|
+
// - packages/font/src/__tests__/plugin-discoverable.test.ts
|
|
8
|
+
//
|
|
9
|
+
// Paradigm: each block-scoped describe('<source-filename>.test.ts', ...) preserves
|
|
10
|
+
// source as ancestorTitles[0]. Top-level imports merged + deduped.
|
|
11
|
+
|
|
12
|
+
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { dirname, join, resolve } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { ImporterRegistry } from '@forgeax/engine-import';
|
|
17
|
+
import type { ImportContext, ImportedAsset, ImportSubAsset } from '@forgeax/engine-types';
|
|
18
|
+
import { FontError } from '@forgeax/engine-types';
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
20
|
+
import {
|
|
21
|
+
type BakeAtlas,
|
|
22
|
+
bakeFont,
|
|
23
|
+
type MsdfGenerator,
|
|
24
|
+
realGeneratorFactory,
|
|
25
|
+
runCliFont,
|
|
26
|
+
} from '../cli-font.js';
|
|
27
|
+
import { fontImporter, fontOutputSourceKeys, sourceKeyForFontOutput } from '../font-importer.js';
|
|
28
|
+
|
|
29
|
+
let tmpRoot: string;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
tmpRoot = await mkdtemp(join(tmpdir(), 'forgeax-font-bake-'));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await rm(tmpRoot, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const TTF_MAGIC = new Uint8Array([0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
|
|
40
|
+
const WOFF2_MAGIC = new Uint8Array([0x77, 0x4f, 0x46, 0x32]);
|
|
41
|
+
|
|
42
|
+
const REAL_TTF_CANDIDATES = [
|
|
43
|
+
'/System/Library/Fonts/Supplemental/Arial.ttf',
|
|
44
|
+
'/Library/Fonts/Arial Unicode.ttf',
|
|
45
|
+
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
|
46
|
+
'/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
async function findRealTtf(): Promise<string | undefined> {
|
|
50
|
+
for (const p of REAL_TTF_CANDIDATES) {
|
|
51
|
+
try {
|
|
52
|
+
await stat(p);
|
|
53
|
+
return p;
|
|
54
|
+
} catch {
|
|
55
|
+
// not present -- try next
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
// ─── from bake-real.test.ts ───
|
|
63
|
+
|
|
64
|
+
describe('bake-real.test.ts', () => {
|
|
65
|
+
describe('real bake pipeline (w29)', () => {
|
|
66
|
+
it('(a) best-effort real run produces a valid atlas + sidecar (skips when Worker / TTF unavailable)', async () => {
|
|
67
|
+
const hasWorker = typeof (globalThis as { Worker?: unknown }).Worker !== 'undefined';
|
|
68
|
+
const ttfPath = await findRealTtf();
|
|
69
|
+
if (!hasWorker || ttfPath === undefined) {
|
|
70
|
+
const reason = !hasWorker ? 'zappar-worker-unavailable' : 'no-real-ttf-fixture';
|
|
71
|
+
expect(['zappar-worker-unavailable', 'no-real-ttf-fixture']).toContain(reason);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const ttf = await readFile(ttfPath);
|
|
75
|
+
const ttfBytes = new Uint8Array(ttf.buffer, ttf.byteOffset, ttf.byteLength);
|
|
76
|
+
const charset = Array.from({ length: 0x7e - 0x20 + 1 }, (_, i) =>
|
|
77
|
+
String.fromCharCode(0x20 + i),
|
|
78
|
+
).join('');
|
|
79
|
+
const factory = async (): Promise<MsdfGenerator> => {
|
|
80
|
+
const mod = (await import('@zappar/msdf-generator')) as unknown as {
|
|
81
|
+
MSDF: new () => {
|
|
82
|
+
initialize(): Promise<void>;
|
|
83
|
+
generateAtlas(opts: unknown): Promise<BakeAtlas>;
|
|
84
|
+
dispose(): Promise<void>;
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
const msdf = new mod.MSDF();
|
|
88
|
+
await msdf.initialize();
|
|
89
|
+
return {
|
|
90
|
+
generateAtlas: (bytes: Uint8Array) =>
|
|
91
|
+
msdf.generateAtlas({
|
|
92
|
+
font: bytes,
|
|
93
|
+
charset,
|
|
94
|
+
textureSize: [1024, 1024],
|
|
95
|
+
fieldRange: 4,
|
|
96
|
+
fontSize: 48,
|
|
97
|
+
}),
|
|
98
|
+
dispose: () => msdf.dispose(),
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
const localTtf = join(tmpRoot, 'Real.ttf');
|
|
102
|
+
await writeFile(localTtf, ttfBytes);
|
|
103
|
+
const result = await bakeFont(localTtf, tmpRoot, factory);
|
|
104
|
+
const png = await readFile(result.atlasPath);
|
|
105
|
+
expect(png.length).toBeGreaterThan(0);
|
|
106
|
+
expect(png[0]).toBe(0x89);
|
|
107
|
+
const sidecar = JSON.parse(await readFile(result.sidecarPath, 'utf8')) as {
|
|
108
|
+
common: { distanceRange: number; atlasWidth: number };
|
|
109
|
+
glyphs: Record<string, unknown>;
|
|
110
|
+
};
|
|
111
|
+
expect(sidecar.common.distanceRange).toBeGreaterThan(0);
|
|
112
|
+
expect(sidecar.common.atlasWidth).toBe(1024);
|
|
113
|
+
expect(Object.keys(sidecar.glyphs).length).toBeGreaterThan(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('(a2) plain Node real run uses the Worker-capable host and writes artefacts', async () => {
|
|
117
|
+
const ttfPath = resolve(
|
|
118
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
119
|
+
'../../../../forgeax-engine-assets/dejavu-fonts/DejaVuSansMono.ttf',
|
|
120
|
+
);
|
|
121
|
+
const result = await bakeFont(ttfPath, tmpRoot, realGeneratorFactory);
|
|
122
|
+
const png = await readFile(result.atlasPath);
|
|
123
|
+
const sidecar = JSON.parse(await readFile(result.sidecarPath, 'utf8')) as {
|
|
124
|
+
common: { distanceRange: number; atlasWidth: number; atlasHeight: number };
|
|
125
|
+
glyphs: Record<string, unknown>;
|
|
126
|
+
};
|
|
127
|
+
expect(png[0]).toBe(0x89);
|
|
128
|
+
expect(sidecar.common).toMatchObject({
|
|
129
|
+
distanceRange: 4,
|
|
130
|
+
atlasWidth: 1024,
|
|
131
|
+
atlasHeight: 1024,
|
|
132
|
+
});
|
|
133
|
+
expect(Object.keys(sidecar.glyphs).length).toBeGreaterThan(90);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('(b) non-TTF input throws unsupported-font-format; valid TTF magic does not', async () => {
|
|
137
|
+
const woffPath = join(tmpRoot, 'X.woff2');
|
|
138
|
+
await writeFile(woffPath, WOFF2_MAGIC);
|
|
139
|
+
const neverGen = async (): Promise<MsdfGenerator> => {
|
|
140
|
+
throw new Error('generator must not be reached for a non-TTF input');
|
|
141
|
+
};
|
|
142
|
+
await expect(bakeFont(woffPath, tmpRoot, neverGen)).rejects.toMatchObject({
|
|
143
|
+
code: 'unsupported-font-format',
|
|
144
|
+
expected: 'ttf',
|
|
145
|
+
});
|
|
146
|
+
const err = await bakeFont(woffPath, tmpRoot, neverGen).catch((e) => e);
|
|
147
|
+
expect(err).toBeInstanceOf(FontError);
|
|
148
|
+
|
|
149
|
+
const ttfPath = join(tmpRoot, 'Valid.ttf');
|
|
150
|
+
await writeFile(ttfPath, TTF_MAGIC);
|
|
151
|
+
const boomGen = async (): Promise<MsdfGenerator> => ({
|
|
152
|
+
generateAtlas: async () => {
|
|
153
|
+
throw new Error('reached generator');
|
|
154
|
+
},
|
|
155
|
+
dispose: async () => undefined,
|
|
156
|
+
});
|
|
157
|
+
const passErr = await bakeFont(ttfPath, tmpRoot, boomGen).catch((e) => e);
|
|
158
|
+
expect(passErr).toBeInstanceOf(FontError);
|
|
159
|
+
expect((passErr as FontError).code).not.toBe('unsupported-font-format');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('(c) mock generator failure yields bake-failed (no silent success)', async () => {
|
|
163
|
+
const ttfPath = join(tmpRoot, 'Valid.ttf');
|
|
164
|
+
await writeFile(ttfPath, TTF_MAGIC);
|
|
165
|
+
const failGen = async (): Promise<MsdfGenerator> => ({
|
|
166
|
+
generateAtlas: async () => {
|
|
167
|
+
throw new Error('wasm boom');
|
|
168
|
+
},
|
|
169
|
+
dispose: async () => undefined,
|
|
170
|
+
});
|
|
171
|
+
const err = await bakeFont(ttfPath, tmpRoot, failGen).catch((e) => e);
|
|
172
|
+
expect(err).toBeInstanceOf(FontError);
|
|
173
|
+
expect((err as FontError).code).toBe('bake-failed');
|
|
174
|
+
expect((err as FontError).detail?.cause).toBe('wasm boom');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('(d) output-root refusal is structured before generator work and repairs on the same path', async () => {
|
|
178
|
+
const ttfPath = join(tmpRoot, 'Valid.ttf');
|
|
179
|
+
await writeFile(ttfPath, TTF_MAGIC);
|
|
180
|
+
const blockedParent = join(tmpRoot, 'blocked-parent');
|
|
181
|
+
const outDir = join(blockedParent, 'atlas');
|
|
182
|
+
await writeFile(blockedParent, 'not a directory');
|
|
183
|
+
|
|
184
|
+
let factoryCalls = 0;
|
|
185
|
+
let generateCalls = 0;
|
|
186
|
+
let disposeCalls = 0;
|
|
187
|
+
const factory = async (): Promise<MsdfGenerator> => {
|
|
188
|
+
factoryCalls += 1;
|
|
189
|
+
return {
|
|
190
|
+
generateAtlas: async (bytes) => {
|
|
191
|
+
generateCalls += 1;
|
|
192
|
+
expect(bytes).toEqual(TTF_MAGIC);
|
|
193
|
+
return {
|
|
194
|
+
texture: { width: 1, height: 1, data: new Uint8Array([255, 255, 255, 255]) },
|
|
195
|
+
glyphs: [],
|
|
196
|
+
metrics: { lineHeight: 1, ascender: 1 },
|
|
197
|
+
textureSize: [1, 1],
|
|
198
|
+
fieldRange: 4,
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
dispose: async () => {
|
|
202
|
+
disposeCalls += 1;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const refusal = await bakeFont(ttfPath, outDir, factory).catch((error) => error);
|
|
208
|
+
expect(refusal).toBeInstanceOf(FontError);
|
|
209
|
+
expect(refusal).toMatchObject({ code: 'bake-failed' });
|
|
210
|
+
expect((refusal as FontError).detail).toMatchObject({ path: outDir });
|
|
211
|
+
expect(typeof (refusal as FontError).detail?.cause).toBe('string');
|
|
212
|
+
expect((refusal as FontError).detail?.cause).toContain('ENOTDIR');
|
|
213
|
+
expect((refusal as FontError).hint).toContain('output');
|
|
214
|
+
expect(factoryCalls).toBe(0);
|
|
215
|
+
expect(generateCalls).toBe(0);
|
|
216
|
+
expect(disposeCalls).toBe(0);
|
|
217
|
+
await expect(stat(join(outDir, 'Valid.atlas.png'))).rejects.toMatchObject({
|
|
218
|
+
code: 'ENOTDIR',
|
|
219
|
+
});
|
|
220
|
+
await expect(stat(join(outDir, 'Valid.meta.json'))).rejects.toMatchObject({
|
|
221
|
+
code: 'ENOTDIR',
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await rm(blockedParent);
|
|
225
|
+
await mkdir(blockedParent);
|
|
226
|
+
const repaired = await bakeFont(ttfPath, outDir, factory);
|
|
227
|
+
const repairedAtlas = await readFile(repaired.atlasPath);
|
|
228
|
+
const repairedSidecar = await readFile(repaired.sidecarPath, 'utf8');
|
|
229
|
+
expect(repaired).toEqual({
|
|
230
|
+
atlasPath: join(outDir, 'Valid.atlas.png'),
|
|
231
|
+
sidecarPath: join(outDir, 'Valid.meta.json'),
|
|
232
|
+
});
|
|
233
|
+
expect(factoryCalls).toBe(1);
|
|
234
|
+
expect(generateCalls).toBe(1);
|
|
235
|
+
expect(disposeCalls).toBe(1);
|
|
236
|
+
|
|
237
|
+
const repeated = await bakeFont(ttfPath, outDir, factory);
|
|
238
|
+
expect(await readFile(repeated.atlasPath)).toEqual(repairedAtlas);
|
|
239
|
+
expect(await readFile(repeated.sidecarPath, 'utf8')).toBe(repairedSidecar);
|
|
240
|
+
expect(repeated).toEqual(repaired);
|
|
241
|
+
expect(factoryCalls).toBe(2);
|
|
242
|
+
expect(generateCalls).toBe(2);
|
|
243
|
+
expect(disposeCalls).toBe(2);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('(d2) atlas-target refusal is structured and repairs on the same path', async () => {
|
|
247
|
+
const ttfPath = join(tmpRoot, 'Valid.ttf');
|
|
248
|
+
const outDir = join(tmpRoot, 'output');
|
|
249
|
+
await writeFile(ttfPath, TTF_MAGIC);
|
|
250
|
+
await mkdir(outDir);
|
|
251
|
+
const atlasPath = join(outDir, 'Valid.atlas.png');
|
|
252
|
+
const sidecarPath = join(outDir, 'Valid.meta.json');
|
|
253
|
+
await mkdir(atlasPath);
|
|
254
|
+
|
|
255
|
+
let factoryCalls = 0;
|
|
256
|
+
let generateCalls = 0;
|
|
257
|
+
let disposeCalls = 0;
|
|
258
|
+
const factory = async (): Promise<MsdfGenerator> => {
|
|
259
|
+
factoryCalls += 1;
|
|
260
|
+
return {
|
|
261
|
+
generateAtlas: async (bytes) => {
|
|
262
|
+
generateCalls += 1;
|
|
263
|
+
expect(bytes).toEqual(TTF_MAGIC);
|
|
264
|
+
return {
|
|
265
|
+
texture: { width: 1, height: 1, data: new Uint8Array([255, 0, 255, 255]) },
|
|
266
|
+
glyphs: [],
|
|
267
|
+
metrics: { lineHeight: 1, ascender: 1 },
|
|
268
|
+
textureSize: [1, 1],
|
|
269
|
+
fieldRange: 4,
|
|
270
|
+
};
|
|
271
|
+
},
|
|
272
|
+
dispose: async () => {
|
|
273
|
+
disposeCalls += 1;
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const refusal = await bakeFont(ttfPath, outDir, factory).catch((error) => error);
|
|
279
|
+
expect(refusal).toBeInstanceOf(FontError);
|
|
280
|
+
expect(refusal).toMatchObject({
|
|
281
|
+
code: 'bake-failed',
|
|
282
|
+
hint: expect.stringContaining('atlas'),
|
|
283
|
+
detail: {
|
|
284
|
+
path: atlasPath,
|
|
285
|
+
cause: expect.stringContaining('EISDIR'),
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
expect((refusal as FontError).detail?.cause).toContain(atlasPath);
|
|
289
|
+
const refusedTarget = await stat(atlasPath);
|
|
290
|
+
expect(refusedTarget.isDirectory()).toBe(true);
|
|
291
|
+
await expect(stat(sidecarPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
292
|
+
expect(factoryCalls).toBe(1);
|
|
293
|
+
expect(generateCalls).toBe(1);
|
|
294
|
+
expect(disposeCalls).toBe(1);
|
|
295
|
+
|
|
296
|
+
await rm(atlasPath, { recursive: true, force: true });
|
|
297
|
+
const repaired = await bakeFont(ttfPath, outDir, factory);
|
|
298
|
+
const repairedAtlas = await readFile(repaired.atlasPath);
|
|
299
|
+
const repairedSidecar = await readFile(repaired.sidecarPath, 'utf8');
|
|
300
|
+
expect(repaired).toEqual({ atlasPath, sidecarPath });
|
|
301
|
+
expect(factoryCalls).toBe(2);
|
|
302
|
+
expect(generateCalls).toBe(2);
|
|
303
|
+
expect(disposeCalls).toBe(2);
|
|
304
|
+
|
|
305
|
+
const repeated = await bakeFont(ttfPath, outDir, factory);
|
|
306
|
+
expect(await readFile(repeated.atlasPath)).toEqual(repairedAtlas);
|
|
307
|
+
expect(await readFile(repeated.sidecarPath, 'utf8')).toBe(repairedSidecar);
|
|
308
|
+
expect(repeated).toEqual(repaired);
|
|
309
|
+
expect(factoryCalls).toBe(3);
|
|
310
|
+
expect(generateCalls).toBe(3);
|
|
311
|
+
expect(disposeCalls).toBe(3);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('(d3) sidecar-target refusal is structured and repairs on the same path', async () => {
|
|
315
|
+
const ttfPath = join(tmpRoot, 'Valid.ttf');
|
|
316
|
+
const outDir = join(tmpRoot, 'output');
|
|
317
|
+
await writeFile(ttfPath, TTF_MAGIC);
|
|
318
|
+
await mkdir(outDir);
|
|
319
|
+
const atlasPath = join(outDir, 'Valid.atlas.png');
|
|
320
|
+
const sidecarPath = join(outDir, 'Valid.meta.json');
|
|
321
|
+
await mkdir(sidecarPath);
|
|
322
|
+
|
|
323
|
+
let factoryCalls = 0;
|
|
324
|
+
let generateCalls = 0;
|
|
325
|
+
let disposeCalls = 0;
|
|
326
|
+
const factory = async (): Promise<MsdfGenerator> => {
|
|
327
|
+
factoryCalls += 1;
|
|
328
|
+
return {
|
|
329
|
+
generateAtlas: async (bytes) => {
|
|
330
|
+
generateCalls += 1;
|
|
331
|
+
expect(bytes).toEqual(TTF_MAGIC);
|
|
332
|
+
return {
|
|
333
|
+
texture: { width: 1, height: 1, data: new Uint8Array([255, 64, 128, 255]) },
|
|
334
|
+
glyphs: [],
|
|
335
|
+
metrics: { lineHeight: 1, ascender: 1 },
|
|
336
|
+
textureSize: [1, 1],
|
|
337
|
+
fieldRange: 4,
|
|
338
|
+
};
|
|
339
|
+
},
|
|
340
|
+
dispose: async () => {
|
|
341
|
+
disposeCalls += 1;
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const refusal = await bakeFont(ttfPath, outDir, factory).catch((error) => error);
|
|
347
|
+
expect(refusal).toBeInstanceOf(FontError);
|
|
348
|
+
expect(refusal).toMatchObject({
|
|
349
|
+
code: 'bake-failed',
|
|
350
|
+
expected: expect.stringContaining('sidecar'),
|
|
351
|
+
hint: expect.stringContaining('sidecar'),
|
|
352
|
+
detail: {
|
|
353
|
+
path: sidecarPath,
|
|
354
|
+
cause: expect.stringContaining('EISDIR'),
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
expect((refusal as FontError).code).not.toBe('EISDIR');
|
|
358
|
+
expect('atlasPath' in (refusal as object)).toBe(false);
|
|
359
|
+
const cause = (refusal as FontError).detail?.cause;
|
|
360
|
+
expect(typeof cause).toBe('string');
|
|
361
|
+
expect((cause as string).length).toBeLessThan(1024);
|
|
362
|
+
expect((await stat(sidecarPath)).isDirectory()).toBe(true);
|
|
363
|
+
const refusedAtlas = await readFile(atlasPath);
|
|
364
|
+
expect(refusedAtlas.length).toBeGreaterThan(0);
|
|
365
|
+
expect(factoryCalls).toBe(1);
|
|
366
|
+
expect(generateCalls).toBe(1);
|
|
367
|
+
expect(disposeCalls).toBe(1);
|
|
368
|
+
|
|
369
|
+
await rm(sidecarPath, { recursive: true, force: true });
|
|
370
|
+
const repaired = await bakeFont(ttfPath, outDir, factory);
|
|
371
|
+
const repairedAtlas = await readFile(repaired.atlasPath);
|
|
372
|
+
const repairedSidecar = await readFile(repaired.sidecarPath, 'utf8');
|
|
373
|
+
expect(repaired).toEqual({ atlasPath, sidecarPath });
|
|
374
|
+
expect(repairedAtlas).toEqual(refusedAtlas);
|
|
375
|
+
expect(JSON.parse(repairedSidecar)).toMatchObject({ importer: 'font' });
|
|
376
|
+
expect(factoryCalls).toBe(2);
|
|
377
|
+
expect(generateCalls).toBe(2);
|
|
378
|
+
expect(disposeCalls).toBe(2);
|
|
379
|
+
|
|
380
|
+
const repeated = await bakeFont(ttfPath, outDir, factory);
|
|
381
|
+
expect(await readFile(repeated.atlasPath)).toEqual(repairedAtlas);
|
|
382
|
+
expect(await readFile(repeated.sidecarPath, 'utf8')).toBe(repairedSidecar);
|
|
383
|
+
expect(repeated).toEqual(repaired);
|
|
384
|
+
expect(factoryCalls).toBe(3);
|
|
385
|
+
expect(generateCalls).toBe(3);
|
|
386
|
+
expect(disposeCalls).toBe(3);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('(d4) malformed atlas textures fail before publication and repair on the same factory', async () => {
|
|
390
|
+
const ttfPath = join(tmpRoot, 'MalformedAtlas.ttf');
|
|
391
|
+
await writeFile(ttfPath, TTF_MAGIC);
|
|
392
|
+
|
|
393
|
+
const repairedAtlas: BakeAtlas = {
|
|
394
|
+
texture: { width: 2, height: 2, data: new Uint8Array(16) },
|
|
395
|
+
glyphs: [
|
|
396
|
+
{
|
|
397
|
+
unicode: 65,
|
|
398
|
+
advance: 10,
|
|
399
|
+
xoffset: 1,
|
|
400
|
+
yoffset: 2,
|
|
401
|
+
atlasPosition: [0, 0],
|
|
402
|
+
atlasSize: [1, 1],
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
metrics: { lineHeight: 20, ascender: 16 },
|
|
406
|
+
textureSize: [2, 2],
|
|
407
|
+
fieldRange: 4,
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const malformedCases: readonly [string, (atlas: BakeAtlas) => BakeAtlas][] = [
|
|
411
|
+
[
|
|
412
|
+
'non-positive-dimension',
|
|
413
|
+
(atlas) => ({
|
|
414
|
+
...atlas,
|
|
415
|
+
texture: { ...atlas.texture, width: 0 },
|
|
416
|
+
}),
|
|
417
|
+
],
|
|
418
|
+
[
|
|
419
|
+
'non-finite-dimension',
|
|
420
|
+
(atlas) => ({
|
|
421
|
+
...atlas,
|
|
422
|
+
texture: { ...atlas.texture, height: Number.NaN },
|
|
423
|
+
}),
|
|
424
|
+
],
|
|
425
|
+
[
|
|
426
|
+
'non-integer-dimension',
|
|
427
|
+
(atlas) => ({
|
|
428
|
+
...atlas,
|
|
429
|
+
texture: { ...atlas.texture, width: 1.5 },
|
|
430
|
+
}),
|
|
431
|
+
],
|
|
432
|
+
[
|
|
433
|
+
'rgba-length-mismatch',
|
|
434
|
+
(atlas) => ({
|
|
435
|
+
...atlas,
|
|
436
|
+
texture: { ...atlas.texture, data: new Uint8Array(15) },
|
|
437
|
+
}),
|
|
438
|
+
],
|
|
439
|
+
[
|
|
440
|
+
'texture-size-mismatch',
|
|
441
|
+
(atlas) => ({
|
|
442
|
+
...atlas,
|
|
443
|
+
textureSize: [1, 2],
|
|
444
|
+
}),
|
|
445
|
+
],
|
|
446
|
+
];
|
|
447
|
+
|
|
448
|
+
for (const [caseName, makeMalformed] of malformedCases) {
|
|
449
|
+
const outDir = join(tmpRoot, `malformed-${caseName}`);
|
|
450
|
+
const atlasPath = join(outDir, 'MalformedAtlas.atlas.png');
|
|
451
|
+
const sidecarPath = join(outDir, 'MalformedAtlas.meta.json');
|
|
452
|
+
let returnMalformed = true;
|
|
453
|
+
let factoryCalls = 0;
|
|
454
|
+
let generateCalls = 0;
|
|
455
|
+
let disposeCalls = 0;
|
|
456
|
+
const factory = async (): Promise<MsdfGenerator> => {
|
|
457
|
+
factoryCalls += 1;
|
|
458
|
+
return {
|
|
459
|
+
generateAtlas: async (bytes) => {
|
|
460
|
+
generateCalls += 1;
|
|
461
|
+
expect(bytes).toEqual(TTF_MAGIC);
|
|
462
|
+
return returnMalformed ? makeMalformed(repairedAtlas) : repairedAtlas;
|
|
463
|
+
},
|
|
464
|
+
dispose: async () => {
|
|
465
|
+
disposeCalls += 1;
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const refusal = await bakeFont(ttfPath, outDir, factory).catch((error) => error);
|
|
471
|
+
expect(refusal).toBeInstanceOf(FontError);
|
|
472
|
+
expect(refusal).toMatchObject({
|
|
473
|
+
code: 'bake-failed',
|
|
474
|
+
expected: expect.stringContaining('atlas'),
|
|
475
|
+
hint: expect.stringContaining('atlas'),
|
|
476
|
+
detail: { cause: expect.stringContaining('malformed-atlas') },
|
|
477
|
+
});
|
|
478
|
+
const cause = (refusal as FontError).detail?.cause;
|
|
479
|
+
expect(typeof cause).toBe('string');
|
|
480
|
+
expect((cause as string).length).toBeLessThan(256);
|
|
481
|
+
expect('atlasPath' in (refusal as object)).toBe(false);
|
|
482
|
+
await expect(stat(atlasPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
483
|
+
await expect(stat(sidecarPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
484
|
+
expect(factoryCalls).toBe(1);
|
|
485
|
+
expect(generateCalls).toBe(1);
|
|
486
|
+
expect(disposeCalls).toBe(1);
|
|
487
|
+
|
|
488
|
+
returnMalformed = false;
|
|
489
|
+
const repaired = await bakeFont(ttfPath, outDir, factory);
|
|
490
|
+
const repairedAtlasBytes = await readFile(repaired.atlasPath);
|
|
491
|
+
const repairedSidecar = await readFile(repaired.sidecarPath, 'utf8');
|
|
492
|
+
expect(repaired).toEqual({ atlasPath, sidecarPath });
|
|
493
|
+
expect(JSON.parse(repairedSidecar)).toMatchObject({
|
|
494
|
+
common: { atlasWidth: 2, atlasHeight: 2 },
|
|
495
|
+
glyphs: {
|
|
496
|
+
'65': {
|
|
497
|
+
advance: 10,
|
|
498
|
+
bearingX: 1,
|
|
499
|
+
bearingY: 2,
|
|
500
|
+
size: { w: 1, h: 1 },
|
|
501
|
+
region: { x: 0, y: 0, w: 1, h: 1 },
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
expect(factoryCalls).toBe(2);
|
|
506
|
+
expect(generateCalls).toBe(2);
|
|
507
|
+
expect(disposeCalls).toBe(2);
|
|
508
|
+
|
|
509
|
+
const repeated = await bakeFont(ttfPath, outDir, factory);
|
|
510
|
+
expect(repeated).toEqual(repaired);
|
|
511
|
+
expect(await readFile(repeated.atlasPath)).toEqual(repairedAtlasBytes);
|
|
512
|
+
expect(await readFile(repeated.sidecarPath, 'utf8')).toBe(repairedSidecar);
|
|
513
|
+
expect(factoryCalls).toBe(3);
|
|
514
|
+
expect(generateCalls).toBe(3);
|
|
515
|
+
expect(disposeCalls).toBe(3);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('(d5) malformed glyph metrics fail before publication and repair on the same factory', async () => {
|
|
520
|
+
const ttfPath = join(tmpRoot, 'MalformedGlyphs.ttf');
|
|
521
|
+
await writeFile(ttfPath, TTF_MAGIC);
|
|
522
|
+
|
|
523
|
+
const validAtlas: BakeAtlas = {
|
|
524
|
+
texture: { width: 2, height: 2, data: new Uint8Array(16) },
|
|
525
|
+
glyphs: [
|
|
526
|
+
{
|
|
527
|
+
unicode: 65,
|
|
528
|
+
advance: 10,
|
|
529
|
+
xoffset: 1,
|
|
530
|
+
yoffset: 2,
|
|
531
|
+
atlasPosition: [0, 0],
|
|
532
|
+
atlasSize: [1, 1],
|
|
533
|
+
},
|
|
534
|
+
],
|
|
535
|
+
metrics: { lineHeight: 20, ascender: 16 },
|
|
536
|
+
textureSize: [2, 2],
|
|
537
|
+
fieldRange: 4,
|
|
538
|
+
};
|
|
539
|
+
const baseGlyph = validAtlas.glyphs[0];
|
|
540
|
+
if (baseGlyph === undefined) {
|
|
541
|
+
throw new Error('test fixture must contain a base glyph');
|
|
542
|
+
}
|
|
543
|
+
const malformedCases: readonly [string, (atlas: BakeAtlas) => BakeAtlas][] = [
|
|
544
|
+
[
|
|
545
|
+
'non-finite-metric',
|
|
546
|
+
(atlas) => ({
|
|
547
|
+
...atlas,
|
|
548
|
+
glyphs: [{ ...baseGlyph, advance: Number.NaN }],
|
|
549
|
+
}),
|
|
550
|
+
],
|
|
551
|
+
[
|
|
552
|
+
'duplicate-unicode',
|
|
553
|
+
(atlas) => ({
|
|
554
|
+
...atlas,
|
|
555
|
+
glyphs: [...atlas.glyphs, { ...baseGlyph, atlasPosition: [1, 0] }],
|
|
556
|
+
}),
|
|
557
|
+
],
|
|
558
|
+
[
|
|
559
|
+
'out-of-atlas-region',
|
|
560
|
+
(atlas) => ({
|
|
561
|
+
...atlas,
|
|
562
|
+
glyphs: [{ ...baseGlyph, atlasPosition: [1, 1], atlasSize: [2, 1] }],
|
|
563
|
+
}),
|
|
564
|
+
],
|
|
565
|
+
];
|
|
566
|
+
|
|
567
|
+
for (const [caseName, makeMalformed] of malformedCases) {
|
|
568
|
+
const outDir = join(tmpRoot, `malformed-glyph-${caseName}`);
|
|
569
|
+
const atlasPath = join(outDir, 'MalformedGlyphs.atlas.png');
|
|
570
|
+
const sidecarPath = join(outDir, 'MalformedGlyphs.meta.json');
|
|
571
|
+
let returnMalformed = true;
|
|
572
|
+
let factoryCalls = 0;
|
|
573
|
+
let generateCalls = 0;
|
|
574
|
+
let disposeCalls = 0;
|
|
575
|
+
const factory = async (): Promise<MsdfGenerator> => {
|
|
576
|
+
factoryCalls += 1;
|
|
577
|
+
return {
|
|
578
|
+
generateAtlas: async (bytes) => {
|
|
579
|
+
generateCalls += 1;
|
|
580
|
+
expect(bytes).toEqual(TTF_MAGIC);
|
|
581
|
+
return returnMalformed ? makeMalformed(validAtlas) : validAtlas;
|
|
582
|
+
},
|
|
583
|
+
dispose: async () => {
|
|
584
|
+
disposeCalls += 1;
|
|
585
|
+
},
|
|
586
|
+
};
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const refusal = await bakeFont(ttfPath, outDir, factory).catch((error) => error);
|
|
590
|
+
expect(refusal).toBeInstanceOf(FontError);
|
|
591
|
+
expect(refusal).toMatchObject({
|
|
592
|
+
code: 'bake-failed',
|
|
593
|
+
expected: expect.stringContaining('glyph'),
|
|
594
|
+
hint: expect.stringContaining('glyph'),
|
|
595
|
+
detail: { cause: expect.stringContaining('malformed-glyph') },
|
|
596
|
+
});
|
|
597
|
+
const cause = (refusal as FontError).detail?.cause;
|
|
598
|
+
expect(typeof cause).toBe('string');
|
|
599
|
+
expect((cause as string).length).toBeLessThan(256);
|
|
600
|
+
expect('atlasPath' in (refusal as object)).toBe(false);
|
|
601
|
+
await expect(stat(atlasPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
602
|
+
await expect(stat(sidecarPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
603
|
+
expect(factoryCalls).toBe(1);
|
|
604
|
+
expect(generateCalls).toBe(1);
|
|
605
|
+
expect(disposeCalls).toBe(1);
|
|
606
|
+
|
|
607
|
+
returnMalformed = false;
|
|
608
|
+
const repaired = await bakeFont(ttfPath, outDir, factory);
|
|
609
|
+
const repairedAtlasBytes = await readFile(repaired.atlasPath);
|
|
610
|
+
const repairedSidecar = await readFile(repaired.sidecarPath, 'utf8');
|
|
611
|
+
expect(repaired).toEqual({ atlasPath, sidecarPath });
|
|
612
|
+
expect(factoryCalls).toBe(2);
|
|
613
|
+
expect(generateCalls).toBe(2);
|
|
614
|
+
expect(disposeCalls).toBe(2);
|
|
615
|
+
|
|
616
|
+
const repeated = await bakeFont(ttfPath, outDir, factory);
|
|
617
|
+
expect(repeated).toEqual(repaired);
|
|
618
|
+
expect(await readFile(repeated.atlasPath)).toEqual(repairedAtlasBytes);
|
|
619
|
+
expect(await readFile(repeated.sidecarPath, 'utf8')).toBe(repairedSidecar);
|
|
620
|
+
expect(factoryCalls).toBe(3);
|
|
621
|
+
expect(generateCalls).toBe(3);
|
|
622
|
+
expect(disposeCalls).toBe(3);
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('(d6) generator disposal rejection is structured and preserves primary failure', async () => {
|
|
627
|
+
const ttfPath = join(tmpRoot, 'GeneratorDispose.ttf');
|
|
628
|
+
await writeFile(ttfPath, TTF_MAGIC);
|
|
629
|
+
|
|
630
|
+
const validAtlas: BakeAtlas = {
|
|
631
|
+
texture: { width: 2, height: 2, data: new Uint8Array(16) },
|
|
632
|
+
glyphs: [
|
|
633
|
+
{
|
|
634
|
+
unicode: 65,
|
|
635
|
+
advance: 10,
|
|
636
|
+
xoffset: 1,
|
|
637
|
+
yoffset: 2,
|
|
638
|
+
atlasPosition: [0, 0],
|
|
639
|
+
atlasSize: [1, 1],
|
|
640
|
+
},
|
|
641
|
+
],
|
|
642
|
+
metrics: { lineHeight: 20, ascender: 16 },
|
|
643
|
+
textureSize: [2, 2],
|
|
644
|
+
fieldRange: 4,
|
|
645
|
+
};
|
|
646
|
+
const cases: readonly [string, 'dispose' | 'primary'][] = [
|
|
647
|
+
['dispose-rejection', 'dispose'],
|
|
648
|
+
['primary-failure-preservation', 'primary'],
|
|
649
|
+
];
|
|
650
|
+
|
|
651
|
+
for (const [caseName, failure] of cases) {
|
|
652
|
+
const outDir = join(tmpRoot, `generator-dispose-${caseName}`);
|
|
653
|
+
const atlasPath = join(outDir, 'GeneratorDispose.atlas.png');
|
|
654
|
+
const sidecarPath = join(outDir, 'GeneratorDispose.meta.json');
|
|
655
|
+
let fail = true;
|
|
656
|
+
let factoryCalls = 0;
|
|
657
|
+
let generateCalls = 0;
|
|
658
|
+
let disposeCalls = 0;
|
|
659
|
+
const factory = async (): Promise<MsdfGenerator> => {
|
|
660
|
+
factoryCalls += 1;
|
|
661
|
+
return {
|
|
662
|
+
generateAtlas: async (bytes) => {
|
|
663
|
+
generateCalls += 1;
|
|
664
|
+
expect(bytes).toEqual(TTF_MAGIC);
|
|
665
|
+
if (failure === 'primary' && fail) {
|
|
666
|
+
throw new Error('primary generator failure');
|
|
667
|
+
}
|
|
668
|
+
return validAtlas;
|
|
669
|
+
},
|
|
670
|
+
dispose: async () => {
|
|
671
|
+
disposeCalls += 1;
|
|
672
|
+
if (failure === 'dispose' && fail) {
|
|
673
|
+
throw new Error('dispose sentinel must not escape');
|
|
674
|
+
}
|
|
675
|
+
if (failure === 'primary' && fail) {
|
|
676
|
+
throw new Error('cleanup sentinel must not replace primary');
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
};
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
const refusal = await bakeFont(ttfPath, outDir, factory).catch((error) => error);
|
|
683
|
+
expect(refusal).toBeInstanceOf(FontError);
|
|
684
|
+
expect(refusal).toMatchObject({
|
|
685
|
+
code: 'bake-failed',
|
|
686
|
+
expected: expect.stringContaining('generator'),
|
|
687
|
+
hint: expect.stringContaining('generator'),
|
|
688
|
+
detail: { cause: expect.any(String) },
|
|
689
|
+
});
|
|
690
|
+
const cause = (refusal as FontError).detail?.cause;
|
|
691
|
+
expect(typeof cause).toBe('string');
|
|
692
|
+
expect((cause as string).length).toBeLessThan(256);
|
|
693
|
+
if (failure === 'dispose') {
|
|
694
|
+
expect(cause).toContain('dispose');
|
|
695
|
+
} else {
|
|
696
|
+
expect(cause).toBe('primary generator failure');
|
|
697
|
+
expect(cause).not.toContain('cleanup sentinel');
|
|
698
|
+
}
|
|
699
|
+
expect('atlasPath' in (refusal as object)).toBe(false);
|
|
700
|
+
await expect(stat(atlasPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
701
|
+
await expect(stat(sidecarPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
702
|
+
expect(factoryCalls).toBe(1);
|
|
703
|
+
expect(generateCalls).toBe(1);
|
|
704
|
+
expect(disposeCalls).toBe(1);
|
|
705
|
+
|
|
706
|
+
fail = false;
|
|
707
|
+
const repaired = await bakeFont(ttfPath, outDir, factory);
|
|
708
|
+
const repairedAtlasBytes = await readFile(repaired.atlasPath);
|
|
709
|
+
const repairedSidecar = await readFile(repaired.sidecarPath, 'utf8');
|
|
710
|
+
expect(repaired).toEqual({ atlasPath, sidecarPath });
|
|
711
|
+
expect(factoryCalls).toBe(2);
|
|
712
|
+
expect(generateCalls).toBe(2);
|
|
713
|
+
expect(disposeCalls).toBe(2);
|
|
714
|
+
|
|
715
|
+
const repeated = await bakeFont(ttfPath, outDir, factory);
|
|
716
|
+
expect(repeated).toEqual(repaired);
|
|
717
|
+
expect(await readFile(repeated.atlasPath)).toEqual(repairedAtlasBytes);
|
|
718
|
+
expect(await readFile(repeated.sidecarPath, 'utf8')).toBe(repairedSidecar);
|
|
719
|
+
expect(factoryCalls).toBe(3);
|
|
720
|
+
expect(generateCalls).toBe(3);
|
|
721
|
+
expect(disposeCalls).toBe(3);
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('(e) unreadable source yields structured bake-failed before generator or output work', async () => {
|
|
726
|
+
const ttfPath = join(tmpRoot, 'missing-source.ttf');
|
|
727
|
+
const outDir = join(tmpRoot, 'untouched-output');
|
|
728
|
+
let factoryCalls = 0;
|
|
729
|
+
let generateCalls = 0;
|
|
730
|
+
let disposeCalls = 0;
|
|
731
|
+
const factory = async (): Promise<MsdfGenerator> => {
|
|
732
|
+
factoryCalls += 1;
|
|
733
|
+
return {
|
|
734
|
+
generateAtlas: async () => {
|
|
735
|
+
generateCalls += 1;
|
|
736
|
+
return {} as BakeAtlas;
|
|
737
|
+
},
|
|
738
|
+
dispose: async () => {
|
|
739
|
+
disposeCalls += 1;
|
|
740
|
+
},
|
|
741
|
+
};
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
const error = await bakeFont(ttfPath, outDir, factory).catch((e) => e);
|
|
745
|
+
expect(error).toBeInstanceOf(FontError);
|
|
746
|
+
expect(error).toMatchObject({
|
|
747
|
+
code: 'bake-failed',
|
|
748
|
+
expected: expect.stringContaining('readable'),
|
|
749
|
+
hint: expect.stringContaining('source'),
|
|
750
|
+
});
|
|
751
|
+
expect((error as FontError).detail).toMatchObject({
|
|
752
|
+
path: ttfPath,
|
|
753
|
+
cause: expect.stringContaining('ENOENT'),
|
|
754
|
+
});
|
|
755
|
+
expect(factoryCalls).toBe(0);
|
|
756
|
+
expect(generateCalls).toBe(0);
|
|
757
|
+
expect(disposeCalls).toBe(0);
|
|
758
|
+
await expect(stat(outDir)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('(e) repairs the same source path and repeats deterministic output with balanced disposal', async () => {
|
|
762
|
+
const ttfPath = join(tmpRoot, 'repair-in-place.ttf');
|
|
763
|
+
const outDir = join(tmpRoot, 'repair-output');
|
|
764
|
+
let factoryCalls = 0;
|
|
765
|
+
let generateCalls = 0;
|
|
766
|
+
let disposeCalls = 0;
|
|
767
|
+
const factory = async (): Promise<MsdfGenerator> => {
|
|
768
|
+
factoryCalls += 1;
|
|
769
|
+
return {
|
|
770
|
+
generateAtlas: async (bytes) => {
|
|
771
|
+
generateCalls += 1;
|
|
772
|
+
expect(bytes.slice(0, TTF_MAGIC.length)).toEqual(TTF_MAGIC);
|
|
773
|
+
return {
|
|
774
|
+
texture: { width: 2, height: 2, data: new Uint8Array(16) },
|
|
775
|
+
glyphs: [
|
|
776
|
+
{
|
|
777
|
+
unicode: 65,
|
|
778
|
+
advance: 10,
|
|
779
|
+
xoffset: 1,
|
|
780
|
+
yoffset: 2,
|
|
781
|
+
atlasPosition: [0, 0],
|
|
782
|
+
atlasSize: [1, 1],
|
|
783
|
+
},
|
|
784
|
+
],
|
|
785
|
+
metrics: { lineHeight: 20, ascender: 16 },
|
|
786
|
+
textureSize: [2, 2],
|
|
787
|
+
fieldRange: 4,
|
|
788
|
+
};
|
|
789
|
+
},
|
|
790
|
+
dispose: async () => {
|
|
791
|
+
disposeCalls += 1;
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
const missing = await bakeFont(ttfPath, outDir, factory).catch((e) => e);
|
|
797
|
+
expect(missing).toBeInstanceOf(FontError);
|
|
798
|
+
expect((missing as FontError).code).toBe('bake-failed');
|
|
799
|
+
expect(factoryCalls).toBe(0);
|
|
800
|
+
expect(generateCalls).toBe(0);
|
|
801
|
+
expect(disposeCalls).toBe(0);
|
|
802
|
+
|
|
803
|
+
await writeFile(ttfPath, TTF_MAGIC);
|
|
804
|
+
const first = await bakeFont(ttfPath, outDir, factory);
|
|
805
|
+
const firstPng = await readFile(first.atlasPath);
|
|
806
|
+
const firstSidecar = await readFile(first.sidecarPath, 'utf8');
|
|
807
|
+
|
|
808
|
+
const second = await bakeFont(ttfPath, outDir, factory);
|
|
809
|
+
const secondPng = await readFile(second.atlasPath);
|
|
810
|
+
const secondSidecar = await readFile(second.sidecarPath, 'utf8');
|
|
811
|
+
|
|
812
|
+
expect(first).toEqual({
|
|
813
|
+
atlasPath: join(outDir, 'repair-in-place.atlas.png'),
|
|
814
|
+
sidecarPath: join(outDir, 'repair-in-place.meta.json'),
|
|
815
|
+
});
|
|
816
|
+
expect(second).toEqual(first);
|
|
817
|
+
expect(secondPng).toEqual(firstPng);
|
|
818
|
+
expect(secondSidecar).toBe(firstSidecar);
|
|
819
|
+
expect(factoryCalls).toBe(2);
|
|
820
|
+
expect(generateCalls).toBe(2);
|
|
821
|
+
expect(disposeCalls).toBe(2);
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
describe('font producer sourceKey', () => {
|
|
828
|
+
it('assigns unique stable keys to atlas, sampler, and font outputs', () => {
|
|
829
|
+
const keys = fontOutputSourceKeys();
|
|
830
|
+
expect(keys).toEqual(['font:texture', 'font:sampler', 'font:font']);
|
|
831
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
832
|
+
expect(sourceKeyForFontOutput('texture')).toBe('font:texture');
|
|
833
|
+
expect(sourceKeyForFontOutput('')).toBeUndefined();
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
describe('font package root surface', () => {
|
|
838
|
+
it('keeps the version authority in package metadata, not a root mirror', async () => {
|
|
839
|
+
const mod = await import('../index.js');
|
|
840
|
+
expect('FONT_PACKAGE_VERSION' in mod).toBe(false);
|
|
841
|
+
expect(typeof mod.fontContribution).toBe('object');
|
|
842
|
+
expect('bakeFont' in mod).toBe(false);
|
|
843
|
+
expect('runCliFont' in mod).toBe(false);
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
{
|
|
848
|
+
// ─── from font-importer.test.ts ───
|
|
849
|
+
|
|
850
|
+
const ATLAS_GUID = '019e3969-1d43-7610-8810-e80dbd491d90';
|
|
851
|
+
const FONT_GUID = '019e3969-1d43-7610-8810-e80dbd491d91';
|
|
852
|
+
const SAMPLER_GUID = '019e3969-1d43-7610-8810-e80dbd491d92';
|
|
853
|
+
|
|
854
|
+
function mockAtlas(): BakeAtlas {
|
|
855
|
+
return {
|
|
856
|
+
texture: { width: 16, height: 16, data: new Uint8Array(16 * 16 * 4) },
|
|
857
|
+
glyphs: [
|
|
858
|
+
{
|
|
859
|
+
unicode: 65,
|
|
860
|
+
advance: 10,
|
|
861
|
+
xoffset: 1,
|
|
862
|
+
yoffset: 2,
|
|
863
|
+
atlasPosition: [0, 0],
|
|
864
|
+
atlasSize: [8, 8],
|
|
865
|
+
},
|
|
866
|
+
],
|
|
867
|
+
metrics: { lineHeight: 20, ascender: 16 },
|
|
868
|
+
textureSize: [16, 16],
|
|
869
|
+
fieldRange: 4,
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function mockGeneratorFactory(): () => Promise<MsdfGenerator> {
|
|
874
|
+
return async () => ({
|
|
875
|
+
generateAtlas: async () => mockAtlas(),
|
|
876
|
+
dispose: async () => undefined,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function makeCtx(subAssets: readonly ImportSubAsset[]): ImportContext {
|
|
881
|
+
return {
|
|
882
|
+
source: 'roboto.ttf',
|
|
883
|
+
readSource: async () => ({ ok: true, value: new Uint8Array([0x00, 0x01, 0x00, 0x00]) }),
|
|
884
|
+
readSibling: async () => ({ ok: true, value: new Uint8Array() }),
|
|
885
|
+
decodeImage: async () => {
|
|
886
|
+
throw new Error('decodeImage not used by fontImporter');
|
|
887
|
+
},
|
|
888
|
+
subAssets,
|
|
889
|
+
importSettings: { generatorFactory: mockGeneratorFactory() },
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
describe('font-importer.test.ts', () => {
|
|
894
|
+
describe('fontImporter dispatch (AC-18)', () => {
|
|
895
|
+
it('an ImporterRegistry resolves meta.importer="font" to fontImporter', () => {
|
|
896
|
+
const registry = new ImporterRegistry();
|
|
897
|
+
registry.register(fontImporter);
|
|
898
|
+
const resolved = registry.get('font');
|
|
899
|
+
expect(resolved).toBe(fontImporter);
|
|
900
|
+
expect(resolved?.key).toBe('font');
|
|
901
|
+
expect(typeof resolved?.import).toBe('function');
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
describe('fontImporter bake mapping (AC-18)', () => {
|
|
906
|
+
it('maps the mock atlas onto declared atlas, sampler, and font sub-asset GUIDs', async () => {
|
|
907
|
+
const ctx = makeCtx([
|
|
908
|
+
{ guid: ATLAS_GUID, sourceIndex: 0, kind: 'texture' },
|
|
909
|
+
{ guid: SAMPLER_GUID, sourceIndex: 1, kind: 'sampler' },
|
|
910
|
+
{ guid: FONT_GUID, sourceIndex: 0, kind: 'font' },
|
|
911
|
+
]);
|
|
912
|
+
const result = await fontImporter.import(ctx);
|
|
913
|
+
expect(result.ok).toBe(true);
|
|
914
|
+
const produced: readonly ImportedAsset[] = result.ok ? result.value.assets : [];
|
|
915
|
+
|
|
916
|
+
const atlas = produced.find((a) => a.guid === ATLAS_GUID);
|
|
917
|
+
expect(atlas?.kind).toBe('texture');
|
|
918
|
+
|
|
919
|
+
const font = produced.find((a) => a.guid === FONT_GUID);
|
|
920
|
+
expect(font?.kind).toBe('font');
|
|
921
|
+
expect(font?.refs.map((r) => r.guid)).toEqual([ATLAS_GUID, SAMPLER_GUID]);
|
|
922
|
+
expect(font?.payload).toMatchObject({
|
|
923
|
+
atlasGuid: ATLAS_GUID,
|
|
924
|
+
samplerGuid: SAMPLER_GUID,
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
expect(produced).toContainEqual({
|
|
928
|
+
guid: SAMPLER_GUID,
|
|
929
|
+
kind: 'sampler',
|
|
930
|
+
payload: {
|
|
931
|
+
kind: 'sampler',
|
|
932
|
+
addressModeU: 'clamp-to-edge',
|
|
933
|
+
addressModeV: 'clamp-to-edge',
|
|
934
|
+
addressModeW: 'clamp-to-edge',
|
|
935
|
+
magFilter: 'linear',
|
|
936
|
+
minFilter: 'linear',
|
|
937
|
+
mipmapFilter: 'nearest',
|
|
938
|
+
},
|
|
939
|
+
refs: [],
|
|
940
|
+
artifacts: {},
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
// P1: fontImporter atlas lookup uses s.kind === 'texture' instead of 'image'.
|
|
945
|
+
it('P1: uses s.kind === "texture" to find atlas sub-asset', async () => {
|
|
946
|
+
const ctx = makeCtx([
|
|
947
|
+
{ guid: ATLAS_GUID, sourceIndex: 0, kind: 'texture' },
|
|
948
|
+
{ guid: SAMPLER_GUID, sourceIndex: 1, kind: 'sampler' },
|
|
949
|
+
{ guid: FONT_GUID, sourceIndex: 0, kind: 'font' },
|
|
950
|
+
]);
|
|
951
|
+
const result = await fontImporter.import(ctx);
|
|
952
|
+
expect(result.ok).toBe(true);
|
|
953
|
+
const produced: readonly ImportedAsset[] = result.ok ? result.value.assets : [];
|
|
954
|
+
|
|
955
|
+
const atlas = produced.find((a) => a.guid === ATLAS_GUID);
|
|
956
|
+
expect(atlas?.kind).toBe('texture');
|
|
957
|
+
|
|
958
|
+
const font = produced.find((a) => a.guid === FONT_GUID);
|
|
959
|
+
expect(font?.kind).toBe('font');
|
|
960
|
+
expect(font?.refs.map((r) => r.guid)).toEqual([ATLAS_GUID, SAMPLER_GUID]);
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
{
|
|
967
|
+
// ─── from plugin-discoverable.test.ts ───
|
|
968
|
+
|
|
969
|
+
const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
970
|
+
|
|
971
|
+
describe('plugin-discoverable.test.ts', () => {
|
|
972
|
+
describe('plugin-discoverable (forgeax-engine-remote-font)', () => {
|
|
973
|
+
describe('bake subcommand routing (a)', () => {
|
|
974
|
+
it('--help on root exits 0 and prints usage', async () => {
|
|
975
|
+
const code = await runCliFont(['--help']);
|
|
976
|
+
expect(code).toBe(0);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it('bake --help exits 0 and prints bake usage', async () => {
|
|
980
|
+
const code = await runCliFont(['bake', '--help']);
|
|
981
|
+
expect(code).toBe(0);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
it('bake with two positionals routes to the real bake (exits 1 on a missing source)', async () => {
|
|
985
|
+
const code = await runCliFont(['bake', 'font.ttf', 'out/']);
|
|
986
|
+
expect(code).toBe(1);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it('unknown subcommand exits 1', async () => {
|
|
990
|
+
const code = await runCliFont(['unknown-cmd']);
|
|
991
|
+
expect(code).toBe(1);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it('no subcommand with empty argv exits 0 (prints help)', async () => {
|
|
995
|
+
const code = await runCliFont([]);
|
|
996
|
+
expect(code).toBe(0);
|
|
997
|
+
});
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
describe('plugin bin naming contract (b)', () => {
|
|
1001
|
+
it('package.json bin field is forgeax-engine-remote-font -> ./dist/cli-font.mjs', async () => {
|
|
1002
|
+
const pkgPath = join(PKG_DIR, 'package.json');
|
|
1003
|
+
const raw = await readFile(pkgPath, 'utf-8');
|
|
1004
|
+
const pkg = JSON.parse(raw) as Record<string, unknown>;
|
|
1005
|
+
const bin = pkg.bin as Record<string, string>;
|
|
1006
|
+
expect(bin).toBeDefined();
|
|
1007
|
+
expect(typeof bin).toBe('object');
|
|
1008
|
+
expect(bin['forgeax-engine-remote-font']).toBe('./dist/cli-font.mjs');
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it('bin name starts with forgeax-engine-remote- (PLUGIN_PREFIX contract)', async () => {
|
|
1012
|
+
const pkgPath = join(PKG_DIR, 'package.json');
|
|
1013
|
+
const raw = await readFile(pkgPath, 'utf-8');
|
|
1014
|
+
const pkg = JSON.parse(raw) as Record<string, unknown>;
|
|
1015
|
+
const bin = pkg.bin as Record<string, string>;
|
|
1016
|
+
const binNames = Object.keys(bin);
|
|
1017
|
+
expect(binNames.length).toBe(1);
|
|
1018
|
+
expect(binNames[0]).toBe('forgeax-engine-remote-font');
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
describe('package.json bin contract (c)', () => {
|
|
1023
|
+
it('package name is @forgeax/engine-font', async () => {
|
|
1024
|
+
const pkgPath = join(PKG_DIR, 'package.json');
|
|
1025
|
+
const raw = await readFile(pkgPath, 'utf-8');
|
|
1026
|
+
const pkg = JSON.parse(raw) as Record<string, unknown>;
|
|
1027
|
+
expect(pkg.name).toBe('@forgeax/engine-font');
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
it('package exports ./dist/index.mjs as main', async () => {
|
|
1031
|
+
const pkgPath = join(PKG_DIR, 'package.json');
|
|
1032
|
+
const raw = await readFile(pkgPath, 'utf-8');
|
|
1033
|
+
const pkg = JSON.parse(raw) as Record<string, unknown>;
|
|
1034
|
+
expect(pkg.main).toBe('./dist/index.mjs');
|
|
1035
|
+
});
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
1039
|
+
}
|