@flighthq/bitmapfont-formats 0.5.1-next.920.da8f530 → 0.6.0

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 (64) hide show
  1. package/README.md +2 -2
  2. package/dist/.tsbuildinfo.contract +1 -0
  3. package/dist/.tsbuildinfo.runtime +1 -0
  4. package/dist/bitmapFontBinary.d.ts +4 -0
  5. package/dist/bitmapFontBinary.d.ts.map +1 -0
  6. package/dist/bitmapFontBinary.js +129 -0
  7. package/dist/bitmapFontBinary.js.map +1 -0
  8. package/dist/bitmapFontDetect.d.ts +59 -0
  9. package/dist/bitmapFontDetect.d.ts.map +1 -0
  10. package/dist/bitmapFontDetect.js +133 -0
  11. package/dist/bitmapFontDetect.js.map +1 -0
  12. package/dist/bitmapFontFnt.d.ts +2 -1
  13. package/dist/bitmapFontFnt.d.ts.map +1 -1
  14. package/dist/bitmapFontFnt.js +1 -1
  15. package/dist/bitmapFontFnt.js.map +1 -1
  16. package/dist/bitmapFontFormatKind.d.ts +37 -0
  17. package/dist/bitmapFontFormatKind.d.ts.map +1 -0
  18. package/dist/bitmapFontFormatKind.js +123 -0
  19. package/dist/bitmapFontFormatKind.js.map +1 -0
  20. package/dist/bitmapFontJson.d.ts +2 -1
  21. package/dist/bitmapFontJson.d.ts.map +1 -1
  22. package/dist/bitmapFontJson.js +4 -3
  23. package/dist/bitmapFontJson.js.map +1 -1
  24. package/dist/bitmapFontRecord.d.ts +3 -1
  25. package/dist/bitmapFontRecord.d.ts.map +1 -1
  26. package/dist/bitmapFontRecord.js +1 -2
  27. package/dist/bitmapFontRecord.js.map +1 -1
  28. package/dist/bitmapFontXml.d.ts +2 -1
  29. package/dist/bitmapFontXml.d.ts.map +1 -1
  30. package/dist/bitmapFontXml.js +1 -1
  31. package/dist/bitmapFontXml.js.map +1 -1
  32. package/dist/bitmapfontFormatRequirements.d.ts +9 -0
  33. package/dist/bitmapfontFormatRequirements.d.ts.map +1 -0
  34. package/dist/bitmapfontFormatRequirements.js +9 -0
  35. package/dist/bitmapfontFormatRequirements.js.map +1 -0
  36. package/dist/contract.bitmapfont-formats-types.d.ts +2 -0
  37. package/dist/contract.bitmapfont-formats-types.d.ts.map +1 -0
  38. package/dist/contract.bitmapfont-formats-types.js +4 -0
  39. package/dist/contract.bitmapfont-formats-types.js.map +1 -0
  40. package/dist/contract.d.ts +10 -3
  41. package/dist/contract.d.ts.map +1 -1
  42. package/dist/contract.js +10 -3
  43. package/dist/contract.js.map +1 -1
  44. package/dist/explainBitmapFontParse.d.ts +4 -0
  45. package/dist/explainBitmapFontParse.d.ts.map +1 -0
  46. package/dist/explainBitmapFontParse.js +194 -0
  47. package/dist/explainBitmapFontParse.js.map +1 -0
  48. package/dist/index.d.ts +9 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +9 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/types.d.ts +59 -0
  53. package/dist/types.d.ts.map +1 -0
  54. package/dist/types.js +5 -0
  55. package/dist/types.js.map +1 -0
  56. package/package.json +13 -7
  57. package/src/bitmapFontBinary.test.ts +196 -0
  58. package/src/bitmapFontDetect.test.ts +335 -0
  59. package/src/bitmapFontFnt.test.ts +5 -2
  60. package/src/bitmapFontFormatKind.test.ts +71 -0
  61. package/src/bitmapFontJson.test.ts +5 -5
  62. package/src/bitmapFontRecord.test.ts +3 -2
  63. package/src/bitmapFontXml.test.ts +2 -2
  64. package/src/explainBitmapFontParse.test.ts +161 -0
