@forgeax/engine-font 0.1.3 → 0.1.6
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 +5 -7
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli-font.d.ts.map +1 -1
- package/dist/cli-font.mjs +9 -135
- package/dist/cli-font.mjs.map +1 -1
- package/dist/font-importer.d.ts +1 -1
- package/dist/font-importer.d.ts.map +1 -1
- package/dist/font-importer.mjs +14 -182
- package/dist/font-importer.mjs.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/font-local-artifacts.test.ts +23 -245
- package/src/__tests__/font.unit.test.ts +3 -648
- package/src/cli-font.ts +10 -172
- package/src/font-importer.ts +12 -61
package/src/cli-font.ts
CHANGED
|
@@ -225,87 +225,6 @@ export interface BakeResult {
|
|
|
225
225
|
readonly sidecarPath: string;
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
function malformedAtlasTextureCause(atlas: BakeAtlas): string | undefined {
|
|
229
|
-
const texture = atlas.texture;
|
|
230
|
-
const textureSize = atlas.textureSize;
|
|
231
|
-
if (
|
|
232
|
-
!Number.isSafeInteger(texture.width) ||
|
|
233
|
-
texture.width <= 0 ||
|
|
234
|
-
!Number.isSafeInteger(texture.height) ||
|
|
235
|
-
texture.height <= 0
|
|
236
|
-
) {
|
|
237
|
-
return 'malformed-atlas: texture dimensions must be positive finite integers';
|
|
238
|
-
}
|
|
239
|
-
if (
|
|
240
|
-
!Number.isSafeInteger(textureSize[0]) ||
|
|
241
|
-
textureSize[0] <= 0 ||
|
|
242
|
-
!Number.isSafeInteger(textureSize[1]) ||
|
|
243
|
-
textureSize[1] <= 0
|
|
244
|
-
) {
|
|
245
|
-
return 'malformed-atlas: textureSize dimensions must be positive finite integers';
|
|
246
|
-
}
|
|
247
|
-
if (texture.width !== textureSize[0] || texture.height !== textureSize[1]) {
|
|
248
|
-
return 'malformed-atlas: texture dimensions must match textureSize';
|
|
249
|
-
}
|
|
250
|
-
const expectedBytes = texture.width * texture.height * 4;
|
|
251
|
-
if (!(texture.data instanceof Uint8Array) || texture.data.length !== expectedBytes) {
|
|
252
|
-
return 'malformed-atlas: RGBA data length must equal width * height * 4';
|
|
253
|
-
}
|
|
254
|
-
return undefined;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function malformedGlyphCause(atlas: BakeAtlas): string | undefined {
|
|
258
|
-
if (
|
|
259
|
-
!Number.isFinite(atlas.metrics.lineHeight) ||
|
|
260
|
-
!Number.isFinite(atlas.metrics.ascender) ||
|
|
261
|
-
!Number.isFinite(atlas.fieldRange)
|
|
262
|
-
) {
|
|
263
|
-
return 'malformed-glyph: common metrics must be finite';
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const [atlasWidth, atlasHeight] = atlas.textureSize;
|
|
267
|
-
const seenUnicode = new Set<number>();
|
|
268
|
-
for (let index = 0; index < atlas.glyphs.length; index += 1) {
|
|
269
|
-
const glyph = atlas.glyphs[index];
|
|
270
|
-
if (glyph === undefined) {
|
|
271
|
-
return `malformed-glyph: missing glyph record at index ${index}`;
|
|
272
|
-
}
|
|
273
|
-
if (!Number.isSafeInteger(glyph.unicode) || glyph.unicode < 0 || glyph.unicode > 0x10ffff) {
|
|
274
|
-
return `malformed-glyph: invalid unicode identity at index ${index}`;
|
|
275
|
-
}
|
|
276
|
-
if (seenUnicode.has(glyph.unicode)) {
|
|
277
|
-
return `malformed-glyph: duplicate unicode identity at index ${index}`;
|
|
278
|
-
}
|
|
279
|
-
seenUnicode.add(glyph.unicode);
|
|
280
|
-
|
|
281
|
-
if (
|
|
282
|
-
!Number.isFinite(glyph.advance) ||
|
|
283
|
-
!Number.isFinite(glyph.xoffset) ||
|
|
284
|
-
!Number.isFinite(glyph.yoffset)
|
|
285
|
-
) {
|
|
286
|
-
return `malformed-glyph: non-finite metrics at index ${index}`;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const [x, y] = glyph.atlasPosition;
|
|
290
|
-
const [width, height] = glyph.atlasSize;
|
|
291
|
-
if (
|
|
292
|
-
!Number.isSafeInteger(x) ||
|
|
293
|
-
!Number.isSafeInteger(y) ||
|
|
294
|
-
!Number.isSafeInteger(width) ||
|
|
295
|
-
!Number.isSafeInteger(height) ||
|
|
296
|
-
x < 0 ||
|
|
297
|
-
y < 0 ||
|
|
298
|
-
width < 0 ||
|
|
299
|
-
height < 0 ||
|
|
300
|
-
x + width > atlasWidth ||
|
|
301
|
-
y + height > atlasHeight
|
|
302
|
-
) {
|
|
303
|
-
return `malformed-glyph: atlas region out of bounds at index ${index}`;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
return undefined;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
228
|
/**
|
|
310
229
|
* Bake an MSDF atlas + glyph-metrics sidecar from a TTF.
|
|
311
230
|
*
|
|
@@ -323,17 +242,7 @@ export async function bakeFont(
|
|
|
323
242
|
outDir: string,
|
|
324
243
|
generatorFactory: () => Promise<MsdfGenerator>,
|
|
325
244
|
): Promise<BakeResult> {
|
|
326
|
-
|
|
327
|
-
try {
|
|
328
|
-
ttf = await readFile(ttfPath);
|
|
329
|
-
} catch (e) {
|
|
330
|
-
throw new FontError({
|
|
331
|
-
code: 'bake-failed',
|
|
332
|
-
expected: 'a readable TrueType source file',
|
|
333
|
-
hint: 'repair the source path or filesystem access, then retry the bake',
|
|
334
|
-
detail: { path: ttfPath, cause: e instanceof Error ? e.message : String(e) },
|
|
335
|
-
});
|
|
336
|
-
}
|
|
245
|
+
const ttf = await readFile(ttfPath);
|
|
337
246
|
const ttfBytes = new Uint8Array(ttf.buffer, ttf.byteOffset, ttf.byteLength);
|
|
338
247
|
if (!isSupportedFontMagic(ttfBytes)) {
|
|
339
248
|
throw new FontError({
|
|
@@ -344,104 +253,33 @@ export async function bakeFont(
|
|
|
344
253
|
});
|
|
345
254
|
}
|
|
346
255
|
|
|
347
|
-
|
|
348
|
-
await mkdir(outDir, { recursive: true });
|
|
349
|
-
} catch (e) {
|
|
350
|
-
throw new FontError({
|
|
351
|
-
code: 'bake-failed',
|
|
352
|
-
expected: 'a writable output directory',
|
|
353
|
-
hint: 'repair the output path or filesystem access, then retry the bake',
|
|
354
|
-
detail: { path: outDir, cause: e instanceof Error ? e.message : String(e) },
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
let atlas: BakeAtlas | undefined;
|
|
256
|
+
let atlas: BakeAtlas;
|
|
359
257
|
let generator: MsdfGenerator | undefined;
|
|
360
|
-
let primaryFailure: unknown;
|
|
361
|
-
let primaryFailed = false;
|
|
362
|
-
let disposeFailure: unknown;
|
|
363
|
-
let disposeFailed = false;
|
|
364
258
|
try {
|
|
365
259
|
generator = await generatorFactory();
|
|
366
260
|
atlas = await generator.generateAtlas(ttfBytes);
|
|
367
261
|
} catch (e) {
|
|
368
|
-
primaryFailed = true;
|
|
369
|
-
primaryFailure = e;
|
|
370
|
-
} finally {
|
|
371
|
-
if (generator !== undefined) {
|
|
372
|
-
try {
|
|
373
|
-
await generator.dispose();
|
|
374
|
-
} catch (e) {
|
|
375
|
-
disposeFailed = true;
|
|
376
|
-
disposeFailure = e;
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
if (primaryFailed) {
|
|
382
262
|
throw new FontError({
|
|
383
263
|
code: 'bake-failed',
|
|
384
264
|
expected: '@zappar/msdf-generator to produce an MSDF atlas',
|
|
385
265
|
hint: 'the MSDF generator threw -- a Web Worker + wasm host is required (a plain Node process reports "Worker is not defined"); run the bake in a Worker-capable environment',
|
|
386
|
-
detail: {
|
|
387
|
-
cause: primaryFailure instanceof Error ? primaryFailure.message : String(primaryFailure),
|
|
388
|
-
},
|
|
389
|
-
});
|
|
390
|
-
}
|
|
391
|
-
if (disposeFailed) {
|
|
392
|
-
throw new FontError({
|
|
393
|
-
code: 'bake-failed',
|
|
394
|
-
expected: 'the MSDF generator to dispose cleanly',
|
|
395
|
-
hint: 'repair the MSDF generator lifecycle, then retry the bake',
|
|
396
|
-
detail: {
|
|
397
|
-
cause: disposeFailure instanceof Error ? disposeFailure.message : String(disposeFailure),
|
|
398
|
-
},
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
if (atlas === undefined) {
|
|
402
|
-
throw new FontError({
|
|
403
|
-
code: 'bake-failed',
|
|
404
|
-
expected: '@zappar/msdf-generator to produce an MSDF atlas',
|
|
405
|
-
hint: 'repair the MSDF generator lifecycle, then retry the bake',
|
|
406
|
-
detail: { cause: 'the MSDF generator produced no atlas' },
|
|
266
|
+
detail: { cause: e instanceof Error ? e.message : String(e) },
|
|
407
267
|
});
|
|
268
|
+
} finally {
|
|
269
|
+
if (generator !== undefined) {
|
|
270
|
+
await generator.dispose().catch(() => undefined);
|
|
271
|
+
}
|
|
408
272
|
}
|
|
409
273
|
|
|
274
|
+
await mkdir(outDir, { recursive: true });
|
|
410
275
|
const base = basename(ttfPath, extname(ttfPath));
|
|
411
276
|
const atlasName = `${base}.atlas.png`;
|
|
412
277
|
const atlasPath = join(outDir, atlasName);
|
|
413
278
|
const sidecarPath = join(outDir, `${base}.meta.json`);
|
|
414
|
-
const malformedCause = malformedAtlasTextureCause(atlas) ?? malformedGlyphCause(atlas);
|
|
415
|
-
if (malformedCause !== undefined) {
|
|
416
|
-
throw new FontError({
|
|
417
|
-
code: 'bake-failed',
|
|
418
|
-
expected: 'a valid MSDF atlas texture and glyph metrics',
|
|
419
|
-
hint: 'repair the MSDF generator atlas and glyph output, then retry the bake',
|
|
420
|
-
detail: { cause: malformedCause },
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
279
|
const png = encodePng(atlas.texture.width, atlas.texture.height, atlas.texture.data);
|
|
424
|
-
|
|
425
|
-
await writeFile(atlasPath, png);
|
|
426
|
-
} catch (e) {
|
|
427
|
-
throw new FontError({
|
|
428
|
-
code: 'bake-failed',
|
|
429
|
-
expected: 'a writable atlas output path',
|
|
430
|
-
hint: 'repair the atlas output path or filesystem access, then retry the bake',
|
|
431
|
-
detail: { path: atlasPath, cause: e instanceof Error ? e.message : String(e) },
|
|
432
|
-
});
|
|
433
|
-
}
|
|
280
|
+
await writeFile(atlasPath, png);
|
|
434
281
|
const sidecar = atlasToSidecar(atlas, atlasName);
|
|
435
|
-
|
|
436
|
-
await writeFile(sidecarPath, `${JSON.stringify(sidecar, null, 2)}\n`);
|
|
437
|
-
} catch (e) {
|
|
438
|
-
throw new FontError({
|
|
439
|
-
code: 'bake-failed',
|
|
440
|
-
expected: 'a writable sidecar output path',
|
|
441
|
-
hint: 'repair the sidecar output path or filesystem access, then retry the bake',
|
|
442
|
-
detail: { path: sidecarPath, cause: e instanceof Error ? e.message : String(e) },
|
|
443
|
-
});
|
|
444
|
-
}
|
|
282
|
+
await writeFile(sidecarPath, `${JSON.stringify(sidecar, null, 2)}\n`);
|
|
445
283
|
return { atlasPath, sidecarPath };
|
|
446
284
|
}
|
|
447
285
|
|
package/src/font-importer.ts
CHANGED
|
@@ -31,17 +31,15 @@
|
|
|
31
31
|
// instead of the real wasm generator; production import calls fall through to
|
|
32
32
|
// the real @zappar factory.
|
|
33
33
|
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
type SamplerAsset,
|
|
44
|
-
type TextureAsset,
|
|
34
|
+
import type {
|
|
35
|
+
FontAsset,
|
|
36
|
+
GlyphMetric,
|
|
37
|
+
ImportContext,
|
|
38
|
+
ImportedAsset,
|
|
39
|
+
Importer,
|
|
40
|
+
ImportResult,
|
|
41
|
+
SamplerAsset,
|
|
42
|
+
TextureAsset,
|
|
45
43
|
} from '@forgeax/engine-types';
|
|
46
44
|
import type { BakeAtlas, MsdfGenerator } from './cli-font.js';
|
|
47
45
|
import { realGeneratorFactory } from './cli-font.js';
|
|
@@ -56,41 +54,6 @@ export function fontOutputSourceKeys(): readonly string[] {
|
|
|
56
54
|
return ['texture', 'sampler', 'font'].map((kind) => sourceKeyForFontOutput(kind) as string);
|
|
57
55
|
}
|
|
58
56
|
|
|
59
|
-
const FONT_REQUIRED_KINDS = ['texture', 'sampler', 'font'] as const;
|
|
60
|
-
const FONT_REQUIRED_TOPOLOGY = 'exactly one texture, one sampler, and one font subAsset';
|
|
61
|
-
|
|
62
|
-
function validateRequiredFontSubAssets(ctx: ImportContext): ImportError | undefined {
|
|
63
|
-
const requiredKinds = new Set<string>(FONT_REQUIRED_KINDS);
|
|
64
|
-
const counts = new Map<string, number>();
|
|
65
|
-
for (const subAsset of ctx.subAssets) {
|
|
66
|
-
if (!requiredKinds.has(subAsset.kind)) continue;
|
|
67
|
-
counts.set(subAsset.kind, (counts.get(subAsset.kind) ?? 0) + 1);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const actual = FONT_REQUIRED_KINDS.map((kind) => `${kind}=${counts.get(kind) ?? 0}`).join(', ');
|
|
71
|
-
if (FONT_REQUIRED_KINDS.every((kind) => counts.get(kind) === 1)) return undefined;
|
|
72
|
-
|
|
73
|
-
return new ImportError({
|
|
74
|
-
code: 'source-validation-failed',
|
|
75
|
-
expected: FONT_REQUIRED_TOPOLOGY,
|
|
76
|
-
hint: IMPORT_ERROR_HINTS['source-validation-failed'],
|
|
77
|
-
detail: {
|
|
78
|
-
diagnostics: [
|
|
79
|
-
{
|
|
80
|
-
code: 'font-required-subasset-topology',
|
|
81
|
-
severity: 'error',
|
|
82
|
-
sourcePath: `${ctx.source}#subAssets`,
|
|
83
|
-
sourceRange: { start: 0, end: 0, line: 1, column: 1 },
|
|
84
|
-
rule: 'font-required-subasset-topology',
|
|
85
|
-
expected: FONT_REQUIRED_TOPOLOGY,
|
|
86
|
-
actual,
|
|
87
|
-
hint: 'Declare exactly one texture, one sampler, and one font subAsset before importing.',
|
|
88
|
-
},
|
|
89
|
-
],
|
|
90
|
-
},
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
|
|
94
57
|
/** Map the @zappar atlas glyphs into the FontAsset glyph-metrics record. */
|
|
95
58
|
function atlasGlyphsToMetrics(atlas: BakeAtlas): Record<number, GlyphMetric> {
|
|
96
59
|
const glyphs: Record<number, GlyphMetric> = {};
|
|
@@ -148,23 +111,11 @@ function makeAtlasSampler(): SamplerAsset {
|
|
|
148
111
|
}
|
|
149
112
|
|
|
150
113
|
async function importFont(ctx: ImportContext): Promise<ImportResult> {
|
|
151
|
-
const topologyError = validateRequiredFontSubAssets(ctx);
|
|
152
|
-
if (topologyError !== undefined) return { ok: false, error: topologyError };
|
|
153
|
-
|
|
154
114
|
const read = await ctx.readSource();
|
|
155
115
|
if (!read.ok) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
code: 'source-read-failed',
|
|
160
|
-
expected: `readable source file at meta.source "${ctx.source}"`,
|
|
161
|
-
hint: IMPORT_ERROR_HINTS['source-read-failed'],
|
|
162
|
-
detail: {
|
|
163
|
-
source: ctx.source,
|
|
164
|
-
reason: read.error instanceof Error ? read.error.message : String(read.error),
|
|
165
|
-
},
|
|
166
|
-
}),
|
|
167
|
-
};
|
|
116
|
+
throw new Error(
|
|
117
|
+
`fontImporter: readSource failed: ${read.error instanceof Error ? read.error.message : String(read.error)}`,
|
|
118
|
+
);
|
|
168
119
|
}
|
|
169
120
|
|
|
170
121
|
const factory =
|