@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.
Files changed (37) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +100 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/font-importer.test.d.ts +2 -0
  5. package/dist/__tests__/font-importer.test.d.ts.map +1 -0
  6. package/dist/__tests__/font-local-artifacts.test.d.ts +2 -0
  7. package/dist/__tests__/font-local-artifacts.test.d.ts.map +1 -0
  8. package/dist/__tests__/font.unit.test.d.ts +2 -0
  9. package/dist/__tests__/font.unit.test.d.ts.map +1 -0
  10. package/dist/cli-font.d.ts +96 -0
  11. package/dist/cli-font.d.ts.map +1 -0
  12. package/dist/cli-font.mjs +465 -0
  13. package/dist/cli-font.mjs.map +1 -0
  14. package/dist/font-importer.d.ts +18 -0
  15. package/dist/font-importer.d.ts.map +1 -0
  16. package/dist/font-importer.mjs +637 -0
  17. package/dist/font-importer.mjs.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.mjs +54 -0
  21. package/dist/index.mjs.map +1 -0
  22. package/dist/node-msdf-worker.mjs +35 -0
  23. package/dist/node-msdf-worker.mjs.map +1 -0
  24. package/dist/node-worker-adapter.d.ts +17 -0
  25. package/dist/node-worker-adapter.d.ts.map +1 -0
  26. package/dist/runtime/font-decoder.d.ts +3 -0
  27. package/dist/runtime/font-decoder.d.ts.map +1 -0
  28. package/package.json +80 -0
  29. package/src/__tests__/font-importer.test.ts +24 -0
  30. package/src/__tests__/font-local-artifacts.test.ts +280 -0
  31. package/src/__tests__/font.unit.test.ts +1039 -0
  32. package/src/cli-font.ts +634 -0
  33. package/src/font-importer.ts +254 -0
  34. package/src/index.ts +7 -0
  35. package/src/node-msdf-worker.mjs +38 -0
  36. package/src/node-worker-adapter.ts +41 -0
  37. package/src/runtime/font-decoder.ts +70 -0