@@ -0,0 +1,335 @@
1
+ import { encodeUTF8 } from '@flighthq/encoding/contract';
2
+ import { createTextureAtlas } from '@flighthq/textureatlas/contract';
3
+
4
+ import {
5
+ applyBitmapFontImportOptions,
6
+ bitmapFontAllFormats,
7
+ bmFontBinaryFormat,
8
+ bmFontJsonFormat,
9
+ bmFontTextFormat,
10
+ bmFontXmlFormat,
11
+ detectBitmapFontFormat,
12
+ getBitmapFontFormat,
13
+ getBitmapFontFormatKinds,
14
+ parseBitmapFont,
15
+ registerBitmapFontFormat,
16
+ unregisterBitmapFontFormat,
17
+ } from './bitmapFontDetect.ts';
18
+ import {
19
+ BitmapFontFormatKindBmFontBinary,
20
+ BitmapFontFormatKindBmFontJson,
21
+ BitmapFontFormatKindBmFontText,
22
+ BitmapFontFormatKindBmFontXml,
23
+ } from './types.ts';
24
+ import type { BitmapFontFormatKind } from './types.ts';
25
+
26
+ const TEXT = encodeUTF8(
27
+ [
28
+ 'info face="Test" size=32 unicode=1',
29
+ 'common lineHeight=32 base=26 scaleW=64 scaleH=64 pages=1 packed=0',
30
+ 'page id=0 file="test_0.png"',
31
+ 'chars count=1',
32
+ 'char id=65 x=0 y=0 width=7 height=8 xoffset=1 yoffset=5 xadvance=9 page=0 chnl=15',
33
+ ].join('\n'),
34
+ );
35
+
36
+ const XML = encodeUTF8(
37
+ [
38
+ '<?xml version="1.0"?>',
39
+ '<!-- exported by hand -->',
40
+ '<font>',
41
+ ' <common lineHeight="32" base="26" scaleW="64" scaleH="64" pages="1"/>',
42
+ ' <pages><page id="0" file="test_0.png"/></pages>',
43
+ ' <chars count="1">',
44
+ ' <char id="65" x="0" y="0" width="7" height="8" xoffset="1" yoffset="5" xadvance="9" page="0" chnl="15"/>',
45
+ ' </chars>',
46
+ '</font>',
47
+ ].join('\n'),
48
+ );
49
+
50
+ const JSON_TEXT = encodeUTF8(
51
+ JSON.stringify({
52
+ chars: [{ chnl: 15, height: 8, id: 65, page: 0, width: 7, x: 0, xadvance: 9, xoffset: 1, y: 0, yoffset: 5 }],
53
+ common: { base: 26, lineHeight: 32, pages: 1, scaleH: 64, scaleW: 64 },
54
+ info: { face: 'Test', size: 32 },
55
+ pages: ['test_0.png'],
56
+ }),
57
+ );
58
+
59
+ // One document per built-in format, shared by the exclusivity proof and the reverse-order proof: both are
60
+ // statements about the same corpus, and a second copy of it could drift from the first without failing either.
61
+ const CORPUS: readonly (readonly [BitmapFontFormatKind, Readonly<Uint8Array>])[] = [
62
+ [BitmapFontFormatKindBmFontBinary, buildBinaryFont()],
63
+ [BitmapFontFormatKindBmFontJson, JSON_TEXT],
64
+ [BitmapFontFormatKindBmFontText, TEXT],
65
+ [BitmapFontFormatKindBmFontXml, XML],
66
+ ];
67
+
68
+ describe('applyBitmapFontImportOptions', () => {
69
+ it('installs a named format and nothing else', () => {
70
+ const kind = 'acme.SeamFont' as BitmapFontFormatKind;
71
+ expect(getBitmapFontFormat(kind)).toBeNull();
72
+ applyBitmapFontImportOptions({ formats: [{ entry: { detect: () => false, parse: () => null }, kind }] });
73
+ expect(getBitmapFontFormat(kind)).not.toBeNull();
74
+ unregisterBitmapFontFormat(kind);
75
+ expect(getBitmapFontFormat(kind)).toBeNull();
76
+ });
77
+
78
+ // ★ MEASURED AGAINST A KIND THAT IS ABSENT, because a before/after comparison of the kind set cannot see this
79
+ // bug. Empty options falling back to the full preset would re-register kinds that are ALREADY installed, which
80
+ // leaves the set identical — the comparison passes while the fallback is live.
81
+ it('installs nothing for empty options, rather than falling back to the built-ins', () => {
82
+ unregisterBitmapFontFormat(BitmapFontFormatKindBmFontXml);
83
+ applyBitmapFontImportOptions({});
84
+ expect(getBitmapFontFormat(BitmapFontFormatKindBmFontXml)).toBeNull();
85
+ applyBitmapFontImportOptions({ formats: [bmFontXmlFormat] });
86
+ expect(getBitmapFontFormat(BitmapFontFormatKindBmFontXml)).toBe(bmFontXmlFormat.entry);
87
+ });
88
+ });
89
+
90
+ describe('bitmapFontAllFormats', () => {
91
+ it('covers exactly the built-in kinds the registry holds', () => {
92
+ expect([...bitmapFontAllFormats.map((format) => format.kind)].sort()).toEqual(
93
+ [...getBitmapFontFormatKinds()].sort(),
94
+ );
95
+ });
96
+
97
+ // A descriptor has to carry the SAME entry the registry resolved, or a catalog row naming a descriptor would
98
+ // name an implementation the importer does not use.
99
+ it('carries the entry identity the registry holds for each kind', () => {
100
+ for (const format of bitmapFontAllFormats) {
101
+ expect(getBitmapFontFormat(format.kind)).toBe(format.entry);
102
+ }
103
+ });
104
+
105
+ // ★ THE PROPERTY THAT MATTERS FOR THIS FAMILY ABOVE ALL OTHERS. BMFont writes binary, text and XML all under
106
+ // `.fnt`, so nothing outside the content separates them. Exactly one detector may accept any file.
107
+ it('accepts each corpus document in exactly one format', () => {
108
+ for (const [expected, bytes] of CORPUS) {
109
+ const accepting = bitmapFontAllFormats.filter((format) => format.entry.detect(bytes)).map((f) => f.kind);
110
+ expect(accepting, expected).toEqual([expected]);
111
+ }
112
+ });
113
+
114
+ // ★ PRECEDENCE IS NOT LOAD-BEARING, PROVEN BY REVERSING IT rather than by reading the detectors. Re-registering
115
+ // a kind keeps its position, so the built-ins have to be REMOVED first or the reversed apply is a no-op and
116
+ // this test passes without reordering anything.
117
+ it('detects every corpus document identically when installed in reverse order', () => {
118
+ const forward = CORPUS.map(([, bytes]) => detectBitmapFontFormat(bytes));
119
+ expect(forward).toEqual(CORPUS.map(([kind]) => kind));
120
+
121
+ for (const format of bitmapFontAllFormats) unregisterBitmapFontFormat(format.kind);
122
+ expect(getBitmapFontFormatKinds()).toEqual([]);
123
+ applyBitmapFontImportOptions({ formats: [...bitmapFontAllFormats].reverse() });
124
+ expect(CORPUS.map(([, bytes]) => detectBitmapFontFormat(bytes))).toEqual(forward);
125
+
126
+ for (const format of bitmapFontAllFormats) unregisterBitmapFontFormat(format.kind);
127
+ applyBitmapFontImportOptions({ formats: bitmapFontAllFormats });
128
+ expect(CORPUS.map(([, bytes]) => detectBitmapFontFormat(bytes))).toEqual(forward);
129
+ });
130
+
131
+ it('names each kind once', () => {
132
+ const kinds = bitmapFontAllFormats.map((format) => format.kind);
133
+ expect(kinds.length).toBe(new Set(kinds).size);
134
+ });
135
+ });
136
+
137
+ describe('bmFontBinaryFormat', () => {
138
+ it('names the binary kind', () => {
139
+ expect(bmFontBinaryFormat.kind).toBe(BitmapFontFormatKindBmFontBinary);
140
+ });
141
+ });
142
+
143
+ describe('bmFontJsonFormat', () => {
144
+ it('names the JSON kind', () => {
145
+ expect(bmFontJsonFormat.kind).toBe(BitmapFontFormatKindBmFontJson);
146
+ });
147
+ });
148
+
149
+ describe('bmFontTextFormat', () => {
150
+ it('names the text kind', () => {
151
+ expect(bmFontTextFormat.kind).toBe(BitmapFontFormatKindBmFontText);
152
+ });
153
+ });
154
+
155
+ describe('bmFontXmlFormat', () => {
156
+ it('names the XML kind', () => {
157
+ expect(bmFontXmlFormat.kind).toBe(BitmapFontFormatKindBmFontXml);
158
+ });
159
+ });
160
+
161
+ describe('detectBitmapFontFormat', () => {
162
+ it('detects each built-in document as its own format', () => {
163
+ for (const [expected, bytes] of CORPUS) {
164
+ expect(detectBitmapFontFormat(bytes), expected).toBe(expected);
165
+ }
166
+ });
167
+
168
+ // The binary front end requires `BMF` plus version 3 exactly, and the detector asks the same question — so a
169
+ // version this build cannot read is reported unrecognised rather than handed to a parser that returns null.
170
+ it('requires the exact binary header the binary front end requires', () => {
171
+ expect(detectBitmapFontFormat(new Uint8Array([66, 77, 70, 3, 2, 0, 0, 0, 0, 0]))).toBe(
172
+ BitmapFontFormatKindBmFontBinary,
173
+ );
174
+ expect(detectBitmapFontFormat(new Uint8Array([66, 77, 70, 2]))).toBeNull();
175
+ expect(detectBitmapFontFormat(new Uint8Array([66, 77, 71, 3]))).toBeNull();
176
+ });
177
+
178
+ it('reads the XML root element, so another XML document is not a font', () => {
179
+ expect(detectBitmapFontFormat(encodeUTF8('<?xml version="1.0"?><TextureAtlas imagePath="a.png"/>'))).toBeNull();
180
+ expect(detectBitmapFontFormat(encodeUTF8('<?xml version="1.0"?><!DOCTYPE font><font/>'))).toBe(
181
+ BitmapFontFormatKindBmFontXml,
182
+ );
183
+ });
184
+
185
+ // ★ PROSE IS NOT THE TEXT GRAMMAR. The text form has no header line to key on the way the others do, so the
186
+ // discriminant is its own block vocabulary — without that, every unrecognised text asset in a build would be
187
+ // claimed as a bitmap font and would link the text front end.
188
+ it('requires a BMFont block name on the first line of a text document', () => {
189
+ expect(detectBitmapFontFormat(encodeUTF8('this is not a font\ncommon lineHeight=1'))).toBeNull();
190
+ expect(detectBitmapFontFormat(encodeUTF8('a.png\nsize: 2,2\nformat: RGBA8888\n'))).toBeNull();
191
+ expect(detectBitmapFontFormat(encodeUTF8('\n\ncommon lineHeight=32 base=26'))).toBe(BitmapFontFormatKindBmFontText);
192
+ });
193
+
194
+ it('returns null for empty and unrecognised bytes rather than throwing', () => {
195
+ expect(detectBitmapFontFormat(new Uint8Array(0))).toBeNull();
196
+ expect(detectBitmapFontFormat(encodeUTF8(' \n '))).toBeNull();
197
+ expect(detectBitmapFontFormat(new Uint8Array([0, 1, 2]))).toBeNull();
198
+ });
199
+
200
+ // ★ THE OPENING BRACE IS NOT ENOUGH, and the cost of pretending otherwise is paid in someone else's bundle.
201
+ // `.json` is shared with seven other format families, so a detector that claimed every JSON object would link
202
+ // this front end into any build carrying an app config or a Tiled map beside its assets. The discriminant is
203
+ // the `common` and `chars` blocks the JSON front end itself requires, so detector and parser still agree.
204
+ it('requires the common and chars blocks the JSON front end requires', () => {
205
+ expect(
206
+ detectBitmapFontFormat(encodeUTF8(JSON.stringify({ frames: {}, meta: { app: 'texturepacker' } }))),
207
+ ).toBeNull();
208
+ expect(detectBitmapFontFormat(encodeUTF8(JSON.stringify({ apiBase: 'https://example.invalid' })))).toBeNull();
209
+ expect(detectBitmapFontFormat(encodeUTF8(JSON.stringify({ layers: [], type: 'map' })))).toBeNull();
210
+ // `common` alone is not a font either: the front end rejects a document with no chars.
211
+ expect(detectBitmapFontFormat(encodeUTF8(JSON.stringify({ common: { base: 26, lineHeight: 32 } })))).toBeNull();
212
+ expect(detectBitmapFontFormat(JSON_TEXT)).toBe(BitmapFontFormatKindBmFontJson);
213
+ });
214
+ });
215
+
216
+ describe('getBitmapFontFormat', () => {
217
+ it('resolves a built-in kind and answers null for an unregistered one', () => {
218
+ expect(getBitmapFontFormat(BitmapFontFormatKindBmFontText)).toBe(bmFontTextFormat.entry);
219
+ expect(getBitmapFontFormat('acme.Absent')).toBeNull();
220
+ });
221
+ });
222
+
223
+ describe('getBitmapFontFormatKinds', () => {
224
+ it('names the built-in kinds and stops naming one after it is unregistered', () => {
225
+ expect([...getBitmapFontFormatKinds()].sort()).toEqual(
226
+ [
227
+ BitmapFontFormatKindBmFontBinary,
228
+ BitmapFontFormatKindBmFontJson,
229
+ BitmapFontFormatKindBmFontText,
230
+ BitmapFontFormatKindBmFontXml,
231
+ ].sort(),
232
+ );
233
+ const kind = 'acme.Enumerated' as BitmapFontFormatKind;
234
+ registerBitmapFontFormat(kind, { detect: () => false, parse: () => null });
235
+ expect(getBitmapFontFormatKinds()).toContain(kind);
236
+ unregisterBitmapFontFormat(kind);
237
+ expect(getBitmapFontFormatKinds()).not.toContain(kind);
238
+ });
239
+ });
240
+
241
+ describe('parseBitmapFont', () => {
242
+ // ★ EVERY FRONT END REACHED THROUGH ONE CALL, which is the claim the descriptors make. The text forms decode
243
+ // the bytes inside the entry, so a caller never has to know that three of the four are text.
244
+ it.each([
245
+ ['binary', 0],
246
+ ['json', 1],
247
+ ['text', 2],
248
+ ['xml', 3],
249
+ ])('auto-detects and reads the %s form', (_label, index) => {
250
+ const font = parseBitmapFont(CORPUS[index][1], undefined, { resolvePage: () => createTextureAtlas() });
251
+ expect(font).not.toBeNull();
252
+ expect(font!.pages.length).toBeGreaterThan(0);
253
+ });
254
+
255
+ it('honours an explicit format kind', () => {
256
+ expect(
257
+ parseBitmapFont(TEXT, BitmapFontFormatKindBmFontText, { resolvePage: () => createTextureAtlas() }),
258
+ ).not.toBeNull();
259
+ });
260
+
261
+ it('returns null for unrecognised bytes and for a kind nothing is registered under', () => {
262
+ expect(parseBitmapFont(new Uint8Array([0, 1, 2]))).toBeNull();
263
+ expect(parseBitmapFont(TEXT, 'acme.Absent')).toBeNull();
264
+ });
265
+ });
266
+
267
+ describe('registerBitmapFontFormat', () => {
268
+ it('makes a custom format detectable and parseable', () => {
269
+ const kind = 'acme.CustomFont' as BitmapFontFormatKind;
270
+ registerBitmapFontFormat(kind, {
271
+ // Forwards the options it was handed, which is what any real custom format does: a bitmap font needs its
272
+ // page atlas resolved and the entry is the only thing holding the caller's resolver.
273
+ detect: (bytes) => bytes[0] === 0x7a,
274
+ parse: (_bytes, options, diagnostics) =>
275
+ parseBitmapFont(TEXT, BitmapFontFormatKindBmFontText, options, diagnostics),
276
+ });
277
+ const custom = new Uint8Array([0x7a, 0x7a]);
278
+ expect(detectBitmapFontFormat(custom)).toBe(kind);
279
+ expect(parseBitmapFont(custom, undefined, { resolvePage: () => createTextureAtlas() })).not.toBeNull();
280
+ unregisterBitmapFontFormat(kind);
281
+ expect(detectBitmapFontFormat(custom)).toBeNull();
282
+ });
283
+
284
+ it('does not disturb the built-in formats', () => {
285
+ const kind = 'acme.Quiet' as BitmapFontFormatKind;
286
+ registerBitmapFontFormat(kind, { detect: () => false, parse: () => null });
287
+ expect(detectBitmapFontFormat(XML)).toBe(BitmapFontFormatKindBmFontXml);
288
+ unregisterBitmapFontFormat(kind);
289
+ });
290
+ });
291
+
292
+ describe('unregisterBitmapFontFormat', () => {
293
+ it('removes a format from detection and resolution', () => {
294
+ unregisterBitmapFontFormat(BitmapFontFormatKindBmFontBinary);
295
+ expect(detectBitmapFontFormat(CORPUS[0][1])).toBeNull();
296
+ applyBitmapFontImportOptions({ formats: [bmFontBinaryFormat] });
297
+ expect(detectBitmapFontFormat(CORPUS[0][1])).toBe(BitmapFontFormatKindBmFontBinary);
298
+ });
299
+ });
300
+
301
+ // A minimal well-formed BMFont binary: the `BMF` magic plus version 3, a common block carrying lineHeight and
302
+ // base, a page block naming one image, and one char block. Built here rather than imported so this file states
303
+ // the header the detector keys on.
304
+ function buildBinaryFont(): Uint8Array {
305
+ const common = new Uint8Array(15);
306
+ const commonView = new DataView(common.buffer);
307
+ commonView.setUint16(0, 32, true);
308
+ commonView.setUint16(2, 26, true);
309
+ const page = encodeUTF8('test_0.png\0');
310
+ const chars = new Uint8Array(20);
311
+ const charView = new DataView(chars.buffer);
312
+ charView.setUint32(0, 65, true);
313
+ charView.setUint16(8, 7, true);
314
+ charView.setUint16(10, 8, true);
315
+ charView.setInt16(16, 9, true);
316
+ const blocks = [block(2, common), block(3, page), block(4, chars)];
317
+ let total = 4;
318
+ for (const part of blocks) total += part.length;
319
+ const bytes = new Uint8Array(total);
320
+ bytes.set([66, 77, 70, 3], 0);
321
+ let offset = 4;
322
+ for (const part of blocks) {
323
+ bytes.set(part, offset);
324
+ offset += part.length;
325
+ }
326
+ return bytes;
327
+ }
328
+
329
+ function block(type: number, data: Readonly<Uint8Array>): Uint8Array {
330
+ const out = new Uint8Array(5 + data.length);
331
+ out[0] = type;
332
+ new DataView(out.buffer).setUint32(1, data.length, true);
333
+ out.set(data, 5);
334
+ return out;
335
+ }
@@ -5,11 +5,14 @@ import {
5
5
  getBitmapFontMetrics,
6
6
  getBitmapFontPage,
7
7
  } from '@flighthq/bitmapfont/contract';
