@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,254 @@
1
+ // font-importer.ts - the build-time fontImporter (feat-20260603-asset-import-loader-injection M3 / w24).
2
+ //
3
+ // The `{ key: 'font', import }` Importer the @forgeax/engine-import runner
4
+ // dispatches a `*.meta.json` with `importer: 'font'` to. It absorbs the MSDF
5
+ // bake that previously lived only behind the `forgeax-engine-remote-font
6
+ // bake` CLI: read the `.ttf` source -> @zappar/msdf-generator atlas ->
7
+ // (a) one atlas `TextureAsset` ImportedAsset (the RGBA MSDF atlas, kind
8
+ // 'texture') under the declared `kind: 'texture'` sub-asset GUID, (b) one
9
+ // sampler ImportedAsset under the declared `kind: 'sampler'` sub-asset GUID,
10
+ // and (c) one font glyph-metrics ImportedAsset (kind 'font') under the
11
+ // declared `kind: 'font'` sub-asset GUID. The font carries the BMFont ->
12
+ // FontAsset glyph map + the common block + atlas/sampler GUID refs.
13
+ //
14
+ // Build-time-only boundary (AC-18 / requirements callout): @zappar/msdf-generator
15
+ // is a generator dependency that MUST stay out of the runtime bundle. The
16
+ // runtime today statically imports ZERO @forgeax/engine-font and ZERO @zappar
17
+ // symbols (research Finding 7); this importer keeps that invariant. fontImporter
18
+ // is a NODE-ONLY sub-export (`@forgeax/engine-font/font-importer`,
19
+ // `default: null` under browser conditions); the generator is dynamically
20
+ // imported (cli-font realGeneratorFactory) so even the build-time graph only
21
+ // pays the wasm load cost when a font is actually baked. The runtime fontLoader
22
+ // (M1 w6) reads an already-baked atlas DDC and has no bake dependency.
23
+ //
24
+ // GUID import-stable iron law: produced GUIDs come from `ctx.subAssets[]`. The
25
+ // font sidecar declares `texture`, `sampler`, and `font` sub-assets; this
26
+ // importer maps the bake output onto those declared GUIDs and stamps nothing
27
+ // of its own.
28
+ //
29
+ // `ctx.importSettings` may inject a test `generatorFactory` (an
30
+ // `() => Promise<MsdfGenerator>`) so unit tests drive the bake with a mock
31
+ // instead of the real wasm generator; production import calls fall through to
32
+ // the real @zappar factory.
33
+
34
+ import {
35
+ type FontAsset,
36
+ type GlyphMetric,
37
+ IMPORT_ERROR_HINTS,
38
+ type ImportContext,
39
+ ImportError,
40
+ type ImportedAsset,
41
+ type Importer,
42
+ type ImportResult,
43
+ type SamplerAsset,
44
+ type TextureAsset,
45
+ } from '@forgeax/engine-types';
46
+ import type { BakeAtlas, MsdfGenerator } from './cli-font.js';
47
+ import { realGeneratorFactory } from './cli-font.js';
48
+
49
+ /** Stable semantic identities for the three writable font outputs. */
50
+ export function sourceKeyForFontOutput(kind: string): string | undefined {
51
+ const normalizedKind = kind.trim();
52
+ return normalizedKind.length === 0 ? undefined : `font:${normalizedKind}`;
53
+ }
54
+
55
+ export function fontOutputSourceKeys(): readonly string[] {
56
+ return ['texture', 'sampler', 'font'].map((kind) => sourceKeyForFontOutput(kind) as string);
57
+ }
58
+
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
+ /** Map the @zappar atlas glyphs into the FontAsset glyph-metrics record. */
95
+ function atlasGlyphsToMetrics(atlas: BakeAtlas): Record<number, GlyphMetric> {
96
+ const glyphs: Record<number, GlyphMetric> = {};
97
+ for (const g of atlas.glyphs) {
98
+ glyphs[g.unicode] = {
99
+ advance: g.advance,
100
+ bearingX: g.xoffset,
101
+ bearingY: g.yoffset,
102
+ size: { w: g.atlasSize[0], h: g.atlasSize[1] },
103
+ region: {
104
+ x: g.atlasPosition[0],
105
+ y: g.atlasPosition[1],
106
+ w: g.atlasSize[0],
107
+ h: g.atlasSize[1],
108
+ },
109
+ };
110
+ }
111
+ return glyphs;
112
+ }
113
+
114
+ function makeAtlasTexture(atlas: BakeAtlas): TextureAsset {
115
+ return {
116
+ kind: 'texture',
117
+ width: atlas.texture.width,
118
+ height: atlas.texture.height,
119
+ // MSDF atlas is linear-space RGBA8 (signed-distance channels, never gamma).
120
+ format: 'rgba8unorm',
121
+ data: atlas.texture.data,
122
+ colorSpace: 'linear',
123
+ mipmap: false,
124
+ };
125
+ }
126
+
127
+ function makeFontCommon(atlas: BakeAtlas): FontAsset['common'] {
128
+ return {
129
+ lineHeight: atlas.metrics.lineHeight,
130
+ base: atlas.metrics.ascender,
131
+ distanceRange: atlas.fieldRange,
132
+ pxRange: atlas.fieldRange,
133
+ atlasWidth: atlas.textureSize[0],
134
+ atlasHeight: atlas.textureSize[1],
135
+ };
136
+ }
137
+
138
+ function makeAtlasSampler(): SamplerAsset {
139
+ return {
140
+ kind: 'sampler',
141
+ addressModeU: 'clamp-to-edge',
142
+ addressModeV: 'clamp-to-edge',
143
+ addressModeW: 'clamp-to-edge',
144
+ magFilter: 'linear',
145
+ minFilter: 'linear',
146
+ mipmapFilter: 'nearest',
147
+ };
148
+ }
149
+
150
+ async function importFont(ctx: ImportContext): Promise<ImportResult> {
151
+ const topologyError = validateRequiredFontSubAssets(ctx);
152
+ if (topologyError !== undefined) return { ok: false, error: topologyError };
153
+
154
+ const read = await ctx.readSource();
155
+ if (!read.ok) {
156
+ return {
157
+ ok: false,
158
+ error: new ImportError({
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
+ };
168
+ }
169
+
170
+ const factory =
171
+ (ctx.importSettings.generatorFactory as (() => Promise<MsdfGenerator>) | undefined) ??
172
+ realGeneratorFactory;
173
+ const generator = await factory();
174
+ let atlas: BakeAtlas;
175
+ try {
176
+ atlas = await generator.generateAtlas(read.value);
177
+ } finally {
178
+ await generator.dispose().catch(() => undefined);
179
+ }
180
+
181
+ const atlasSub = ctx.subAssets.find((s) => s.kind === 'texture');
182
+ const samplerSub = ctx.subAssets.find((s) => s.kind === 'sampler');
183
+ const fontSub = ctx.subAssets.find((s) => s.kind === 'font');
184
+ const out: ImportedAsset[] = [];
185
+ if (atlasSub !== undefined) {
186
+ out.push({
187
+ guid: atlasSub.guid,
188
+ kind: 'texture',
189
+ payload: makeAtlasTexture(atlas),
190
+ refs: [],
191
+ artifacts: {
192
+ atlas: {
193
+ // Pack v2's runtime texture loader consumes non-Basis artifacts as
194
+ // raw pixels. Keep the baked RGBA8 MSDF atlas in that form here;
195
+ // the CLI still emits a PNG for standalone bake output.
196
+ mediaType: 'application/octet-stream',
197
+ bytes: atlas.texture.data,
198
+ },
199
+ },
200
+ });
201
+ }
202
+ if (samplerSub !== undefined) {
203
+ out.push({
204
+ guid: samplerSub.guid,
205
+ kind: 'sampler',
206
+ payload: makeAtlasSampler(),
207
+ refs: [],
208
+ artifacts: {},
209
+ });
210
+ }
211
+ if (fontSub !== undefined) {
212
+ // The runtime font loader reads atlasGuid / samplerGuid / glyphs / common
213
+ // off the DDC payload (asset-registry loadFontAsset); the produced payload
214
+ // mirrors that shape. atlas Handle / sampler Handle are runtime-resolved
215
+ // from the GUID refs, so the build-time payload carries the GUID strings
216
+ // (cast through FontAsset for the ImportedAsset.payload Asset slot, same
217
+ // build-time POD-vs-Handle bridge the gltfImporter scene arm uses).
218
+ const fontPayload = {
219
+ kind: 'font',
220
+ atlasGuid: atlasSub?.guid ?? '',
221
+ samplerGuid: samplerSub?.guid ?? '',
222
+ glyphs: atlasGlyphsToMetrics(atlas),
223
+ common: makeFontCommon(atlas),
224
+ } as unknown as FontAsset;
225
+ out.push({
226
+ guid: fontSub.guid,
227
+ kind: 'font',
228
+ payload: fontPayload,
229
+ refs: [
230
+ ...(atlasSub !== undefined ? [{ guid: atlasSub.guid }] : []),
231
+ ...(samplerSub !== undefined ? [{ guid: samplerSub.guid }] : []),
232
+ ],
233
+ artifacts: {},
234
+ });
235
+ }
236
+ return { ok: true, value: { assets: out, sourceDependencies: [] } };
237
+ }
238
+
239
+ /**
240
+ * The font {@link Importer}. Register it into an `ImporterRegistry` so the
241
+ * import runner dispatches `meta.importer === 'font'` sidecars here.
242
+ *
243
+ * @example
244
+ * ```ts
245
+ * import { ImporterRegistry } from '@forgeax/engine-import';
246
+ * import { fontImporter } from '@forgeax/engine-font/font-importer';
247
+ * const importers = new ImporterRegistry();
248
+ * importers.register(fontImporter);
249
+ * ```
250
+ */
251
+ export const fontImporter: Importer = {
252
+ key: 'font',
253
+ import: importFont,
254
+ };
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // @forgeax/engine-font - runtime-safe font asset contribution.
2
+ //
3
+ // Build-time baking lives behind the explicit Node-only `./cli-font` and
4
+ // `./font-importer` subpaths. Keeping the main entry free of the CLI's
5
+ // `node:buffer`/WASM graph prevents App bundles from pulling the producer
6
+ // toolchain into the browser runtime.
7
+ export { fontContribution } from './runtime/font-decoder.js';
@@ -0,0 +1,38 @@
1
+ import { parentPort } from 'node:worker_threads';
2
+
3
+ if (parentPort === null) {
4
+ throw new Error('the MSDF Node worker requires worker_threads.parentPort');
5
+ }
6
+
7
+ const listeners = new Map();
8
+ const endpoint = globalThis;
9
+
10
+ class NodeImageData {
11
+ constructor(data, width, height) {
12
+ this.data = data;
13
+ this.width = width;
14
+ this.height = height;
15
+ }
16
+ }
17
+
18
+ endpoint.ImageData = NodeImageData;
19
+ endpoint.addEventListener = (type, listener) => {
20
+ if (type !== 'message') return;
21
+ const handler = (data) => listener({ data, origin: '*' });
22
+ listeners.set(listener, handler);
23
+ parentPort.on('message', handler);
24
+ };
25
+
26
+ endpoint.removeEventListener = (type, listener) => {
27
+ if (type !== 'message') return;
28
+ const handler = listeners.get(listener);
29
+ if (handler === undefined) return;
30
+ listeners.delete(listener);
31
+ parentPort.off('message', handler);
32
+ };
33
+
34
+ endpoint.postMessage = (message, transferList = []) => {
35
+ parentPort.postMessage(message, [...transferList]);
36
+ };
37
+
38
+ await import('@zappar/msdf-generator/worker.js');
@@ -0,0 +1,41 @@
1
+ import { Worker } from 'node:worker_threads';
2
+
3
+ interface MessageEventLike {
4
+ readonly data: unknown;
5
+ readonly origin: string;
6
+ }
7
+
8
+ type MessageListener = (event: MessageEventLike) => void;
9
+
10
+ /** Comlink's browser Worker-shaped endpoint backed by a Node worker thread. */
11
+ export class NodeWorkerAdapter {
12
+ private readonly worker: Worker;
13
+ private readonly listeners = new Map<MessageListener, (data: unknown) => void>();
14
+
15
+ public constructor(url: string | URL) {
16
+ this.worker = new Worker(url);
17
+ }
18
+
19
+ public postMessage(message: unknown, transferList: readonly ArrayBuffer[] = []): void {
20
+ this.worker.postMessage(message, [...transferList]);
21
+ }
22
+
23
+ public addEventListener(type: string, listener: MessageListener): void {
24
+ if (type !== 'message') return;
25
+ const handler = (data: unknown) => listener({ data, origin: '*' });
26
+ this.listeners.set(listener, handler);
27
+ this.worker.on('message', handler);
28
+ }
29
+
30
+ public removeEventListener(type: string, listener: MessageListener): void {
31
+ if (type !== 'message') return;
32
+ const handler = this.listeners.get(listener);
33
+ if (handler === undefined) return;
34
+ this.listeners.delete(listener);
35
+ this.worker.off('message', handler);
36
+ }
37
+
38
+ public terminate(): Promise<number> {
39
+ return this.worker.terminate();
40
+ }
41
+ }
@@ -0,0 +1,70 @@
1
+ import { AssetGuid } from '@forgeax/engine-pack/guid';
2
+ import {
3
+ type AssetDecoderContribution,
4
+ type AssetKind,
5
+ err,
6
+ type FontAsset,
7
+ ok,
8
+ } from '@forgeax/engine-types';
9
+
10
+ function parseGuid(value: unknown): FontAsset['atlas'] | undefined {
11
+ if (value instanceof Uint8Array && value.length === 16) return value as FontAsset['atlas'];
12
+ if (
13
+ Array.isArray(value) &&
14
+ value.length === 16 &&
15
+ value.every((item) => Number.isInteger(item) && item >= 0 && item <= 255)
16
+ ) {
17
+ return Uint8Array.from(value) as FontAsset['atlas'];
18
+ }
19
+ if (typeof value !== 'string') return undefined;
20
+ const parsed = AssetGuid.parse(value);
21
+ return parsed.ok ? parsed.value : undefined;
22
+ }
23
+
24
+ function validCommon(value: unknown): value is FontAsset['common'] {
25
+ if (value === null || typeof value !== 'object') return false;
26
+ const common = value as Record<string, unknown>;
27
+ return ['lineHeight', 'base', 'distanceRange', 'pxRange', 'atlasWidth', 'atlasHeight'].every(
28
+ (key) => typeof common[key] === 'number' && Number.isFinite(common[key]),
29
+ );
30
+ }
31
+
32
+ export const fontContribution: AssetDecoderContribution<FontAsset, 'font'> = {
33
+ kind: { kind: 'font' } as AssetKind<FontAsset, 'font'>,
34
+ consumer: 'GlyphTextLayout',
35
+ decoder: {
36
+ async decode({ envelope }) {
37
+ const payload = envelope.payload as unknown;
38
+ if (payload !== null && typeof payload === 'object') {
39
+ const source = payload as Record<string, unknown>;
40
+ const atlas = parseGuid(source.atlas ?? source.atlasGuid);
41
+ const sampler = parseGuid(source.sampler ?? source.samplerGuid);
42
+ if (
43
+ (source.kind === undefined || source.kind === 'font') &&
44
+ atlas !== undefined &&
45
+ sampler !== undefined &&
46
+ source.glyphs !== null &&
47
+ typeof source.glyphs === 'object' &&
48
+ validCommon(source.common)
49
+ ) {
50
+ return ok({
51
+ kind: 'font',
52
+ atlas,
53
+ sampler,
54
+ glyphs: source.glyphs as FontAsset['glyphs'],
55
+ common: source.common,
56
+ ...(source.notdef === undefined
57
+ ? {}
58
+ : { notdef: source.notdef as NonNullable<FontAsset['notdef']> }),
59
+ });
60
+ }
61
+ }
62
+ return err({
63
+ code: 'asset-package-invalid',
64
+ expected: 'a font payload with atlas and sampler references',
65
+ hint: 'recook the font atlas and publish its local sub-assets',
66
+ detail: { guid: envelope.guid, reason: 'font owner validation failed' },
67
+ });
68
+ },
69
+ },
70
+ };