@@ -0,0 +1,634 @@
1
+ #!/usr/bin/env node
2
+
3
+ // @forgeax/engine-font/src/cli-font — `forgeax-engine-remote-font` plugin
4
+ // bin. Discovered by the base bin via the kubectl 4th-path
5
+ // `forgeax-engine-remote-` prefix scanner.
6
+ //
7
+ // `bake <ttf> <out>` reads a TrueType font and produces an MSDF atlas PNG +
8
+ // a glyph-metrics sidecar JSON (importer: 'font'). The real bake calls
9
+ // @zappar/msdf-generator (feat-20260531-world-space-msdf-text-rendering M5 /
10
+ // w28 -- replaces the M1 placeholder).
11
+ //
12
+ // Error model (FontErrorCode, structured to stderr, exit code 1):
13
+ // - non-TTF magic (not 0x00010000 / 'true' / 'OTTO') -> 'unsupported-font-format'
14
+ // (AC-15 / plan-strategy D-11). The check runs BEFORE the generator so a
15
+ // bad format never reaches the wasm path.
16
+ // - @zappar/msdf-generator throws (wasm / Worker unavailable, internal error)
17
+ // -> 'bake-failed' (charter P3: explicit failure, never a silent exit-0
18
+ // no-atlas). In a plain Node CI without a Web Worker the generator throws
19
+ // 'Worker is not defined' and the bake reports 'bake-failed' (exit 1) --
20
+ // the best-effort real run requires a Worker + wasm host.
21
+
22
+ import { Buffer } from 'node:buffer';
23
+ import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
24
+ import { basename, extname, join } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { parseArgs } from 'node:util';
27
+ import { deflateSync } from 'node:zlib';
28
+ import { FontError, type GlyphMetric } from '@forgeax/engine-types';
29
+ import { NodeWorkerAdapter } from './node-worker-adapter.js';
30
+
31
+ /**
32
+ * Minimal subset of the @zappar/msdf-generator glyph record consumed by the
33
+ * bake. Mirrors the package's `GlyphInfo` (dist/index.d.ts) -- only the fields
34
+ * the BMFont -> FontAsset mapping needs (toolchain wiki section 4).
35
+ */
36
+ export interface BakeGlyph {
37
+ readonly unicode: number;
38
+ readonly advance: number;
39
+ readonly xoffset: number;
40
+ readonly yoffset: number;
41
+ readonly atlasPosition: readonly [number, number];
42
+ readonly atlasSize: readonly [number, number];
43
+ }
44
+
45
+ /**
46
+ * Minimal subset of the @zappar/msdf-generator `MSDFAtlas` consumed by the
47
+ * bake. `texture` carries the RGBA pixel buffer + dimensions (the package's
48
+ * `ImageData`-shaped texture, but reduced to POD so the bake stays
49
+ * environment-agnostic for testing).
50
+ */
51
+ export interface BakeAtlas {
52
+ readonly texture: { readonly width: number; readonly height: number; readonly data: Uint8Array };
53
+ readonly glyphs: readonly BakeGlyph[];
54
+ readonly metrics: { readonly lineHeight: number; readonly ascender: number };
55
+ readonly textureSize: readonly [number, number];
56
+ readonly fieldRange: number;
57
+ }
58
+
59
+ /**
60
+ * The bake-time MSDF generator contract. Injected into {@link bakeFont} so
61
+ * unit tests can supply a mock (real path: `@zappar/msdf-generator`'s `MSDF`).
62
+ */
63
+ export interface MsdfGenerator {
64
+ generateAtlas(ttf: Uint8Array): Promise<BakeAtlas>;
65
+ dispose(): Promise<void>;
66
+ }
67
+
68
+ /** Default charset baked into the atlas (printable ASCII). */
69
+ const DEFAULT_CHARSET = (() => {
70
+ let s = '';
71
+ for (let c = 0x20; c <= 0x7e; c++) s += String.fromCharCode(c);
72
+ return s;
73
+ })();
74
+
75
+ const DEFAULT_TEXTURE_SIZE = 1024;
76
+ const DEFAULT_FIELD_RANGE = 4;
77
+ const DEFAULT_FONT_SIZE = 48;
78
+
79
+ /**
80
+ * Bake-time sidecar JSON shape (importer: 'font'). Carries the glyph metrics
81
+ * (BMFont -> FontAsset mapping, toolchain wiki section 4) + the common block
82
+ * (distanceRange / atlas dimensions). Parsed by the runtime font load path.
83
+ */
84
+ export interface BakeSidecar {
85
+ readonly schemaVersion: string;
86
+ readonly kind: 'external-asset-package';
87
+ readonly importer: 'font';
88
+ readonly source: string;
89
+ readonly importSettings: { readonly colorSpace: 'linear'; readonly mipmap: 'none' };
90
+ readonly common: {
91
+ readonly lineHeight: number;
92
+ readonly base: number;
93
+ readonly distanceRange: number;
94
+ readonly pxRange: number;
95
+ readonly atlasWidth: number;
96
+ readonly atlasHeight: number;
97
+ };
98
+ readonly glyphs: Record<number, GlyphMetric>;
99
+ }
100
+
101
+ /** TTF / OTF magic numbers (first 4 bytes). Per the OpenType spec. */
102
+ function isSupportedFontMagic(bytes: Uint8Array): boolean {
103
+ if (bytes.length < 4) return false;
104
+ const b0 = bytes[0] ?? 0;
105
+ const b1 = bytes[1] ?? 0;
106
+ const b2 = bytes[2] ?? 0;
107
+ const b3 = bytes[3] ?? 0;
108
+ // 0x00010000 = TrueType outlines; 'true' (0x74727565) = legacy Apple TTF;
109
+ // 'OTTO' (0x4f54544f) = OpenType with CFF outlines. WOFF/WOFF2 ('wOFF' /
110
+ // 'wOF2') are rejected as non-TTF per AC-15.
111
+ const isTrueType = b0 === 0x00 && b1 === 0x01 && b2 === 0x00 && b3 === 0x00;
112
+ const isTrue = b0 === 0x74 && b1 === 0x72 && b2 === 0x75 && b3 === 0x65;
113
+ const isOtto = b0 === 0x4f && b1 === 0x54 && b2 === 0x54 && b3 === 0x4f;
114
+ return isTrueType || isTrue || isOtto;
115
+ }
116
+
117
+ /** CRC-32 (PNG / zlib polynomial) over a byte slice. */
118
+ function crc32(bytes: Uint8Array): number {
119
+ let crc = 0xffffffff;
120
+ for (let i = 0; i < bytes.length; i++) {
121
+ crc ^= bytes[i] ?? 0;
122
+ for (let k = 0; k < 8; k++) {
123
+ crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
124
+ }
125
+ }
126
+ return (crc ^ 0xffffffff) >>> 0;
127
+ }
128
+
129
+ function pngChunk(type: string, data: Uint8Array): Uint8Array {
130
+ const typeBytes = new Uint8Array([
131
+ type.charCodeAt(0),
132
+ type.charCodeAt(1),
133
+ type.charCodeAt(2),
134
+ type.charCodeAt(3),
135
+ ]);
136
+ const body = new Uint8Array(typeBytes.length + data.length);
137
+ body.set(typeBytes, 0);
138
+ body.set(data, typeBytes.length);
139
+ const out = new Uint8Array(4 + body.length + 4);
140
+ const dv = new DataView(out.buffer);
141
+ dv.setUint32(0, data.length);
142
+ out.set(body, 4);
143
+ dv.setUint32(4 + body.length, crc32(body));
144
+ return out;
145
+ }
146
+
147
+ /**
148
+ * Encode an RGBA pixel buffer into a PNG (zlib deflate, no external deps).
149
+ * The atlas texture from @zappar is RGBA8; this writes a standard 8-bit
150
+ * RGBA PNG so any consumer (engine image importer / browser) can decode it.
151
+ */
152
+ export function encodePng(width: number, height: number, rgba: Uint8Array): Uint8Array {
153
+ // Filter byte 0 (None) prefixes each scanline.
154
+ const stride = width * 4;
155
+ const raw = new Uint8Array((stride + 1) * height);
156
+ for (let y = 0; y < height; y++) {
157
+ raw[y * (stride + 1)] = 0;
158
+ raw.set(rgba.subarray(y * stride, y * stride + stride), y * (stride + 1) + 1);
159
+ }
160
+ const idat = deflateSync(raw);
161
+ const ihdr = new Uint8Array(13);
162
+ const dv = new DataView(ihdr.buffer);
163
+ dv.setUint32(0, width);
164
+ dv.setUint32(4, height);
165
+ ihdr[8] = 8; // bit depth
166
+ ihdr[9] = 6; // color type RGBA
167
+ ihdr[10] = 0; // compression
168
+ ihdr[11] = 0; // filter
169
+ ihdr[12] = 0; // interlace
170
+ const signature = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
171
+ const chunks = [
172
+ signature,
173
+ pngChunk('IHDR', ihdr),
174
+ pngChunk('IDAT', new Uint8Array(idat)),
175
+ pngChunk('IEND', new Uint8Array(0)),
176
+ ];
177
+ const total = chunks.reduce((n, c) => n + c.length, 0);
178
+ const out = new Uint8Array(total);
179
+ let off = 0;
180
+ for (const c of chunks) {
181
+ out.set(c, off);
182
+ off += c.length;
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /** Map a @zappar atlas into the FontAsset glyph-metrics sidecar shape. */
188
+ export function atlasToSidecar(atlas: BakeAtlas, sourcePng: string): BakeSidecar {
189
+ const glyphs: Record<number, GlyphMetric> = {};
190
+ for (const g of atlas.glyphs) {
191
+ glyphs[g.unicode] = {
192
+ advance: g.advance,
193
+ bearingX: g.xoffset,
194
+ bearingY: g.yoffset,
195
+ size: { w: g.atlasSize[0], h: g.atlasSize[1] },
196
+ region: {
197
+ x: g.atlasPosition[0],
198
+ y: g.atlasPosition[1],
199
+ w: g.atlasSize[0],
200
+ h: g.atlasSize[1],
201
+ },
202
+ };
203
+ }
204
+ return {
205
+ schemaVersion: '1.0.0',
206
+ kind: 'external-asset-package',
207
+ importer: 'font',
208
+ source: sourcePng,
209
+ importSettings: { colorSpace: 'linear', mipmap: 'none' },
210
+ common: {
211
+ lineHeight: atlas.metrics.lineHeight,
212
+ base: atlas.metrics.ascender,
213
+ distanceRange: atlas.fieldRange,
214
+ pxRange: atlas.fieldRange,
215
+ atlasWidth: atlas.textureSize[0],
216
+ atlasHeight: atlas.textureSize[1],
217
+ },
218
+ glyphs,
219
+ };
220
+ }
221
+
222
+ /** Result of a successful bake -- the written artefact paths. */
223
+ export interface BakeResult {
224
+ readonly atlasPath: string;
225
+ readonly sidecarPath: string;
226
+ }
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
+ /**
310
+ * Bake an MSDF atlas + glyph-metrics sidecar from a TTF.
311
+ *
312
+ * @param ttfPath path to the TrueType source.
313
+ * @param outDir output directory (created if missing).
314
+ * @param generatorFactory yields the MSDF generator (real: @zappar; tests: mock).
315
+ * @returns `Result`-style: throws a {@link FontError} on every failure mode
316
+ * (unsupported-font-format before the generator runs; bake-failed when the
317
+ * generator throws). The CLI layer maps the thrown FontError to a structured
318
+ * stderr line + exit code 1 -- never a silent exit 0 without artefacts
319
+ * (charter P3).
320
+ */
321
+ export async function bakeFont(
322
+ ttfPath: string,
323
+ outDir: string,
324
+ generatorFactory: () => Promise<MsdfGenerator>,
325
+ ): Promise<BakeResult> {
326
+ let ttf: Buffer;
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
+ }
337
+ const ttfBytes = new Uint8Array(ttf.buffer, ttf.byteOffset, ttf.byteLength);
338
+ if (!isSupportedFontMagic(ttfBytes)) {
339
+ throw new FontError({
340
+ code: 'unsupported-font-format',
341
+ expected: 'ttf',
342
+ hint: 'bake accepts TrueType (.ttf / 0x00010000 / "true") or OpenType-TTF ("OTTO") sources; WOFF / WOFF2 / other formats are not supported -- convert to TTF first',
343
+ detail: { path: ttfPath },
344
+ });
345
+ }
346
+
347
+ try {
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;
359
+ let generator: MsdfGenerator | undefined;
360
+ let primaryFailure: unknown;
361
+ let primaryFailed = false;
362
+ let disposeFailure: unknown;
363
+ let disposeFailed = false;
364
+ try {
365
+ generator = await generatorFactory();
366
+ atlas = await generator.generateAtlas(ttfBytes);
367
+ } 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
+ throw new FontError({
383
+ code: 'bake-failed',
384
+ expected: '@zappar/msdf-generator to produce an MSDF atlas',
385
+ 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' },
407
+ });
408
+ }
409
+
410
+ const base = basename(ttfPath, extname(ttfPath));
411
+ const atlasName = `${base}.atlas.png`;
412
+ const atlasPath = join(outDir, atlasName);
413
+ 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
+ const png = encodePng(atlas.texture.width, atlas.texture.height, atlas.texture.data);
424
+ try {
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
+ }
434
+ const sidecar = atlasToSidecar(atlas, atlasName);
435
+ try {
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
+ }
445
+ return { atlasPath, sidecarPath };
446
+ }
447
+
448
+ /**
449
+ * Real @zappar/msdf-generator factory. Dynamically imported so a non-bake
450
+ * subcommand (or a test that injects a mock) never pays the wasm load cost,
451
+ * and so the package builds without the browser globals the generator needs.
452
+ */
453
+ /**
454
+ * Shape of @zappar/msdf-generator's `MSDFAtlas` (dist/index.d.ts) that the
455
+ * real factory adapts into the POD {@link BakeAtlas}. The package's `texture`
456
+ * is an ImageData-shaped `{ width, height, data }`; we read those fields
457
+ * structurally so the font package never needs the DOM `ImageData` type.
458
+ */
459
+ interface ZapparAtlas {
460
+ texture: { width: number; height: number; data: Uint8ClampedArray | Uint8Array };
461
+ glyphs: ReadonlyArray<{
462
+ unicode: number;
463
+ advance: number;
464
+ xoffset: number;
465
+ yoffset: number;
466
+ atlasPosition: [number, number];
467
+ atlasSize: [number, number];
468
+ }>;
469
+ metrics: { lineHeight: number; ascender: number };
470
+ textureSize: [number, number];
471
+ fieldRange: number;
472
+ }
473
+
474
+ export async function realGeneratorFactory(): Promise<MsdfGenerator> {
475
+ const mod = (await import('@zappar/msdf-generator')) as unknown as {
476
+ MSDF: new (config?: {
477
+ workerUrl?: URL;
478
+ wasmUrl?: string;
479
+ }) => {
480
+ initialize(): Promise<void>;
481
+ generateAtlas(opts: {
482
+ font: Uint8Array;
483
+ charset: string;
484
+ textureSize: [number, number];
485
+ fieldRange: number;
486
+ fontSize: number;
487
+ }): Promise<ZapparAtlas>;
488
+ dispose(): Promise<void>;
489
+ };
490
+ };
491
+
492
+ const wasmModuleUrl = import.meta.resolve('@zappar/msdf-generator/msdfgen_wasm.wasm');
493
+ const wasmBytes = await readFile(new URL(wasmModuleUrl));
494
+ const wasmUrl = `data:application/octet-stream;base64,${Buffer.from(wasmBytes).toString('base64')}`;
495
+ const nodeGlobal = globalThis as unknown as { Worker?: typeof NodeWorkerAdapter };
496
+ const previousWorker = nodeGlobal.Worker;
497
+ nodeGlobal.Worker = NodeWorkerAdapter;
498
+ let msdf: InstanceType<typeof mod.MSDF> | undefined;
499
+ try {
500
+ msdf = new mod.MSDF({
501
+ workerUrl: new URL('./node-msdf-worker.mjs', import.meta.url),
502
+ wasmUrl,
503
+ });
504
+ await msdf.initialize();
505
+ } finally {
506
+ if (previousWorker === undefined) {
507
+ delete nodeGlobal.Worker;
508
+ } else {
509
+ nodeGlobal.Worker = previousWorker;
510
+ }
511
+ }
512
+ if (msdf === undefined) {
513
+ throw new Error('the MSDF generator did not initialize');
514
+ }
515
+ return {
516
+ async generateAtlas(ttf: Uint8Array): Promise<BakeAtlas> {
517
+ const a = await msdf.generateAtlas({
518
+ font: ttf,
519
+ charset: DEFAULT_CHARSET,
520
+ textureSize: [DEFAULT_TEXTURE_SIZE, DEFAULT_TEXTURE_SIZE],
521
+ fieldRange: DEFAULT_FIELD_RANGE,
522
+ fontSize: DEFAULT_FONT_SIZE,
523
+ });
524
+ return {
525
+ texture: {
526
+ width: a.texture.width,
527
+ height: a.texture.height,
528
+ data: new Uint8Array(a.texture.data),
529
+ },
530
+ glyphs: a.glyphs.map((g) => ({
531
+ unicode: g.unicode,
532
+ advance: g.advance,
533
+ xoffset: g.xoffset,
534
+ yoffset: g.yoffset,
535
+ atlasPosition: [g.atlasPosition[0], g.atlasPosition[1]],
536
+ atlasSize: [g.atlasSize[0], g.atlasSize[1]],
537
+ })),
538
+ metrics: { lineHeight: a.metrics.lineHeight, ascender: a.metrics.ascender },
539
+ textureSize: [a.textureSize[0], a.textureSize[1]],
540
+ fieldRange: a.fieldRange,
541
+ };
542
+ },
543
+ async dispose(): Promise<void> {
544
+ await msdf.dispose();
545
+ },
546
+ };
547
+ }
548
+
549
+ function bakeHelpBody(): string {
550
+ return [
551
+ 'forgeax-engine-remote-font bake — bake MSDF font atlas from TTF',
552
+ '',
553
+ 'Usage:',
554
+ ' forgeax-engine-remote-font bake <ttf> <out>',
555
+ '',
556
+ 'Reads a TrueType font file and produces:',
557
+ ` <out>/<basename>.atlas.png — ${DEFAULT_TEXTURE_SIZE}x${DEFAULT_TEXTURE_SIZE} MSDF atlas`,
558
+ ' <out>/<basename>.meta.json — glyph metrics sidecar (importer: font)',
559
+ '',
560
+ ].join('\n');
561
+ }
562
+
563
+ function helpBody(): string {
564
+ return [
565
+ 'forgeax-engine-remote-font — MSDF font atlas baking',
566
+ '',
567
+ 'Usage:',
568
+ ' forgeax-engine-remote-font bake <ttf> <out>',
569
+ '',
570
+ ].join('\n');
571
+ }
572
+
573
+ export async function runCliFont(argv: string[]): Promise<number> {
574
+ const [sub, ...rest] = argv;
575
+ if (sub === undefined || sub === '--help' || sub === '-h') {
576
+ process.stdout.write(`${helpBody()}\n`);
577
+ return 0;
578
+ }
579
+ if (sub !== 'bake') {
580
+ process.stderr.write(`unknown subcommand: ${sub}\n`);
581
+ return 1;
582
+ }
583
+ return runBake(rest);
584
+ }
585
+
586
+ async function runBake(rest: string[]): Promise<number> {
587
+ if (rest[0] === '--help' || rest[0] === '-h') {
588
+ process.stdout.write(`${bakeHelpBody()}\n`);
589
+ return 0;
590
+ }
591
+ let positionals: string[];
592
+ try {
593
+ const parsed = parseArgs({ args: rest, allowPositionals: true, strict: true });
594
+ positionals = [...parsed.positionals];
595
+ } catch {
596
+ process.stderr.write('error parsing CLI args\n');
597
+ return 1;
598
+ }
599
+ const ttfPath = positionals[0];
600
+ const outDir = positionals[1];
601
+ if (ttfPath === undefined || outDir === undefined) {
602
+ process.stderr.write('usage: forgeax-engine-remote-font bake <ttf> <out>\n');
603
+ return 1;
604
+ }
605
+ try {
606
+ const result = await bakeFont(ttfPath, outDir, realGeneratorFactory);
607
+ process.stdout.write(`baked ${result.atlasPath} + ${result.sidecarPath}\n`);
608
+ return 0;
609
+ } catch (e) {
610
+ if (e instanceof FontError) {
611
+ process.stderr.write(
612
+ `${JSON.stringify({ code: e.code, expected: e.expected, hint: e.hint, detail: e.detail })}\n`,
613
+ );
614
+ return 1;
615
+ }
616
+ process.stderr.write(`bake failed: ${e instanceof Error ? e.message : String(e)}\n`);
617
+ return 1;
618
+ }
619
+ }
620
+
621
+ const isBinEntry = await (async (): Promise<boolean> => {
622
+ const argv1 = process.argv[1];
623
+ if (typeof argv1 !== 'string') return false;
624
+ const argv1Real = await realpath(argv1).catch(() => argv1);
625
+ const selfReal = await realpath(fileURLToPath(import.meta.url)).catch(() =>
626
+ fileURLToPath(import.meta.url),
627
+ );
628
+ return argv1Real === selfReal;
629
+ })();
630
+
631
+ if (isBinEntry) {
632
+ const exitCode = await runCliFont(process.argv.slice(2));
633
+ process.exit(exitCode);
634
+ }