@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,18 @@
1
+ import { type Importer } from '@forgeax/engine-types';
2
+ /** Stable semantic identities for the three writable font outputs. */
3
+ export declare function sourceKeyForFontOutput(kind: string): string | undefined;
4
+ export declare function fontOutputSourceKeys(): readonly string[];
5
+ /**
6
+ * The font {@link Importer}. Register it into an `ImporterRegistry` so the
7
+ * import runner dispatches `meta.importer === 'font'` sidecars here.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { ImporterRegistry } from '@forgeax/engine-import';
12
+ * import { fontImporter } from '@forgeax/engine-font/font-importer';
13
+ * const importers = new ImporterRegistry();
14
+ * importers.register(fontImporter);
15
+ * ```
16
+ */
17
+ export declare const fontImporter: Importer;
18
+ //# sourceMappingURL=font-importer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"font-importer.d.ts","sourceRoot":"","sources":["../src/font-importer.ts"],"names":[],"mappings":"AAiCA,OAAO,EAOL,KAAK,QAAQ,EAId,MAAM,uBAAuB,CAAC;AAI/B,sEAAsE;AACtE,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGvE;AAED,wBAAgB,oBAAoB,IAAI,SAAS,MAAM,EAAE,CAExD;AAsLD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,YAAY,EAAE,QAG1B,CAAC"}
@@ -0,0 +1,637 @@
1
+ import { FontError, ImportError, IMPORT_ERROR_HINTS } from '@forgeax/engine-types';
2
+ import { Buffer } from 'buffer';
3
+ import { realpath, readFile, mkdir, writeFile } from 'fs/promises';
4
+ import { basename, extname, join } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { parseArgs } from 'util';
7
+ import { deflateSync } from 'zlib';
8
+ import { Worker } from 'worker_threads';
9
+
10
+ // src/font-importer.ts
11
+ var NodeWorkerAdapter = class {
12
+ worker;
13
+ listeners = /* @__PURE__ */ new Map();
14
+ constructor(url) {
15
+ this.worker = new Worker(url);
16
+ }
17
+ postMessage(message, transferList = []) {
18
+ this.worker.postMessage(message, [...transferList]);
19
+ }
20
+ addEventListener(type, listener) {
21
+ if (type !== "message") return;
22
+ const handler = (data) => listener({ data, origin: "*" });
23
+ this.listeners.set(listener, handler);
24
+ this.worker.on("message", handler);
25
+ }
26
+ removeEventListener(type, listener) {
27
+ if (type !== "message") return;
28
+ const handler = this.listeners.get(listener);
29
+ if (handler === void 0) return;
30
+ this.listeners.delete(listener);
31
+ this.worker.off("message", handler);
32
+ }
33
+ terminate() {
34
+ return this.worker.terminate();
35
+ }
36
+ };
37
+
38
+ // src/cli-font.ts
39
+ var DEFAULT_CHARSET = (() => {
40
+ let s = "";
41
+ for (let c = 32; c <= 126; c++) s += String.fromCharCode(c);
42
+ return s;
43
+ })();
44
+ var DEFAULT_TEXTURE_SIZE = 1024;
45
+ var DEFAULT_FIELD_RANGE = 4;
46
+ var DEFAULT_FONT_SIZE = 48;
47
+ function isSupportedFontMagic(bytes) {
48
+ if (bytes.length < 4) return false;
49
+ const b0 = bytes[0] ?? 0;
50
+ const b1 = bytes[1] ?? 0;
51
+ const b2 = bytes[2] ?? 0;
52
+ const b3 = bytes[3] ?? 0;
53
+ const isTrueType = b0 === 0 && b1 === 1 && b2 === 0 && b3 === 0;
54
+ const isTrue = b0 === 116 && b1 === 114 && b2 === 117 && b3 === 101;
55
+ const isOtto = b0 === 79 && b1 === 84 && b2 === 84 && b3 === 79;
56
+ return isTrueType || isTrue || isOtto;
57
+ }
58
+ function crc32(bytes) {
59
+ let crc = 4294967295;
60
+ for (let i = 0; i < bytes.length; i++) {
61
+ crc ^= bytes[i] ?? 0;
62
+ for (let k = 0; k < 8; k++) {
63
+ crc = crc & 1 ? crc >>> 1 ^ 3988292384 : crc >>> 1;
64
+ }
65
+ }
66
+ return (crc ^ 4294967295) >>> 0;
67
+ }
68
+ function pngChunk(type, data) {
69
+ const typeBytes = new Uint8Array([
70
+ type.charCodeAt(0),
71
+ type.charCodeAt(1),
72
+ type.charCodeAt(2),
73
+ type.charCodeAt(3)
74
+ ]);
75
+ const body = new Uint8Array(typeBytes.length + data.length);
76
+ body.set(typeBytes, 0);
77
+ body.set(data, typeBytes.length);
78
+ const out = new Uint8Array(4 + body.length + 4);
79
+ const dv = new DataView(out.buffer);
80
+ dv.setUint32(0, data.length);
81
+ out.set(body, 4);
82
+ dv.setUint32(4 + body.length, crc32(body));
83
+ return out;
84
+ }
85
+ function encodePng(width, height, rgba) {
86
+ const stride = width * 4;
87
+ const raw = new Uint8Array((stride + 1) * height);
88
+ for (let y = 0; y < height; y++) {
89
+ raw[y * (stride + 1)] = 0;
90
+ raw.set(rgba.subarray(y * stride, y * stride + stride), y * (stride + 1) + 1);
91
+ }
92
+ const idat = deflateSync(raw);
93
+ const ihdr = new Uint8Array(13);
94
+ const dv = new DataView(ihdr.buffer);
95
+ dv.setUint32(0, width);
96
+ dv.setUint32(4, height);
97
+ ihdr[8] = 8;
98
+ ihdr[9] = 6;
99
+ ihdr[10] = 0;
100
+ ihdr[11] = 0;
101
+ ihdr[12] = 0;
102
+ const signature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
103
+ const chunks = [
104
+ signature,
105
+ pngChunk("IHDR", ihdr),
106
+ pngChunk("IDAT", new Uint8Array(idat)),
107
+ pngChunk("IEND", new Uint8Array(0))
108
+ ];
109
+ const total = chunks.reduce((n, c) => n + c.length, 0);
110
+ const out = new Uint8Array(total);
111
+ let off = 0;
112
+ for (const c of chunks) {
113
+ out.set(c, off);
114
+ off += c.length;
115
+ }
116
+ return out;
117
+ }
118
+ function atlasToSidecar(atlas, sourcePng) {
119
+ const glyphs = {};
120
+ for (const g of atlas.glyphs) {
121
+ glyphs[g.unicode] = {
122
+ advance: g.advance,
123
+ bearingX: g.xoffset,
124
+ bearingY: g.yoffset,
125
+ size: { w: g.atlasSize[0], h: g.atlasSize[1] },
126
+ region: {
127
+ x: g.atlasPosition[0],
128
+ y: g.atlasPosition[1],
129
+ w: g.atlasSize[0],
130
+ h: g.atlasSize[1]
131
+ }
132
+ };
133
+ }
134
+ return {
135
+ schemaVersion: "1.0.0",
136
+ kind: "external-asset-package",
137
+ importer: "font",
138
+ source: sourcePng,
139
+ importSettings: { colorSpace: "linear", mipmap: "none" },
140
+ common: {
141
+ lineHeight: atlas.metrics.lineHeight,
142
+ base: atlas.metrics.ascender,
143
+ distanceRange: atlas.fieldRange,
144
+ pxRange: atlas.fieldRange,
145
+ atlasWidth: atlas.textureSize[0],
146
+ atlasHeight: atlas.textureSize[1]
147
+ },
148
+ glyphs
149
+ };
150
+ }
151
+ function malformedAtlasTextureCause(atlas) {
152
+ const texture = atlas.texture;
153
+ const textureSize = atlas.textureSize;
154
+ if (!Number.isSafeInteger(texture.width) || texture.width <= 0 || !Number.isSafeInteger(texture.height) || texture.height <= 0) {
155
+ return "malformed-atlas: texture dimensions must be positive finite integers";
156
+ }
157
+ if (!Number.isSafeInteger(textureSize[0]) || textureSize[0] <= 0 || !Number.isSafeInteger(textureSize[1]) || textureSize[1] <= 0) {
158
+ return "malformed-atlas: textureSize dimensions must be positive finite integers";
159
+ }
160
+ if (texture.width !== textureSize[0] || texture.height !== textureSize[1]) {
161
+ return "malformed-atlas: texture dimensions must match textureSize";
162
+ }
163
+ const expectedBytes = texture.width * texture.height * 4;
164
+ if (!(texture.data instanceof Uint8Array) || texture.data.length !== expectedBytes) {
165
+ return "malformed-atlas: RGBA data length must equal width * height * 4";
166
+ }
167
+ return void 0;
168
+ }
169
+ function malformedGlyphCause(atlas) {
170
+ if (!Number.isFinite(atlas.metrics.lineHeight) || !Number.isFinite(atlas.metrics.ascender) || !Number.isFinite(atlas.fieldRange)) {
171
+ return "malformed-glyph: common metrics must be finite";
172
+ }
173
+ const [atlasWidth, atlasHeight] = atlas.textureSize;
174
+ const seenUnicode = /* @__PURE__ */ new Set();
175
+ for (let index = 0; index < atlas.glyphs.length; index += 1) {
176
+ const glyph = atlas.glyphs[index];
177
+ if (glyph === void 0) {
178
+ return `malformed-glyph: missing glyph record at index ${index}`;
179
+ }
180
+ if (!Number.isSafeInteger(glyph.unicode) || glyph.unicode < 0 || glyph.unicode > 1114111) {
181
+ return `malformed-glyph: invalid unicode identity at index ${index}`;
182
+ }
183
+ if (seenUnicode.has(glyph.unicode)) {
184
+ return `malformed-glyph: duplicate unicode identity at index ${index}`;
185
+ }
186
+ seenUnicode.add(glyph.unicode);
187
+ if (!Number.isFinite(glyph.advance) || !Number.isFinite(glyph.xoffset) || !Number.isFinite(glyph.yoffset)) {
188
+ return `malformed-glyph: non-finite metrics at index ${index}`;
189
+ }
190
+ const [x, y] = glyph.atlasPosition;
191
+ const [width, height] = glyph.atlasSize;
192
+ if (!Number.isSafeInteger(x) || !Number.isSafeInteger(y) || !Number.isSafeInteger(width) || !Number.isSafeInteger(height) || x < 0 || y < 0 || width < 0 || height < 0 || x + width > atlasWidth || y + height > atlasHeight) {
193
+ return `malformed-glyph: atlas region out of bounds at index ${index}`;
194
+ }
195
+ }
196
+ return void 0;
197
+ }
198
+ async function bakeFont(ttfPath, outDir, generatorFactory) {
199
+ let ttf;
200
+ try {
201
+ ttf = await readFile(ttfPath);
202
+ } catch (e) {
203
+ throw new FontError({
204
+ code: "bake-failed",
205
+ expected: "a readable TrueType source file",
206
+ hint: "repair the source path or filesystem access, then retry the bake",
207
+ detail: { path: ttfPath, cause: e instanceof Error ? e.message : String(e) }
208
+ });
209
+ }
210
+ const ttfBytes = new Uint8Array(ttf.buffer, ttf.byteOffset, ttf.byteLength);
211
+ if (!isSupportedFontMagic(ttfBytes)) {
212
+ throw new FontError({
213
+ code: "unsupported-font-format",
214
+ expected: "ttf",
215
+ hint: 'bake accepts TrueType (.ttf / 0x00010000 / "true") or OpenType-TTF ("OTTO") sources; WOFF / WOFF2 / other formats are not supported -- convert to TTF first',
216
+ detail: { path: ttfPath }
217
+ });
218
+ }
219
+ try {
220
+ await mkdir(outDir, { recursive: true });
221
+ } catch (e) {
222
+ throw new FontError({
223
+ code: "bake-failed",
224
+ expected: "a writable output directory",
225
+ hint: "repair the output path or filesystem access, then retry the bake",
226
+ detail: { path: outDir, cause: e instanceof Error ? e.message : String(e) }
227
+ });
228
+ }
229
+ let atlas;
230
+ let generator;
231
+ let primaryFailure;
232
+ let primaryFailed = false;
233
+ let disposeFailure;
234
+ let disposeFailed = false;
235
+ try {
236
+ generator = await generatorFactory();
237
+ atlas = await generator.generateAtlas(ttfBytes);
238
+ } catch (e) {
239
+ primaryFailed = true;
240
+ primaryFailure = e;
241
+ } finally {
242
+ if (generator !== void 0) {
243
+ try {
244
+ await generator.dispose();
245
+ } catch (e) {
246
+ disposeFailed = true;
247
+ disposeFailure = e;
248
+ }
249
+ }
250
+ }
251
+ if (primaryFailed) {
252
+ throw new FontError({
253
+ code: "bake-failed",
254
+ expected: "@zappar/msdf-generator to produce an MSDF atlas",
255
+ 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',
256
+ detail: {
257
+ cause: primaryFailure instanceof Error ? primaryFailure.message : String(primaryFailure)
258
+ }
259
+ });
260
+ }
261
+ if (disposeFailed) {
262
+ throw new FontError({
263
+ code: "bake-failed",
264
+ expected: "the MSDF generator to dispose cleanly",
265
+ hint: "repair the MSDF generator lifecycle, then retry the bake",
266
+ detail: {
267
+ cause: disposeFailure instanceof Error ? disposeFailure.message : String(disposeFailure)
268
+ }
269
+ });
270
+ }
271
+ if (atlas === void 0) {
272
+ throw new FontError({
273
+ code: "bake-failed",
274
+ expected: "@zappar/msdf-generator to produce an MSDF atlas",
275
+ hint: "repair the MSDF generator lifecycle, then retry the bake",
276
+ detail: { cause: "the MSDF generator produced no atlas" }
277
+ });
278
+ }
279
+ const base = basename(ttfPath, extname(ttfPath));
280
+ const atlasName = `${base}.atlas.png`;
281
+ const atlasPath = join(outDir, atlasName);
282
+ const sidecarPath = join(outDir, `${base}.meta.json`);
283
+ const malformedCause = malformedAtlasTextureCause(atlas) ?? malformedGlyphCause(atlas);
284
+ if (malformedCause !== void 0) {
285
+ throw new FontError({
286
+ code: "bake-failed",
287
+ expected: "a valid MSDF atlas texture and glyph metrics",
288
+ hint: "repair the MSDF generator atlas and glyph output, then retry the bake",
289
+ detail: { cause: malformedCause }
290
+ });
291
+ }
292
+ const png = encodePng(atlas.texture.width, atlas.texture.height, atlas.texture.data);
293
+ try {
294
+ await writeFile(atlasPath, png);
295
+ } catch (e) {
296
+ throw new FontError({
297
+ code: "bake-failed",
298
+ expected: "a writable atlas output path",
299
+ hint: "repair the atlas output path or filesystem access, then retry the bake",
300
+ detail: { path: atlasPath, cause: e instanceof Error ? e.message : String(e) }
301
+ });
302
+ }
303
+ const sidecar = atlasToSidecar(atlas, atlasName);
304
+ try {
305
+ await writeFile(sidecarPath, `${JSON.stringify(sidecar, null, 2)}
306
+ `);
307
+ } catch (e) {
308
+ throw new FontError({
309
+ code: "bake-failed",
310
+ expected: "a writable sidecar output path",
311
+ hint: "repair the sidecar output path or filesystem access, then retry the bake",
312
+ detail: { path: sidecarPath, cause: e instanceof Error ? e.message : String(e) }
313
+ });
314
+ }
315
+ return { atlasPath, sidecarPath };
316
+ }
317
+ async function realGeneratorFactory() {
318
+ const mod = await import('@zappar/msdf-generator');
319
+ const wasmModuleUrl = import.meta.resolve("@zappar/msdf-generator/msdfgen_wasm.wasm");
320
+ const wasmBytes = await readFile(new URL(wasmModuleUrl));
321
+ const wasmUrl = `data:application/octet-stream;base64,${Buffer.from(wasmBytes).toString("base64")}`;
322
+ const nodeGlobal = globalThis;
323
+ const previousWorker = nodeGlobal.Worker;
324
+ nodeGlobal.Worker = NodeWorkerAdapter;
325
+ let msdf;
326
+ try {
327
+ msdf = new mod.MSDF({
328
+ workerUrl: new URL("./node-msdf-worker.mjs", import.meta.url),
329
+ wasmUrl
330
+ });
331
+ await msdf.initialize();
332
+ } finally {
333
+ if (previousWorker === void 0) {
334
+ delete nodeGlobal.Worker;
335
+ } else {
336
+ nodeGlobal.Worker = previousWorker;
337
+ }
338
+ }
339
+ if (msdf === void 0) {
340
+ throw new Error("the MSDF generator did not initialize");
341
+ }
342
+ return {
343
+ async generateAtlas(ttf) {
344
+ const a = await msdf.generateAtlas({
345
+ font: ttf,
346
+ charset: DEFAULT_CHARSET,
347
+ textureSize: [DEFAULT_TEXTURE_SIZE, DEFAULT_TEXTURE_SIZE],
348
+ fieldRange: DEFAULT_FIELD_RANGE,
349
+ fontSize: DEFAULT_FONT_SIZE
350
+ });
351
+ return {
352
+ texture: {
353
+ width: a.texture.width,
354
+ height: a.texture.height,
355
+ data: new Uint8Array(a.texture.data)
356
+ },
357
+ glyphs: a.glyphs.map((g) => ({
358
+ unicode: g.unicode,
359
+ advance: g.advance,
360
+ xoffset: g.xoffset,
361
+ yoffset: g.yoffset,
362
+ atlasPosition: [g.atlasPosition[0], g.atlasPosition[1]],
363
+ atlasSize: [g.atlasSize[0], g.atlasSize[1]]
364
+ })),
365
+ metrics: { lineHeight: a.metrics.lineHeight, ascender: a.metrics.ascender },
366
+ textureSize: [a.textureSize[0], a.textureSize[1]],
367
+ fieldRange: a.fieldRange
368
+ };
369
+ },
370
+ async dispose() {
371
+ await msdf.dispose();
372
+ }
373
+ };
374
+ }
375
+ function bakeHelpBody() {
376
+ return [
377
+ "forgeax-engine-remote-font bake \u2014 bake MSDF font atlas from TTF",
378
+ "",
379
+ "Usage:",
380
+ " forgeax-engine-remote-font bake <ttf> <out>",
381
+ "",
382
+ "Reads a TrueType font file and produces:",
383
+ ` <out>/<basename>.atlas.png \u2014 ${DEFAULT_TEXTURE_SIZE}x${DEFAULT_TEXTURE_SIZE} MSDF atlas`,
384
+ " <out>/<basename>.meta.json \u2014 glyph metrics sidecar (importer: font)",
385
+ ""
386
+ ].join("\n");
387
+ }
388
+ function helpBody() {
389
+ return [
390
+ "forgeax-engine-remote-font \u2014 MSDF font atlas baking",
391
+ "",
392
+ "Usage:",
393
+ " forgeax-engine-remote-font bake <ttf> <out>",
394
+ ""
395
+ ].join("\n");
396
+ }
397
+ async function runCliFont(argv) {
398
+ const [sub, ...rest] = argv;
399
+ if (sub === void 0 || sub === "--help" || sub === "-h") {
400
+ process.stdout.write(`${helpBody()}
401
+ `);
402
+ return 0;
403
+ }
404
+ if (sub !== "bake") {
405
+ process.stderr.write(`unknown subcommand: ${sub}
406
+ `);
407
+ return 1;
408
+ }
409
+ return runBake(rest);
410
+ }
411
+ async function runBake(rest) {
412
+ if (rest[0] === "--help" || rest[0] === "-h") {
413
+ process.stdout.write(`${bakeHelpBody()}
414
+ `);
415
+ return 0;
416
+ }
417
+ let positionals;
418
+ try {
419
+ const parsed = parseArgs({ args: rest, allowPositionals: true, strict: true });
420
+ positionals = [...parsed.positionals];
421
+ } catch {
422
+ process.stderr.write("error parsing CLI args\n");
423
+ return 1;
424
+ }
425
+ const ttfPath = positionals[0];
426
+ const outDir = positionals[1];
427
+ if (ttfPath === void 0 || outDir === void 0) {
428
+ process.stderr.write("usage: forgeax-engine-remote-font bake <ttf> <out>\n");
429
+ return 1;
430
+ }
431
+ try {
432
+ const result = await bakeFont(ttfPath, outDir, realGeneratorFactory);
433
+ process.stdout.write(`baked ${result.atlasPath} + ${result.sidecarPath}
434
+ `);
435
+ return 0;
436
+ } catch (e) {
437
+ if (e instanceof FontError) {
438
+ process.stderr.write(
439
+ `${JSON.stringify({ code: e.code, expected: e.expected, hint: e.hint, detail: e.detail })}
440
+ `
441
+ );
442
+ return 1;
443
+ }
444
+ process.stderr.write(`bake failed: ${e instanceof Error ? e.message : String(e)}
445
+ `);
446
+ return 1;
447
+ }
448
+ }
449
+ var isBinEntry = await (async () => {
450
+ const argv1 = process.argv[1];
451
+ if (typeof argv1 !== "string") return false;
452
+ const argv1Real = await realpath(argv1).catch(() => argv1);
453
+ const selfReal = await realpath(fileURLToPath(import.meta.url)).catch(
454
+ () => fileURLToPath(import.meta.url)
455
+ );
456
+ return argv1Real === selfReal;
457
+ })();
458
+ if (isBinEntry) {
459
+ const exitCode = await runCliFont(process.argv.slice(2));
460
+ process.exit(exitCode);
461
+ }
462
+
463
+ // src/font-importer.ts
464
+ function sourceKeyForFontOutput(kind) {
465
+ const normalizedKind = kind.trim();
466
+ return normalizedKind.length === 0 ? void 0 : `font:${normalizedKind}`;
467
+ }
468
+ function fontOutputSourceKeys() {
469
+ return ["texture", "sampler", "font"].map((kind) => sourceKeyForFontOutput(kind));
470
+ }
471
+ var FONT_REQUIRED_KINDS = ["texture", "sampler", "font"];
472
+ var FONT_REQUIRED_TOPOLOGY = "exactly one texture, one sampler, and one font subAsset";
473
+ function validateRequiredFontSubAssets(ctx) {
474
+ const requiredKinds = new Set(FONT_REQUIRED_KINDS);
475
+ const counts = /* @__PURE__ */ new Map();
476
+ for (const subAsset of ctx.subAssets) {
477
+ if (!requiredKinds.has(subAsset.kind)) continue;
478
+ counts.set(subAsset.kind, (counts.get(subAsset.kind) ?? 0) + 1);
479
+ }
480
+ const actual = FONT_REQUIRED_KINDS.map((kind) => `${kind}=${counts.get(kind) ?? 0}`).join(", ");
481
+ if (FONT_REQUIRED_KINDS.every((kind) => counts.get(kind) === 1)) return void 0;
482
+ return new ImportError({
483
+ code: "source-validation-failed",
484
+ expected: FONT_REQUIRED_TOPOLOGY,
485
+ hint: IMPORT_ERROR_HINTS["source-validation-failed"],
486
+ detail: {
487
+ diagnostics: [
488
+ {
489
+ code: "font-required-subasset-topology",
490
+ severity: "error",
491
+ sourcePath: `${ctx.source}#subAssets`,
492
+ sourceRange: { start: 0, end: 0, line: 1, column: 1 },
493
+ rule: "font-required-subasset-topology",
494
+ expected: FONT_REQUIRED_TOPOLOGY,
495
+ actual,
496
+ hint: "Declare exactly one texture, one sampler, and one font subAsset before importing."
497
+ }
498
+ ]
499
+ }
500
+ });
501
+ }
502
+ function atlasGlyphsToMetrics(atlas) {
503
+ const glyphs = {};
504
+ for (const g of atlas.glyphs) {
505
+ glyphs[g.unicode] = {
506
+ advance: g.advance,
507
+ bearingX: g.xoffset,
508
+ bearingY: g.yoffset,
509
+ size: { w: g.atlasSize[0], h: g.atlasSize[1] },
510
+ region: {
511
+ x: g.atlasPosition[0],
512
+ y: g.atlasPosition[1],
513
+ w: g.atlasSize[0],
514
+ h: g.atlasSize[1]
515
+ }
516
+ };
517
+ }
518
+ return glyphs;
519
+ }
520
+ function makeAtlasTexture(atlas) {
521
+ return {
522
+ kind: "texture",
523
+ width: atlas.texture.width,
524
+ height: atlas.texture.height,
525
+ // MSDF atlas is linear-space RGBA8 (signed-distance channels, never gamma).
526
+ format: "rgba8unorm",
527
+ data: atlas.texture.data,
528
+ colorSpace: "linear",
529
+ mipmap: false
530
+ };
531
+ }
532
+ function makeFontCommon(atlas) {
533
+ return {
534
+ lineHeight: atlas.metrics.lineHeight,
535
+ base: atlas.metrics.ascender,
536
+ distanceRange: atlas.fieldRange,
537
+ pxRange: atlas.fieldRange,
538
+ atlasWidth: atlas.textureSize[0],
539
+ atlasHeight: atlas.textureSize[1]
540
+ };
541
+ }
542
+ function makeAtlasSampler() {
543
+ return {
544
+ kind: "sampler",
545
+ addressModeU: "clamp-to-edge",
546
+ addressModeV: "clamp-to-edge",
547
+ addressModeW: "clamp-to-edge",
548
+ magFilter: "linear",
549
+ minFilter: "linear",
550
+ mipmapFilter: "nearest"
551
+ };
552
+ }
553
+ async function importFont(ctx) {
554
+ const topologyError = validateRequiredFontSubAssets(ctx);
555
+ if (topologyError !== void 0) return { ok: false, error: topologyError };
556
+ const read = await ctx.readSource();
557
+ if (!read.ok) {
558
+ return {
559
+ ok: false,
560
+ error: new ImportError({
561
+ code: "source-read-failed",
562
+ expected: `readable source file at meta.source "${ctx.source}"`,
563
+ hint: IMPORT_ERROR_HINTS["source-read-failed"],
564
+ detail: {
565
+ source: ctx.source,
566
+ reason: read.error instanceof Error ? read.error.message : String(read.error)
567
+ }
568
+ })
569
+ };
570
+ }
571
+ const factory = ctx.importSettings.generatorFactory ?? realGeneratorFactory;
572
+ const generator = await factory();
573
+ let atlas;
574
+ try {
575
+ atlas = await generator.generateAtlas(read.value);
576
+ } finally {
577
+ await generator.dispose().catch(() => void 0);
578
+ }
579
+ const atlasSub = ctx.subAssets.find((s) => s.kind === "texture");
580
+ const samplerSub = ctx.subAssets.find((s) => s.kind === "sampler");
581
+ const fontSub = ctx.subAssets.find((s) => s.kind === "font");
582
+ const out = [];
583
+ if (atlasSub !== void 0) {
584
+ out.push({
585
+ guid: atlasSub.guid,
586
+ kind: "texture",
587
+ payload: makeAtlasTexture(atlas),
588
+ refs: [],
589
+ artifacts: {
590
+ atlas: {
591
+ // Pack v2's runtime texture loader consumes non-Basis artifacts as
592
+ // raw pixels. Keep the baked RGBA8 MSDF atlas in that form here;
593
+ // the CLI still emits a PNG for standalone bake output.
594
+ mediaType: "application/octet-stream",
595
+ bytes: atlas.texture.data
596
+ }
597
+ }
598
+ });
599
+ }
600
+ if (samplerSub !== void 0) {
601
+ out.push({
602
+ guid: samplerSub.guid,
603
+ kind: "sampler",
604
+ payload: makeAtlasSampler(),
605
+ refs: [],
606
+ artifacts: {}
607
+ });
608
+ }
609
+ if (fontSub !== void 0) {
610
+ const fontPayload = {
611
+ kind: "font",
612
+ atlasGuid: atlasSub?.guid ?? "",
613
+ samplerGuid: samplerSub?.guid ?? "",
614
+ glyphs: atlasGlyphsToMetrics(atlas),
615
+ common: makeFontCommon(atlas)
616
+ };
617
+ out.push({
618
+ guid: fontSub.guid,
619
+ kind: "font",
620
+ payload: fontPayload,
621
+ refs: [
622
+ ...atlasSub !== void 0 ? [{ guid: atlasSub.guid }] : [],
623
+ ...samplerSub !== void 0 ? [{ guid: samplerSub.guid }] : []
624
+ ],
625
+ artifacts: {}
626
+ });
627
+ }
628
+ return { ok: true, value: { assets: out, sourceDependencies: [] } };
629
+ }
630
+ var fontImporter = {
631
+ key: "font",
632
+ import: importFont
633
+ };
634
+
635
+ export { fontImporter, fontOutputSourceKeys, sourceKeyForFontOutput };
636
+ //# sourceMappingURL=font-importer.mjs.map
637
+ //# sourceMappingURL=font-importer.mjs.map