8
+ import type { BitmapFontParseOptions } from '@flighthq/bitmapfont/contract';
9
+ import type { ImageResource } from '@flighthq/image/contract';
10
+ import type { ImportDiagnostic } from '@flighthq/importdiagnostics/contract';
8
11
  import { createTextureAtlas, createTextureAtlasFromImageResource } from '@flighthq/textureatlas/contract';
9
- import type { BitmapFontParseOptions, ImageResource, ImportDiagnostic, TextureAtlas } from '@flighthq/types/contract';
12
+ import type { TextureAtlas } from '@flighthq/textureatlas/contract';
10
13
  import { describe, expect, it } from 'vitest';
11
14
 
12
- import { formatBitmapFontFnt, parseBitmapFontFnt } from './bitmapFontFnt';
15
+ import { formatBitmapFontFnt, parseBitmapFontFnt } from './bitmapFontFnt.ts';
13
16
 
14
17
  const FNT_MULTIPAGE = [
15
18
  'info face="Test" size=32 unicode=1',
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { readBitmapFontFormatKind } from './bitmapFontFormatKind.ts';
4
+ import {
5
+ BitmapFontFormatKindBmFontBinary,
6
+ BitmapFontFormatKindBmFontJson,
7
+ BitmapFontFormatKindBmFontText,
8
+ BitmapFontFormatKindBmFontXml,
9
+ } from './types.ts';
10
+
11
+ // ★ THE EXTENSION CANNOT NAME THE FORMAT, WHICH IS THE WHOLE REASON THIS IS SEPARATELY IMPORTABLE. BMFont writes
12
+ // binary, text and XML all under `.fnt`, so a build-time analyzer that keyed on the extension would name the text
13
+ // parser for a file that is XML and get a null font with no error. These are the same four discriminants the
14
+ // detectors in `bitmapFontDetect.ts` ask, asserted here against the module that now owns them.
15
+ describe('readBitmapFontFormatKind', () => {
16
+ it('reads the binary form from its BMF version-3 header', () => {
17
+ expect(readBitmapFontFormatKind(new Uint8Array([66, 77, 70, 3, 1, 2, 3]))).toBe(BitmapFontFormatKindBmFontBinary);
18
+ });
19
+
20
+ // The version byte is part of the discriminant: the binary front end requires version 3, so a detector that
21
+ // accepted any BMF header would hand it a file it cannot read.
22
+ it('rejects a BMF header at another version rather than claiming it', () => {
23
+ expect(readBitmapFontFormatKind(new Uint8Array([66, 77, 70, 2, 1]))).toBeNull();
24
+ });
25
+
26
+ it('reads the XML form only when the root element is font', () => {
27
+ expect(readBitmapFontFormatKind(utf8('<?xml version="1.0"?><font><common lineHeight="10"/></font>'))).toBe(
28
+ BitmapFontFormatKindBmFontXml,
29
+ );
30
+ expect(readBitmapFontFormatKind(utf8('<TextureAtlas imagePath="a.png"/>'))).toBeNull();
31
+ });
32
+
33
+ // ★ THE OPENING BRACE IS NOT ENOUGH. `.json` is shared with several families, so a discriminator that claimed
34
+ // every JSON object would put this front end into any build carrying a config file.
35
+ it('reads the JSON form only when the object carries common and chars', () => {
36
+ expect(readBitmapFontFormatKind(utf8('{"common":{"lineHeight":10},"chars":[]}'))).toBe(
37
+ BitmapFontFormatKindBmFontJson,
38
+ );
39
+ expect(readBitmapFontFormatKind(utf8('{"name":"an app config"}'))).toBeNull();
40
+ });
41
+
42
+ it('reads the text form from a leading grammar block, and nothing else', () => {
43
+ expect(readBitmapFontFormatKind(utf8('info face="A" size=32\ncommon lineHeight=10\n'))).toBe(
44
+ BitmapFontFormatKindBmFontText,
45
+ );
46
+ expect(readBitmapFontFormatKind(utf8('this is prose, not a font descriptor'))).toBeNull();
47
+ });
48
+
49
+ it('returns the null sentinel for empty input rather than throwing', () => {
50
+ expect(readBitmapFontFormatKind(new Uint8Array())).toBeNull();
51
+ expect(readBitmapFontFormatKind(utf8(' \n '))).toBeNull();
52
+ });
53
+
54
+ // Exactly one kind per file is the property the four detectors rest on: each compares this single answer
55
+ // against its own kind, so two of them accepting one file is impossible by construction rather than by policy.
56
+ it('answers at most one kind for any given input', () => {
57
+ const inputs = [
58
+ new Uint8Array([66, 77, 70, 3]),
59
+ utf8('<font><common/></font>'),
60
+ utf8('{"common":{},"chars":[]}'),
61
+ utf8('info face="A"\n'),
62
+ ];
63
+ const kinds = inputs.map((bytes) => readBitmapFontFormatKind(bytes));
64
+ expect(new Set(kinds).size).toBe(kinds.length);
65
+ expect(kinds.every((kind) => kind !== null)).toBe(true);
66
+ });
67
+ });
68
+
69
+ function utf8(text: string): Uint8Array {
70
+ return new TextEncoder().encode(text);
71
+ }
@@ -4,14 +4,14 @@ import {
4
4
  getBitmapFontMetrics,
5
5
  getBitmapFontPage,
6
6
  } from '@flighthq/bitmapfont/contract';
7
+ import type { BitmapFont } from '@flighthq/bitmapfont/contract';
8
+ import type { ImportDiagnostic } from '@flighthq/importdiagnostics/contract';
7
9
  import { createTextureAtlas } from '@flighthq/textureatlas/contract';
8
- import type { ImportDiagnostic } from '@flighthq/types/contract';
9
- import type { BitmapFont } from '@flighthq/types/contract';
10
10
  import { describe, expect, it } from 'vitest';
11
11
 
12
- import { parseBitmapFontFnt } from './bitmapFontFnt';
13
- import { parseBitmapFontJson } from './bitmapFontJson';
14
- import { parseBitmapFontXml } from './bitmapFontXml';
12
+ import { parseBitmapFontFnt } from './bitmapFontFnt.ts';
13
+ import { parseBitmapFontJson } from './bitmapFontJson.ts';
14
+ import { parseBitmapFontXml } from './bitmapFontXml.ts';
15
15
 
16
16
  const FNT_JSON = JSON.stringify({
17
17
  chars: [
@@ -4,11 +4,12 @@ import {
4
4
  getBitmapFontMetrics,
5
5
  getBitmapFontPage,
6
6
  } from '@flighthq/bitmapfont/contract';
7
+ import type { ImportDiagnostic } from '@flighthq/importdiagnostics/contract';
7
8
  import { createTextureAtlas } from '@flighthq/textureatlas/contract';
8
- import type { BitmapFontRecord, ImportDiagnostic } from '@flighthq/types/contract';
9
9
  import { describe, expect, it } from 'vitest';
10
10
 
11
- import { buildBitmapFontFromRecord, reportDroppedBitmapFontRecords } from './bitmapFontRecord';
11
+ import { buildBitmapFontFromRecord, reportDroppedBitmapFontRecords } from './bitmapFontRecord.ts';
12
+ import type { BitmapFontRecord } from './types.ts';
12
13
 
13
14
  describe('buildBitmapFontFromRecord', () => {
14
15
  it('maps chars, kernings, and common metrics onto a BitmapFont', () => {
@@ -4,11 +4,11 @@ import {
4
4
  getBitmapFontMetrics,
5
5
  getBitmapFontPage,
6
6
  } from '@flighthq/bitmapfont/contract';
7
+ import type { ImportDiagnostic } from '@flighthq/importdiagnostics/contract';
7
8
  import { createTextureAtlas } from '@flighthq/textureatlas/contract';
8
- import type { ImportDiagnostic } from '@flighthq/types/contract';
9
9
  import { describe, expect, it } from 'vitest';
10
10
 
11
- import { parseBitmapFontXml } from './bitmapFontXml';
11
+ import { parseBitmapFontXml } from './bitmapFontXml.ts';
12
12
 
13
13
  const FNT_XML = [
14
14
  '<?xml version="1.0"?>',
@@ -0,0 +1,161 @@
1
+ import type { TextureAtlas } from '@flighthq/textureatlas/contract';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { explainBitmapFontParse } from './explainBitmapFontParse.ts';
5
+
6
+ describe('explainBitmapFontParse', () => {
7
+ it('returns invalid-data for empty string', () => {
8
+ const result = explainBitmapFontParse('');
9
+ expect(result.success).toBe(false);
10
+ expect(result.reason).toBe('invalid-data');
11
+ expect(result.detectedFormat).toBe('unknown');
12
+ });
13
+
14
+ it('returns invalid-data for arbitrary text', () => {
15
+ const result = explainBitmapFontParse('hello world');
16
+ expect(result.success).toBe(false);
17
+ expect(result.reason).toBe('invalid-data');
18
+ });
19
+
20
+ it('detects JSON format and reports missing-common', () => {
21
+ const result = explainBitmapFontParse(JSON.stringify({ chars: [{ id: 65, x: 0, y: 0 }] }));
22
+ expect(result.detectedFormat).toBe('json');
23
+ expect(result.reason).toBe('missing-common');
24
+ expect(result.success).toBe(false);
25
+ });
26
+
27
+ it('detects JSON format and reports missing-chars', () => {
28
+ const result = explainBitmapFontParse(JSON.stringify({ common: { lineHeight: 32, base: 24 } }));
29
+ expect(result.detectedFormat).toBe('json');
30
+ expect(result.reason).toBe('missing-chars');
31
+ expect(result.charCount).toBe(0);
32
+ });
33
+
34
+ it('detects JSON format and reports unresolved-page without resolvePage', () => {
35
+ const json = JSON.stringify({
36
+ chars: [{ id: 65, x: 0, y: 0, width: 10, height: 10, xoffset: 0, yoffset: 0, xadvance: 10, page: 0 }],
37
+ common: { lineHeight: 32, base: 24 },
38
+ pages: ['font.png'],
39
+ });
40
+ const result = explainBitmapFontParse(json);
41
+ expect(result.detectedFormat).toBe('json');
42
+ expect(result.reason).toBe('unresolved-page');
43
+ expect(result.unresolvedPages).toEqual([0]);
44
+ expect(result.charCount).toBe(1);
45
+ expect(result.pageCount).toBe(1);
46
+ });
47
+
48
+ it('detects JSON format and reports ok when pages resolve', () => {
49
+ const json = JSON.stringify({
50
+ chars: [{ id: 65, x: 0, y: 0, width: 10, height: 10, xoffset: 0, yoffset: 0, xadvance: 10, page: 0 }],
51
+ common: { lineHeight: 32, base: 24 },
52
+ pages: ['font.png'],
53
+ });
54
+ const result = explainBitmapFontParse(json, {
55
+ resolvePage: () => ({}) as TextureAtlas,
56
+ });
57
+ expect(result.success).toBe(true);
58
+ expect(result.reason).toBe('ok');
59
+ expect(result.charCount).toBe(1);
60
+ expect(result.kerningCount).toBe(0);
61
+ });
62
+
63
+ it('detects JSON with chars-as-object', () => {
64
+ const json = JSON.stringify({
65
+ chars: { '65': { id: 65, x: 0, y: 0, width: 10, height: 10, xoffset: 0, yoffset: 0, xadvance: 10, page: 0 } },
66
+ common: { lineHeight: 32, base: 24 },
67
+ pages: ['font.png'],
68
+ });
69
+ const result = explainBitmapFontParse(json, {
70
+ resolvePage: () => ({}) as TextureAtlas,
71
+ });
72
+ expect(result.detectedFormat).toBe('json');
73
+ expect(result.charCount).toBe(1);
74
+ expect(result.success).toBe(true);
75
+ });
76
+
77
+ it('counts kernings in JSON', () => {
78
+ const json = JSON.stringify({
79
+ chars: [{ id: 65, x: 0, y: 0, width: 10, height: 10, xoffset: 0, yoffset: 0, xadvance: 10 }],
80
+ common: { lineHeight: 32, base: 24 },
81
+ kernings: [
82
+ { first: 65, second: 66, amount: -2 },
83
+ { first: 66, second: 65, amount: -1 },
84
+ ],
85
+ });
86
+ const result = explainBitmapFontParse(json, {
87
+ resolvePage: () => ({}) as TextureAtlas,
88
+ });
89
+ expect(result.kerningCount).toBe(2);
90
+ });
91
+
92
+ it('detects FNT text format and reports missing-common', () => {
93
+ const fnt = 'char id=65 x=0 y=0 width=10 height=10 xoffset=0 yoffset=0 xadvance=10 page=0\n';
94
+ const result = explainBitmapFontParse(fnt);
95
+ expect(result.detectedFormat).toBe('fnt');
96
+ expect(result.reason).toBe('missing-common');
97
+ });
98
+
99
+ it('detects FNT text format and reports missing-chars', () => {
100
+ const fnt = 'common lineHeight=32 base=24 scaleW=256 scaleH=256 pages=1\n';
101
+ const result = explainBitmapFontParse(fnt);
102
+ expect(result.detectedFormat).toBe('fnt');
103
+ expect(result.reason).toBe('missing-chars');
104
+ });
105
+
106
+ it('detects FNT text format and reports ok', () => {
107
+ const fnt = [
108
+ 'common lineHeight=32 base=24 scaleW=256 scaleH=256 pages=1',
109
+ 'page id=0 file="font.png"',
110
+ 'char id=65 x=0 y=0 width=10 height=10 xoffset=0 yoffset=0 xadvance=10 page=0',
111
+ ].join('\n');
112
+ const result = explainBitmapFontParse(fnt, {
113
+ resolvePage: () => ({}) as TextureAtlas,
114
+ });
115
+ expect(result.detectedFormat).toBe('fnt');
116
+ expect(result.success).toBe(true);
117
+ expect(result.charCount).toBe(1);
118
+ expect(result.pageCount).toBe(1);
119
+ });
120
+
121
+ it('detects XML format and reports ok', () => {
122
+ const xml = [
123
+ '<font>',
124
+ ' <common lineHeight="32" base="24" scaleW="256" scaleH="256"/>',
125
+ ' <pages><page id="0" file="font.png"/></pages>',
126
+ ' <chars><char id="65" x="0" y="0" width="10" height="10" xoffset="0" yoffset="0" xadvance="10" page="0"/></chars>',
127
+ '</font>',
128
+ ].join('\n');
129
+ const result = explainBitmapFontParse(xml, {
130
+ resolvePage: () => ({}) as TextureAtlas,
131
+ });
132
+ expect(result.detectedFormat).toBe('xml');
133
+ expect(result.success).toBe(true);
134
+ expect(result.charCount).toBe(1);
135
+ });
136
+
137
+ it('detects XML format and reports missing-common', () => {
138
+ const xml = [
139
+ '<font>',
140
+ ' <chars><char id="65" x="0" y="0" width="10" height="10" xoffset="0" yoffset="0" xadvance="10"/></chars>',
141
+ '</font>',
142
+ ].join('\n');
143
+ const result = explainBitmapFontParse(xml);
144
+ expect(result.detectedFormat).toBe('xml');
145
+ expect(result.reason).toBe('missing-common');
146
+ });
147
+
148
+ it('reports multiple unresolved pages', () => {
149
+ const json = JSON.stringify({
150
+ chars: [
151
+ { id: 65, x: 0, y: 0, width: 10, height: 10, xoffset: 0, yoffset: 0, xadvance: 10, page: 0 },
152
+ { id: 66, x: 10, y: 0, width: 10, height: 10, xoffset: 0, yoffset: 0, xadvance: 10, page: 1 },
153
+ ],
154
+ common: { lineHeight: 32, base: 24 },
155
+ pages: ['font0.png', 'font1.png'],
156
+ });
157
+ const result = explainBitmapFontParse(json);
158
+ expect(result.unresolvedPages).toEqual(expect.arrayContaining([0, 1]));
159
+ expect(result.unresolvedPages).toHaveLength(2);
160
+ });
161
+ });