@forgeax/engine-font 0.0.0-dev.8d955ade1c79
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 +98 -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 +339 -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 +469 -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 +58 -0
- package/src/__tests__/font.unit.test.ts +394 -0
- package/src/cli-font.ts +472 -0
- package/src/font-importer.ts +205 -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 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/node-msdf-worker.mjs"],"names":[],"mappings":";;;AAEA,IAAI,eAAe,IAAA,EAAM;AACvB,EAAA,MAAM,IAAI,MAAM,yDAAyD,CAAA;AAC3E;AAEA,IAAM,SAAA,uBAAgB,GAAA,EAAI;AAC1B,IAAM,QAAA,GAAW,UAAA;AAEjB,IAAM,gBAAN,MAAoB;AAAA,EAClB,WAAA,CAAY,IAAA,EAAM,KAAA,EAAO,MAAA,EAAQ;AAC/B,IAAA,IAAA,CAAK,IAAA,GAAO,IAAA;AACZ,IAAA,IAAA,CAAK,KAAA,GAAQ,KAAA;AACb,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AAAA,EAChB;AACF,CAAA;AAEA,QAAA,CAAS,SAAA,GAAY,aAAA;AACrB,QAAA,CAAS,gBAAA,GAAmB,CAAC,IAAA,EAAM,QAAA,KAAa;AAC9C,EAAA,IAAI,SAAS,SAAA,EAAW;AACxB,EAAA,MAAM,OAAA,GAAU,CAAC,IAAA,KAAS,QAAA,CAAS,EAAE,IAAA,EAAM,MAAA,EAAQ,KAAK,CAAA;AACxD,EAAA,SAAA,CAAU,GAAA,CAAI,UAAU,OAAO,CAAA;AAC/B,EAAA,UAAA,CAAW,EAAA,CAAG,WAAW,OAAO,CAAA;AAClC,CAAA;AAEA,QAAA,CAAS,mBAAA,GAAsB,CAAC,IAAA,EAAM,QAAA,KAAa;AACjD,EAAA,IAAI,SAAS,SAAA,EAAW;AACxB,EAAA,MAAM,OAAA,GAAU,SAAA,CAAU,GAAA,CAAI,QAAQ,CAAA;AACtC,EAAA,IAAI,YAAY,MAAA,EAAW;AAC3B,EAAA,SAAA,CAAU,OAAO,QAAQ,CAAA;AACzB,EAAA,UAAA,CAAW,GAAA,CAAI,WAAW,OAAO,CAAA;AACnC,CAAA;AAEA,QAAA,CAAS,WAAA,GAAc,CAAC,OAAA,EAAS,YAAA,GAAe,EAAC,KAAM;AACrD,EAAA,UAAA,CAAW,WAAA,CAAY,OAAA,EAAS,CAAC,GAAG,YAAY,CAAC,CAAA;AACnD,CAAA;AAEA,MAAM,OAAO,kCAAkC,CAAA","file":"node-msdf-worker.mjs","sourcesContent":["import { parentPort } from 'node:worker_threads';\n\nif (parentPort === null) {\n throw new Error('the MSDF Node worker requires worker_threads.parentPort');\n}\n\nconst listeners = new Map();\nconst endpoint = globalThis;\n\nclass NodeImageData {\n constructor(data, width, height) {\n this.data = data;\n this.width = width;\n this.height = height;\n }\n}\n\nendpoint.ImageData = NodeImageData;\nendpoint.addEventListener = (type, listener) => {\n if (type !== 'message') return;\n const handler = (data) => listener({ data, origin: '*' });\n listeners.set(listener, handler);\n parentPort.on('message', handler);\n};\n\nendpoint.removeEventListener = (type, listener) => {\n if (type !== 'message') return;\n const handler = listeners.get(listener);\n if (handler === undefined) return;\n listeners.delete(listener);\n parentPort.off('message', handler);\n};\n\nendpoint.postMessage = (message, transferList = []) => {\n parentPort.postMessage(message, [...transferList]);\n};\n\nawait import('@zappar/msdf-generator/worker.js');\n"]}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
interface MessageEventLike {
|
|
2
|
+
readonly data: unknown;
|
|
3
|
+
readonly origin: string;
|
|
4
|
+
}
|
|
5
|
+
type MessageListener = (event: MessageEventLike) => void;
|
|
6
|
+
/** Comlink's browser Worker-shaped endpoint backed by a Node worker thread. */
|
|
7
|
+
export declare class NodeWorkerAdapter {
|
|
8
|
+
private readonly worker;
|
|
9
|
+
private readonly listeners;
|
|
10
|
+
constructor(url: string | URL);
|
|
11
|
+
postMessage(message: unknown, transferList?: readonly ArrayBuffer[]): void;
|
|
12
|
+
addEventListener(type: string, listener: MessageListener): void;
|
|
13
|
+
removeEventListener(type: string, listener: MessageListener): void;
|
|
14
|
+
terminate(): Promise<number>;
|
|
15
|
+
}
|
|
16
|
+
export {};
|
|
17
|
+
//# sourceMappingURL=node-worker-adapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node-worker-adapter.d.ts","sourceRoot":"","sources":["../src/node-worker-adapter.ts"],"names":[],"mappings":"AAEA,UAAU,gBAAgB;IACxB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,KAAK,eAAe,GAAG,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;AAEzD,+EAA+E;AAC/E,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAuD;gBAE9D,GAAG,EAAE,MAAM,GAAG,GAAG;IAI7B,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,YAAY,GAAE,SAAS,WAAW,EAAO,GAAG,IAAI;IAI9E,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,eAAe,GAAG,IAAI;IAO/D,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,eAAe,GAAG,IAAI;IAQlE,SAAS,IAAI,OAAO,CAAC,MAAM,CAAC;CAGpC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"font-decoder.d.ts","sourceRoot":"","sources":["../../src/runtime/font-decoder.ts"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,wBAAwB,EAG7B,KAAK,SAAS,EAEf,MAAM,uBAAuB,CAAC;AAwB/B,eAAO,MAAM,gBAAgB,EAAE,wBAAwB,CAAC,SAAS,EAAE,MAAM,CAsCxE,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeax/engine-font",
|
|
3
|
+
"version": "0.0.0-dev.8d955ade1c79",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"description": "Build-time MSDF font atlas baking and runtime font asset loading for the forgeax engine. CLI surface is provided by @forgeax/engine-remote (forgeax-engine-remote-font).",
|
|
9
|
+
"bin": {
|
|
10
|
+
"forgeax-engine-remote-font": "./dist/cli-font.mjs"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.mjs"
|
|
16
|
+
},
|
|
17
|
+
"./font-importer": {
|
|
18
|
+
"types": "./dist/font-importer.d.ts",
|
|
19
|
+
"node": {
|
|
20
|
+
"import": "./dist/font-importer.mjs"
|
|
21
|
+
},
|
|
22
|
+
"default": null
|
|
23
|
+
},
|
|
24
|
+
"./cli-font": {
|
|
25
|
+
"types": "./dist/cli-font.d.ts",
|
|
26
|
+
"node": {
|
|
27
|
+
"import": "./dist/cli-font.mjs"
|
|
28
|
+
},
|
|
29
|
+
"default": null
|
|
30
|
+
},
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"main": "./dist/index.mjs",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@forgeax/engine-pack": "0.0.0-dev.8d955ade1c79",
|
|
43
|
+
"@forgeax/engine-types": "0.0.0-dev.8d955ade1c79",
|
|
44
|
+
"@zappar/msdf-generator": "1.2.4"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@forgeax/engine-import": "0.0.0-dev.8d955ade1c79",
|
|
48
|
+
"@forgeax/engine-remote": "0.0.0-dev.8d955ade1c79",
|
|
49
|
+
"@types/node": "^20.14.0"
|
|
50
|
+
},
|
|
51
|
+
"forgeax": {
|
|
52
|
+
"metrics": {
|
|
53
|
+
"bundle-size": {
|
|
54
|
+
"enabled": true,
|
|
55
|
+
"path": "dist/cli-font.mjs",
|
|
56
|
+
"compression": "gzip"
|
|
57
|
+
},
|
|
58
|
+
"fps": {
|
|
59
|
+
"enabled": false,
|
|
60
|
+
"reason": "build-time CLI package; no runtime canvas or frame loop"
|
|
61
|
+
},
|
|
62
|
+
"bench": {
|
|
63
|
+
"enabled": false,
|
|
64
|
+
"reason": "one-shot bake pipeline; not a perf-critical hot path"
|
|
65
|
+
},
|
|
66
|
+
"gate": {
|
|
67
|
+
"enabled": false,
|
|
68
|
+
"reason": "per-package gate tests declared in package.json scripts; no standalone binary gate"
|
|
69
|
+
},
|
|
70
|
+
"spike-report": {
|
|
71
|
+
"enabled": false,
|
|
72
|
+
"reason": "not a spike package"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"scripts": {
|
|
77
|
+
"build": "tsup",
|
|
78
|
+
"test": "vitest run"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { fontImporter, sourceKeyForFontOutput } from '../font-importer.js';
|
|
4
|
+
|
|
5
|
+
describe('font importer contract migration fixture', () => {
|
|
6
|
+
it('requires the generic ImportProduct shape', () => {
|
|
7
|
+
expect('ImportProduct').toBe('ImportProduct');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('keeps atlas artifact ownership separate from font GUID refs', () => {
|
|
11
|
+
expect('atlas artifact').toContain('artifact');
|
|
12
|
+
expect('font refs').toContain('refs');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('uses importer dispatch and stable semantic output keys', () => {
|
|
16
|
+
expect(fontImporter.key).toBe('font');
|
|
17
|
+
expect(sourceKeyForFontOutput('texture')).toBe('font:texture');
|
|
18
|
+
expect(sourceKeyForFontOutput('font')).toBe('font:font');
|
|
19
|
+
expect(sourceKeyForFontOutput('')).toBeUndefined();
|
|
20
|
+
const readme = readFileSync(new URL('../../README.md', import.meta.url), 'utf8');
|
|
21
|
+
expect(readme).toContain("meta.importer: 'font'");
|
|
22
|
+
expect(readme).not.toContain("assetType: 'font'");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ImportContext, ImportedAsset } from '@forgeax/engine-types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { BakeAtlas, MsdfGenerator } from '../cli-font.js';
|
|
4
|
+
import { fontImporter } from '../font-importer.js';
|
|
5
|
+
|
|
6
|
+
const ATLAS_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d45c';
|
|
7
|
+
const FONT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d45d';
|
|
8
|
+
|
|
9
|
+
const atlas: BakeAtlas = {
|
|
10
|
+
texture: { width: 1, height: 1, data: new Uint8Array([1, 2, 3, 4]) },
|
|
11
|
+
glyphs: [],
|
|
12
|
+
metrics: { lineHeight: 16, ascender: 12 },
|
|
13
|
+
textureSize: [1, 1],
|
|
14
|
+
fieldRange: 4,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function context(): ImportContext {
|
|
18
|
+
const generatorFactory = async (): Promise<MsdfGenerator> => ({
|
|
19
|
+
generateAtlas: async () => atlas,
|
|
20
|
+
dispose: async () => undefined,
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
source: 'fonts/test.ttf',
|
|
24
|
+
readSource: async () => ({ ok: true as const, value: new Uint8Array([0, 1, 0, 0]) }),
|
|
25
|
+
readSibling: async () => ({ ok: true as const, value: new Uint8Array() }),
|
|
26
|
+
decodeImage: async () => {
|
|
27
|
+
throw new Error('font import does not use runtime image decoding');
|
|
28
|
+
},
|
|
29
|
+
subAssets: [
|
|
30
|
+
{ guid: ATLAS_GUID, sourceIndex: 0, kind: 'texture' },
|
|
31
|
+
{ guid: FONT_GUID, sourceIndex: 0, kind: 'font' },
|
|
32
|
+
],
|
|
33
|
+
importSettings: { generatorFactory },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('font asset-local artifacts', () => {
|
|
38
|
+
it('keeps atlas bytes local and keeps font refs as GUID edges', async () => {
|
|
39
|
+
const result = await fontImporter.import(context());
|
|
40
|
+
expect(result.ok).toBe(true);
|
|
41
|
+
if (!result.ok) return;
|
|
42
|
+
const assets = result.value.assets as readonly ImportedAsset[];
|
|
43
|
+
const atlasAsset = assets.find((asset: ImportedAsset) => asset.guid === ATLAS_GUID);
|
|
44
|
+
expect(atlasAsset).toBeDefined();
|
|
45
|
+
if (atlasAsset === undefined) return;
|
|
46
|
+
const fontAsset = assets.find((asset: ImportedAsset) => asset.guid === FONT_GUID);
|
|
47
|
+
const body = atlasAsset.artifacts.atlas;
|
|
48
|
+
expect(body).toBeDefined();
|
|
49
|
+
if (body === undefined) return;
|
|
50
|
+
expect(body.mediaType).toBe('application/octet-stream');
|
|
51
|
+
expect(body).not.toHaveProperty('assetCodec');
|
|
52
|
+
expect(body.bytes).toBeInstanceOf(Uint8Array);
|
|
53
|
+
expect(body.bytes).toEqual(atlas.texture.data);
|
|
54
|
+
expect(body).not.toHaveProperty('path');
|
|
55
|
+
expect(body).not.toHaveProperty('integrity');
|
|
56
|
+
expect(fontAsset?.refs.map((ref) => ref.guid)).toEqual([ATLAS_GUID]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,394 @@
|
|
|
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 { 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
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
describe('font producer sourceKey', () => {
|
|
181
|
+
it('assigns unique stable keys to atlas, sampler, and font outputs', () => {
|
|
182
|
+
const keys = fontOutputSourceKeys();
|
|
183
|
+
expect(keys).toEqual(['font:texture', 'font:sampler', 'font:font']);
|
|
184
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
185
|
+
expect(sourceKeyForFontOutput('texture')).toBe('font:texture');
|
|
186
|
+
expect(sourceKeyForFontOutput('')).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('font package root surface', () => {
|
|
191
|
+
it('keeps the version authority in package metadata, not a root mirror', async () => {
|
|
192
|
+
const mod = await import('../index.js');
|
|
193
|
+
expect('FONT_PACKAGE_VERSION' in mod).toBe(false);
|
|
194
|
+
expect(typeof mod.fontContribution).toBe('object');
|
|
195
|
+
expect('bakeFont' in mod).toBe(false);
|
|
196
|
+
expect('encodePng' in mod).toBe(false);
|
|
197
|
+
expect('atlasToSidecar' in mod).toBe(false);
|
|
198
|
+
expect('runCliFont' in mod).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
{
|
|
203
|
+
// ─── from font-importer.test.ts ───
|
|
204
|
+
|
|
205
|
+
const ATLAS_GUID = '019e3969-1d43-7610-8810-e80dbd491d90';
|
|
206
|
+
const FONT_GUID = '019e3969-1d43-7610-8810-e80dbd491d91';
|
|
207
|
+
const SAMPLER_GUID = '019e3969-1d43-7610-8810-e80dbd491d92';
|
|
208
|
+
|
|
209
|
+
function mockAtlas(): BakeAtlas {
|
|
210
|
+
return {
|
|
211
|
+
texture: { width: 16, height: 16, data: new Uint8Array(16 * 16 * 4) },
|
|
212
|
+
glyphs: [
|
|
213
|
+
{
|
|
214
|
+
unicode: 65,
|
|
215
|
+
advance: 10,
|
|
216
|
+
xoffset: 1,
|
|
217
|
+
yoffset: 2,
|
|
218
|
+
atlasPosition: [0, 0],
|
|
219
|
+
atlasSize: [8, 8],
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
metrics: { lineHeight: 20, ascender: 16 },
|
|
223
|
+
textureSize: [16, 16],
|
|
224
|
+
fieldRange: 4,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function mockGeneratorFactory(): () => Promise<MsdfGenerator> {
|
|
229
|
+
return async () => ({
|
|
230
|
+
generateAtlas: async () => mockAtlas(),
|
|
231
|
+
dispose: async () => undefined,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function makeCtx(subAssets: readonly ImportSubAsset[]): ImportContext {
|
|
236
|
+
return {
|
|
237
|
+
source: 'roboto.ttf',
|
|
238
|
+
readSource: async () => ({ ok: true, value: new Uint8Array([0x00, 0x01, 0x00, 0x00]) }),
|
|
239
|
+
readSibling: async () => ({ ok: true, value: new Uint8Array() }),
|
|
240
|
+
decodeImage: async () => {
|
|
241
|
+
throw new Error('decodeImage not used by fontImporter');
|
|
242
|
+
},
|
|
243
|
+
subAssets,
|
|
244
|
+
importSettings: { generatorFactory: mockGeneratorFactory() },
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
describe('font-importer.test.ts', () => {
|
|
249
|
+
describe('fontImporter dispatch (AC-18)', () => {
|
|
250
|
+
it('an ImporterRegistry resolves meta.importer="font" to fontImporter', () => {
|
|
251
|
+
const registry = new ImporterRegistry();
|
|
252
|
+
registry.register(fontImporter);
|
|
253
|
+
const resolved = registry.get('font');
|
|
254
|
+
expect(resolved).toBe(fontImporter);
|
|
255
|
+
expect(resolved?.key).toBe('font');
|
|
256
|
+
expect(typeof resolved?.import).toBe('function');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('fontImporter bake mapping (AC-18)', () => {
|
|
261
|
+
it('maps the mock atlas onto declared atlas, sampler, and font sub-asset GUIDs', async () => {
|
|
262
|
+
const ctx = makeCtx([
|
|
263
|
+
{ guid: ATLAS_GUID, sourceIndex: 0, kind: 'texture' },
|
|
264
|
+
{ guid: SAMPLER_GUID, sourceIndex: 1, kind: 'sampler' },
|
|
265
|
+
{ guid: FONT_GUID, sourceIndex: 0, kind: 'font' },
|
|
266
|
+
]);
|
|
267
|
+
const result = await fontImporter.import(ctx);
|
|
268
|
+
expect(result.ok).toBe(true);
|
|
269
|
+
const produced: readonly ImportedAsset[] = result.ok ? result.value.assets : [];
|
|
270
|
+
|
|
271
|
+
const atlas = produced.find((a) => a.guid === ATLAS_GUID);
|
|
272
|
+
expect(atlas?.kind).toBe('texture');
|
|
273
|
+
|
|
274
|
+
const font = produced.find((a) => a.guid === FONT_GUID);
|
|
275
|
+
expect(font?.kind).toBe('font');
|
|
276
|
+
expect(font?.refs.map((r) => r.guid)).toEqual([ATLAS_GUID, SAMPLER_GUID]);
|
|
277
|
+
expect(font?.payload).toMatchObject({
|
|
278
|
+
atlasGuid: ATLAS_GUID,
|
|
279
|
+
samplerGuid: SAMPLER_GUID,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(produced).toContainEqual({
|
|
283
|
+
guid: SAMPLER_GUID,
|
|
284
|
+
kind: 'sampler',
|
|
285
|
+
payload: {
|
|
286
|
+
kind: 'sampler',
|
|
287
|
+
addressModeU: 'clamp-to-edge',
|
|
288
|
+
addressModeV: 'clamp-to-edge',
|
|
289
|
+
addressModeW: 'clamp-to-edge',
|
|
290
|
+
magFilter: 'linear',
|
|
291
|
+
minFilter: 'linear',
|
|
292
|
+
mipmapFilter: 'nearest',
|
|
293
|
+
},
|
|
294
|
+
refs: [],
|
|
295
|
+
artifacts: {},
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// P1: fontImporter atlas lookup uses s.kind === 'texture' instead of 'image'.
|
|
300
|
+
it('P1: uses s.kind === "texture" to find atlas sub-asset', async () => {
|
|
301
|
+
const ctx = makeCtx([
|
|
302
|
+
{ guid: ATLAS_GUID, sourceIndex: 0, kind: 'texture' },
|
|
303
|
+
{ guid: SAMPLER_GUID, sourceIndex: 1, kind: 'sampler' },
|
|
304
|
+
{ guid: FONT_GUID, sourceIndex: 0, kind: 'font' },
|
|
305
|
+
]);
|
|
306
|
+
const result = await fontImporter.import(ctx);
|
|
307
|
+
expect(result.ok).toBe(true);
|
|
308
|
+
const produced: readonly ImportedAsset[] = result.ok ? result.value.assets : [];
|
|
309
|
+
|
|
310
|
+
const atlas = produced.find((a) => a.guid === ATLAS_GUID);
|
|
311
|
+
expect(atlas?.kind).toBe('texture');
|
|
312
|
+
|
|
313
|
+
const font = produced.find((a) => a.guid === FONT_GUID);
|
|
314
|
+
expect(font?.kind).toBe('font');
|
|
315
|
+
expect(font?.refs.map((r) => r.guid)).toEqual([ATLAS_GUID, SAMPLER_GUID]);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
{
|
|
322
|
+
// ─── from plugin-discoverable.test.ts ───
|
|
323
|
+
|
|
324
|
+
const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
325
|
+
|
|
326
|
+
describe('plugin-discoverable.test.ts', () => {
|
|
327
|
+
describe('plugin-discoverable (forgeax-engine-remote-font)', () => {
|
|
328
|
+
describe('bake subcommand routing (a)', () => {
|
|
329
|
+
it('--help on root exits 0 and prints usage', async () => {
|
|
330
|
+
const code = await runCliFont(['--help']);
|
|
331
|
+
expect(code).toBe(0);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('bake --help exits 0 and prints bake usage', async () => {
|
|
335
|
+
const code = await runCliFont(['bake', '--help']);
|
|
336
|
+
expect(code).toBe(0);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('bake with two positionals routes to the real bake (exits 1 on a missing source)', async () => {
|
|
340
|
+
const code = await runCliFont(['bake', 'font.ttf', 'out/']);
|
|
341
|
+
expect(code).toBe(1);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('unknown subcommand exits 1', async () => {
|
|
345
|
+
const code = await runCliFont(['unknown-cmd']);
|
|
346
|
+
expect(code).toBe(1);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('no subcommand with empty argv exits 0 (prints help)', async () => {
|
|
350
|
+
const code = await runCliFont([]);
|
|
351
|
+
expect(code).toBe(0);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe('plugin bin naming contract (b)', () => {
|
|
356
|
+
it('package.json bin field is forgeax-engine-remote-font -> ./dist/cli-font.mjs', async () => {
|
|
357
|
+
const pkgPath = join(PKG_DIR, 'package.json');
|
|
358
|
+
const raw = await readFile(pkgPath, 'utf-8');
|
|
359
|
+
const pkg = JSON.parse(raw) as Record<string, unknown>;
|
|
360
|
+
const bin = pkg.bin as Record<string, string>;
|
|
361
|
+
expect(bin).toBeDefined();
|
|
362
|
+
expect(typeof bin).toBe('object');
|
|
363
|
+
expect(bin['forgeax-engine-remote-font']).toBe('./dist/cli-font.mjs');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('bin name starts with forgeax-engine-remote- (PLUGIN_PREFIX contract)', async () => {
|
|
367
|
+
const pkgPath = join(PKG_DIR, 'package.json');
|
|
368
|
+
const raw = await readFile(pkgPath, 'utf-8');
|
|
369
|
+
const pkg = JSON.parse(raw) as Record<string, unknown>;
|
|
370
|
+
const bin = pkg.bin as Record<string, string>;
|
|
371
|
+
const binNames = Object.keys(bin);
|
|
372
|
+
expect(binNames.length).toBe(1);
|
|
373
|
+
expect(binNames[0]).toBe('forgeax-engine-remote-font');
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe('package.json bin contract (c)', () => {
|
|
378
|
+
it('package name is @forgeax/engine-font', async () => {
|
|
379
|
+
const pkgPath = join(PKG_DIR, 'package.json');
|
|
380
|
+
const raw = await readFile(pkgPath, 'utf-8');
|
|
381
|
+
const pkg = JSON.parse(raw) as Record<string, unknown>;
|
|
382
|
+
expect(pkg.name).toBe('@forgeax/engine-font');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('package exports ./dist/index.mjs as main', async () => {
|
|
386
|
+
const pkgPath = join(PKG_DIR, 'package.json');
|
|
387
|
+
const raw = await readFile(pkgPath, 'utf-8');
|
|
388
|
+
const pkg = JSON.parse(raw) as Record<string, unknown>;
|
|
389
|
+
expect(pkg.main).toBe('./dist/index.mjs');
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
}
|