@flighthq/font-formats 0.3.1-next.1041.35b950c

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 (84) hide show
  1. package/dist/cffCharstring.d.ts +13 -0
  2. package/dist/cffCharstring.d.ts.map +1 -0
  3. package/dist/cffCharstring.js +336 -0
  4. package/dist/cffCharstring.js.map +1 -0
  5. package/dist/cffDict.d.ts +8 -0
  6. package/dist/cffDict.d.ts.map +1 -0
  7. package/dist/cffDict.js +118 -0
  8. package/dist/cffDict.js.map +1 -0
  9. package/dist/cffFdSelect.d.ts +2 -0
  10. package/dist/cffFdSelect.d.ts.map +1 -0
  11. package/dist/cffFdSelect.js +60 -0
  12. package/dist/cffFdSelect.js.map +1 -0
  13. package/dist/cffIndex.d.ts +3 -0
  14. package/dist/cffIndex.d.ts.map +1 -0
  15. package/dist/cffIndex.js +52 -0
  16. package/dist/cffIndex.js.map +1 -0
  17. package/dist/cffTable.d.ts +3 -0
  18. package/dist/cffTable.d.ts.map +1 -0
  19. package/dist/cffTable.js +134 -0
  20. package/dist/cffTable.js.map +1 -0
  21. package/dist/contract.d.ts +15 -0
  22. package/dist/contract.d.ts.map +1 -0
  23. package/dist/contract.js +15 -0
  24. package/dist/contract.js.map +1 -0
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +2 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/openTypeCmap.d.ts +5 -0
  30. package/dist/openTypeCmap.d.ts.map +1 -0
  31. package/dist/openTypeCmap.js +161 -0
  32. package/dist/openTypeCmap.js.map +1 -0
  33. package/dist/openTypeGlyf.d.ts +4 -0
  34. package/dist/openTypeGlyf.d.ts.map +1 -0
  35. package/dist/openTypeGlyf.js +229 -0
  36. package/dist/openTypeGlyf.js.map +1 -0
  37. package/dist/openTypeGlyphOutlineSource.d.ts +4 -0
  38. package/dist/openTypeGlyphOutlineSource.d.ts.map +1 -0
  39. package/dist/openTypeGlyphOutlineSource.js +168 -0
  40. package/dist/openTypeGlyphOutlineSource.js.map +1 -0
  41. package/dist/openTypeMetrics.d.ts +6 -0
  42. package/dist/openTypeMetrics.d.ts.map +1 -0
  43. package/dist/openTypeMetrics.js +74 -0
  44. package/dist/openTypeMetrics.js.map +1 -0
  45. package/dist/openTypeTestHelper.d.ts +27 -0
  46. package/dist/openTypeTestHelper.d.ts.map +1 -0
  47. package/dist/openTypeTestHelper.js +520 -0
  48. package/dist/openTypeTestHelper.js.map +1 -0
  49. package/dist/sfntAssembly.d.ts +20 -0
  50. package/dist/sfntAssembly.d.ts.map +1 -0
  51. package/dist/sfntAssembly.js +237 -0
  52. package/dist/sfntAssembly.js.map +1 -0
  53. package/dist/sfntTableDirectory.d.ts +4 -0
  54. package/dist/sfntTableDirectory.d.ts.map +1 -0
  55. package/dist/sfntTableDirectory.js +39 -0
  56. package/dist/sfntTableDirectory.js.map +1 -0
  57. package/dist/woff2Font.d.ts +6 -0
  58. package/dist/woff2Font.d.ts.map +1 -0
  59. package/dist/woff2Font.js +210 -0
  60. package/dist/woff2Font.js.map +1 -0
  61. package/dist/woff2GlyfTransform.d.ts +23 -0
  62. package/dist/woff2GlyfTransform.d.ts.map +1 -0
  63. package/dist/woff2GlyfTransform.js +437 -0
  64. package/dist/woff2GlyfTransform.js.map +1 -0
  65. package/dist/woffFont.d.ts +6 -0
  66. package/dist/woffFont.d.ts.map +1 -0
  67. package/dist/woffFont.js +122 -0
  68. package/dist/woffFont.js.map +1 -0
  69. package/package.json +51 -0
  70. package/src/cffCharstring.test.ts +177 -0
  71. package/src/cffDict.test.ts +67 -0
  72. package/src/cffFdSelect.test.ts +84 -0
  73. package/src/cffIndex.test.ts +97 -0
  74. package/src/cffTable.test.ts +84 -0
  75. package/src/openTypeCmap.test.ts +124 -0
  76. package/src/openTypeGlyf.test.ts +168 -0
  77. package/src/openTypeGlyfWinding.test.ts +80 -0
  78. package/src/openTypeGlyphOutlineSource.test.ts +298 -0
  79. package/src/openTypeMetrics.test.ts +103 -0
  80. package/src/sfntAssembly.test.ts +228 -0
  81. package/src/sfntTableDirectory.test.ts +64 -0
  82. package/src/woff2Font.test.ts +129 -0
  83. package/src/woff2GlyfTransform.test.ts +315 -0
  84. package/src/woffFont.test.ts +207 -0
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ readOpenTypeAdvances,
5
+ readOpenTypeGlyphCount,
6
+ readOpenTypeLocaFormat,
7
+ readOpenTypeMetrics,
8
+ } from './openTypeMetrics';
9
+ import { createSyntheticFont, emptySyntheticGlyph, squareSyntheticGlyph } from './openTypeTestHelper';
10
+ import { readSfntTableDirectory } from './sfntTableDirectory';
11
+
12
+ describe('readOpenTypeAdvances', () => {
13
+ it('reads one advance per glyph', () => {
14
+ const font = createSyntheticFont({
15
+ advances: [300, 750],
16
+ glyphs: [emptySyntheticGlyph(), squareSyntheticGlyph(10)],
17
+ });
18
+ expect([...readOpenTypeAdvances(font, readSfntTableDirectory(font)!, 2)!]).toEqual([300, 750]);
19
+ });
20
+
21
+ // `hmtx` is run-length shaped: glyphs past `numberOfHMetrics` repeat the last stated advance. Reading
22
+ // it flat gives every one of them a wrong width, which is exactly how monospaced and CJK fonts stay
23
+ // small — so the bug would be invisible on a Latin test font and wrong on the fonts that use it.
24
+ it('repeats the last stated advance past the long-metric count rather than reading zero', () => {
25
+ const font = createSyntheticFont({
26
+ advances: [600],
27
+ glyphs: [emptySyntheticGlyph(), squareSyntheticGlyph(10), squareSyntheticGlyph(20)],
28
+ });
29
+ expect([...readOpenTypeAdvances(font, readSfntTableDirectory(font)!, 3)!]).toEqual([600, 600, 600]);
30
+ });
31
+
32
+ it('returns the sentinel when hmtx or hhea is absent', () => {
33
+ for (const table of ['hmtx', 'hhea']) {
34
+ const font = createSyntheticFont({ omitTable: table });
35
+ expect(readOpenTypeAdvances(font, readSfntTableDirectory(font)!, 1)).toBeNull();
36
+ }
37
+ });
38
+ });
39
+
40
+ describe('readOpenTypeGlyphCount', () => {
41
+ it('reads the count maxp declares', () => {
42
+ const font = createSyntheticFont({ glyphs: [emptySyntheticGlyph(), squareSyntheticGlyph(10)] });
43
+ expect(readOpenTypeGlyphCount(font, readSfntTableDirectory(font)!)).toBe(2);
44
+ });
45
+
46
+ it('returns -1 rather than a count when maxp is absent, so every index is out of range', () => {
47
+ const font = createSyntheticFont({ omitTable: 'maxp' });
48
+ expect(readOpenTypeGlyphCount(font, readSfntTableDirectory(font)!)).toBe(-1);
49
+ });
50
+ });
51
+
52
+ describe('readOpenTypeLocaFormat', () => {
53
+ it('reads the index-to-loc format head declares', () => {
54
+ const font = createSyntheticFont();
55
+ expect(readOpenTypeLocaFormat(font, readSfntTableDirectory(font)!)).toBe(1);
56
+ });
57
+
58
+ it('returns -1 for a value that is neither of the two the format defines', () => {
59
+ const font = createSyntheticFont();
60
+ const directory = readSfntTableDirectory(font)!;
61
+ // Guessing here would produce plausible garbage outlines instead of a clean rejection.
62
+ new DataView(font.buffer).setInt16(directory.tables.get('head')!.offset + 50, 7);
63
+ expect(readOpenTypeLocaFormat(font, directory)).toBe(-1);
64
+ });
65
+
66
+ it('returns -1 when head is absent', () => {
67
+ const font = createSyntheticFont({ omitTable: 'head' });
68
+ expect(readOpenTypeLocaFormat(font, readSfntTableDirectory(font)!)).toBe(-1);
69
+ });
70
+ });
71
+
72
+ describe('readOpenTypeMetrics', () => {
73
+ it('reads the design-unit scale from head', () => {
74
+ const font = createSyntheticFont({ unitsPerEm: 2048 });
75
+ expect(readOpenTypeMetrics(font, readSfntTableDirectory(font)!)?.unitsPerEm).toBe(2048);
76
+ });
77
+
78
+ // `hhea` states descender as a negative distance; `GlyphOutlineMetrics` documents both ascent and
79
+ // descent as POSITIVE distances from the baseline. The flip happens once, here, at the seam.
80
+ it('reports descent as a positive distance although the table stores it negative', () => {
81
+ const font = createSyntheticFont();
82
+ expect(readOpenTypeMetrics(font, readSfntTableDirectory(font)!)).toEqual({
83
+ ascent: 800,
84
+ descent: 200,
85
+ lineGap: 100,
86
+ unitsPerEm: 1000,
87
+ });
88
+ });
89
+
90
+ it('returns the sentinel for a zero unitsPerEm rather than a scale that divides by zero', () => {
91
+ const font = createSyntheticFont();
92
+ const directory = readSfntTableDirectory(font)!;
93
+ new DataView(font.buffer).setUint16(directory.tables.get('head')!.offset + 18, 0);
94
+ expect(readOpenTypeMetrics(font, directory)).toBeNull();
95
+ });
96
+
97
+ it('returns the sentinel when head or hhea is absent', () => {
98
+ for (const table of ['head', 'hhea']) {
99
+ const font = createSyntheticFont({ omitTable: table });
100
+ expect(readOpenTypeMetrics(font, readSfntTableDirectory(font)!)).toBeNull();
101
+ }
102
+ });
103
+ });
@@ -0,0 +1,228 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ assembleSfntFont,
5
+ computeSfntTableChecksum,
6
+ encodeSfntCompositeGlyph,
7
+ encodeSfntLoca,
8
+ encodeSfntSimpleGlyph,
9
+ packSfntTag,
10
+ } from './sfntAssembly';
11
+
12
+ describe('assembleSfntFont', () => {
13
+ it('writes the directory in tag order whatever order it was given', () => {
14
+ // Reversed on purpose. A pre-sorted input would pass whether or not anything sorted, which is how
15
+ // the WOFF tag-order test was once vacuous.
16
+ const font = assembleSfntFont(0x00010000, [
17
+ { data: Uint8Array.from([9]), tag: packSfntTag('name') },
18
+ { data: Uint8Array.from([7]), tag: packSfntTag('head') },
19
+ { data: Uint8Array.from([8]), tag: packSfntTag('cmap') },
20
+ ]);
21
+ const view = new DataView(font.buffer);
22
+ const tags = Array.from({ length: view.getUint16(4) }, (_, index) => view.getUint32(12 + index * 16));
23
+ expect(tags).toEqual([packSfntTag('cmap'), packSfntTag('head'), packSfntTag('name')]);
24
+ });
25
+
26
+ it('starts every table on a four-byte boundary', () => {
27
+ // Lengths chosen so that unpadded placement would land tables at odd offsets — without this the
28
+ // assertion would hold whether or not any padding ran.
29
+ const font = assembleSfntFont(0x00010000, [
30
+ { data: Uint8Array.from([1]), tag: packSfntTag('aaaa') },
31
+ { data: Uint8Array.from([2, 3, 4]), tag: packSfntTag('bbbb') },
32
+ { data: Uint8Array.from([5, 6]), tag: packSfntTag('cccc') },
33
+ ]);
34
+ const view = new DataView(font.buffer);
35
+ const count = view.getUint16(4);
36
+ const offsets = Array.from({ length: count }, (_, index) => view.getUint32(12 + index * 16 + 8));
37
+ expect(offsets.filter((offset) => offset % 4 !== 0)).toEqual([]);
38
+ });
39
+
40
+ it('preserves each table byte for byte at its declared offset and length', () => {
41
+ const payload = Uint8Array.from([11, 22, 33, 44, 55]);
42
+ const font = assembleSfntFont(0x4f54544f, [{ data: payload, tag: packSfntTag('CFF ') }]);
43
+ const view = new DataView(font.buffer);
44
+ const offset = view.getUint32(12 + 8);
45
+ const length = view.getUint32(12 + 12);
46
+ expect(length).toBe(payload.byteLength);
47
+ expect([...font.subarray(offset, offset + length)]).toEqual([...payload]);
48
+ });
49
+
50
+ it('writes each table a real checksum rather than a zero', () => {
51
+ const payload = Uint8Array.from([0, 0, 0, 7]);
52
+ const font = assembleSfntFont(0x00010000, [{ data: payload, tag: packSfntTag('cmap') }]);
53
+ const view = new DataView(font.buffer);
54
+ expect(view.getUint32(12 + 4)).toBe(7);
55
+ expect(view.getUint32(12 + 4)).toBe(computeSfntTableChecksum(payload));
56
+ });
57
+
58
+ it('carries the flavor it was given rather than inferring one', () => {
59
+ const font = assembleSfntFont(0x4f54544f, [{ data: Uint8Array.from([0]), tag: packSfntTag('CFF ') }]);
60
+ expect(new DataView(font.buffer).getUint32(0)).toBe(0x4f54544f);
61
+ });
62
+ });
63
+
64
+ describe('computeSfntTableChecksum', () => {
65
+ it('sums big-endian words', () => {
66
+ expect(computeSfntTableChecksum(Uint8Array.from([0, 0, 0, 1, 0, 0, 0, 2]))).toBe(3);
67
+ });
68
+
69
+ it('pads a partial final word with zeros rather than dropping it', () => {
70
+ // [0,0,1] is one short of a word. Dropping the tail would give 0; padding gives 0x100.
71
+ expect(computeSfntTableChecksum(Uint8Array.from([0, 0, 1]))).toBe(0x100);
72
+ });
73
+
74
+ it('treats head checkSumAdjustment as zero, which is what stops every real font mismatching', () => {
75
+ // Byte 8 begins checkSumAdjustment. The same bytes must sum differently for head than for any
76
+ // other table, so the two calls below are what separates the exception from a no-op.
77
+ const head = new Uint8Array(16);
78
+ head[8] = 0xff;
79
+ head[9] = 0xff;
80
+ expect(computeSfntTableChecksum(head, false)).toBe(0xffff0000);
81
+ expect(computeSfntTableChecksum(head, true)).toBe(0);
82
+ });
83
+
84
+ it('wraps modulo 2^32 rather than growing past a uint32', () => {
85
+ const big = Uint8Array.from([0xff, 0xff, 0xff, 0xff, 0, 0, 0, 2]);
86
+ expect(computeSfntTableChecksum(big)).toBe(1);
87
+ });
88
+ });
89
+
90
+ describe('encodeSfntCompositeGlyph', () => {
91
+ const box = { xMax: 9, xMin: 1, yMax: 8, yMin: 2 };
92
+ const comps = Uint8Array.from([0x00, 0x20, 0x00, 0x03, 0x01, 0x02]);
93
+ const none = new Uint8Array(0);
94
+
95
+ it('marks the glyph composite with a negative contour count', () => {
96
+ // Readers switch on the sign alone. A positive count here makes every reader parse the component
97
+ // records as an endPtsOfContours array.
98
+ const out = encodeSfntCompositeGlyph(comps, none, box, false);
99
+ expect(new DataView(out.buffer, out.byteOffset, out.byteLength).getInt16(0)).toBeLessThan(0);
100
+ });
101
+
102
+ it('copies the component records unchanged', () => {
103
+ const out = encodeSfntCompositeGlyph(comps, none, box, false);
104
+ expect([...out.subarray(10, 16)]).toEqual([...comps]);
105
+ });
106
+
107
+ it('omits the instruction length entirely when no component asked for instructions', () => {
108
+ // A zero length written unconditionally adds two bytes no reader expects, shifting every glyph
109
+ // that follows.
110
+ expect(encodeSfntCompositeGlyph(comps, none, box, false).byteLength).toBe(16);
111
+ });
112
+
113
+ it('writes the instruction length and bytes when a component did ask', () => {
114
+ const out = encodeSfntCompositeGlyph(comps, Uint8Array.from([0xb0]), box, true);
115
+ const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
116
+ expect(out.byteLength).toBe(19);
117
+ expect(view.getUint16(16)).toBe(1);
118
+ expect(out[18]).toBe(0xb0);
119
+ });
120
+
121
+ it('carries the bounds it was given, since a composite cannot compute its own', () => {
122
+ const out = encodeSfntCompositeGlyph(comps, none, box, false);
123
+ const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
124
+ expect([view.getInt16(2), view.getInt16(4), view.getInt16(6), view.getInt16(8)]).toEqual([1, 2, 9, 8]);
125
+ });
126
+ });
127
+
128
+ describe('encodeSfntLoca', () => {
129
+ it('writes one more offset than there are glyphs', () => {
130
+ // The last entry is the end of the final glyph, which is how its length is expressed at all.
131
+ expect(encodeSfntLoca([4, 6], 1)!.byteLength).toBe(12);
132
+ expect(encodeSfntLoca([4, 6], 0)!.byteLength).toBe(6);
133
+ });
134
+
135
+ it('accumulates offsets in glyph order', () => {
136
+ const out = encodeSfntLoca([4, 6, 2], 1)!;
137
+ const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
138
+ expect([0, 1, 2, 3].map((i) => view.getUint32(i * 4))).toEqual([0, 4, 10, 12]);
139
+ });
140
+
141
+ it('halves the offset in the short form', () => {
142
+ const out = encodeSfntLoca([4, 6], 0)!;
143
+ const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
144
+ expect([0, 1, 2].map((i) => view.getUint16(i * 2))).toEqual([0, 2, 5]);
145
+ });
146
+
147
+ it('gives equal consecutive offsets for a blank glyph', () => {
148
+ const out = encodeSfntLoca([4, 0, 4], 1)!;
149
+ const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
150
+ expect(view.getUint32(4)).toBe(view.getUint32(8));
151
+ });
152
+
153
+ it('refuses the short form for an odd offset rather than truncating it', () => {
154
+ // Truncation here would point the reader one byte before the glyph, inside its predecessor.
155
+ expect(encodeSfntLoca([5, 4], 0)).toBeNull();
156
+ expect(encodeSfntLoca([5, 4], 1)).not.toBeNull();
157
+ });
158
+
159
+ it('refuses the short form when the font outgrows its reach', () => {
160
+ expect(encodeSfntLoca([0x20000, 2], 0)).toBeNull();
161
+ });
162
+ });
163
+
164
+ describe('encodeSfntSimpleGlyph', () => {
165
+ const box = { xMax: 10, xMin: 0, yMax: 10, yMin: 0 };
166
+ const none = new Uint8Array(0);
167
+
168
+ it('writes the contour count and bounds as signed values', () => {
169
+ const out = encodeSfntSimpleGlyph([0], [5], [5], [true], none, { xMax: 5, xMin: -5, yMax: 5, yMin: -5 });
170
+ const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
171
+ expect(view.getInt16(0)).toBe(1);
172
+ // Negative bounds are ordinary: a glyph descending below the baseline has a negative yMin, and
173
+ // reading these unsigned turns it into a huge positive box.
174
+ expect(view.getInt16(2)).toBe(-5);
175
+ expect(view.getInt16(4)).toBe(-5);
176
+ });
177
+
178
+ it('returns nothing at all for a glyph with no contours', () => {
179
+ // Zero length is how the format spells a blank glyph. A ten-byte record with a zero contour count
180
+ // would give every space character a bounding box.
181
+ expect(encodeSfntSimpleGlyph([], [], [], [], none, box).byteLength).toBe(0);
182
+ });
183
+
184
+ it('spends no coordinate bytes on a point that does not move', () => {
185
+ const moved = encodeSfntSimpleGlyph([1], [0, 7], [0, 0], [true, true], none, box);
186
+ const still = encodeSfntSimpleGlyph([1], [0, 0], [0, 0], [true, true], none, box);
187
+ expect(moved.byteLength - still.byteLength).toBe(1);
188
+ });
189
+
190
+ it('collapses a run of identical flags into one repeat group', () => {
191
+ // Ten identical points cost one flag plus a count, not ten flags.
192
+ const xs = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
193
+ const packed = encodeSfntSimpleGlyph(
194
+ [9],
195
+ xs,
196
+ xs,
197
+ xs.map(() => true),
198
+ none,
199
+ box,
200
+ );
201
+ // 10 header + 2 endPts + 2 instructionLength + 2 flag bytes, and no coordinate bytes at all.
202
+ expect(packed.byteLength).toBe(16);
203
+ });
204
+
205
+ it('carries the instructions and their length', () => {
206
+ const out = encodeSfntSimpleGlyph([0], [0], [0], [true], Uint8Array.from([0xb0, 0x01]), box);
207
+ const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
208
+ expect(view.getUint16(12)).toBe(2);
209
+ expect([...out.subarray(14, 16)]).toEqual([0xb0, 0x01]);
210
+ });
211
+
212
+ it('writes a delta beyond a byte as two bytes rather than truncating it', () => {
213
+ const short = encodeSfntSimpleGlyph([0], [255], [0], [true], none, box);
214
+ const long = encodeSfntSimpleGlyph([0], [256], [0], [true], none, box);
215
+ expect(long.byteLength - short.byteLength).toBe(1);
216
+ });
217
+ });
218
+
219
+ describe('packSfntTag', () => {
220
+ it('packs four characters big-endian', () => {
221
+ expect(packSfntTag('glyf')).toBe(0x676c7966);
222
+ });
223
+
224
+ it('pads a short tag with spaces, which is how the format spells its own', () => {
225
+ expect(packSfntTag('cvt')).toBe(packSfntTag('cvt '));
226
+ expect(packSfntTag('cvt')).toBe(0x63767420);
227
+ });
228
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createSyntheticFont } from './openTypeTestHelper';
4
+ import { readSfntTableDirectory, readSfntTag } from './sfntTableDirectory';
5
+
6
+ describe('readSfntTableDirectory', () => {
7
+ it('reads every table a well-formed font declares', () => {
8
+ const directory = readSfntTableDirectory(createSyntheticFont())!;
9
+ expect(directory.tables.has('glyf')).toBe(true);
10
+ expect(directory.tables.has('cmap')).toBe(true);
11
+ expect(directory.declaredTableCount).toBe(directory.tables.size);
12
+ });
13
+
14
+ it('reports the container flavor from the sfnt version', () => {
15
+ expect(readSfntTableDirectory(createSyntheticFont())?.sfntVersion).toBe(0x00010000);
16
+ // 'OTTO' — the same directory layout, PostScript outlines behind it.
17
+ expect(readSfntTableDirectory(createSyntheticFont({ flavor: 'opentype' }))?.sfntVersion).toBe(0x4f54544f);
18
+ });
19
+
20
+ it('gives each table a byte range that lies inside the file', () => {
21
+ const font = createSyntheticFont();
22
+ for (const range of readSfntTableDirectory(font)!.tables.values()) {
23
+ expect(range.offset + range.length).toBeLessThanOrEqual(font.byteLength);
24
+ }
25
+ });
26
+
27
+ // The distinction the explanation is built on: a truncated file still DECLARES every table, and the
28
+ // gap between what it declares and what is readable is what localizes the damage.
29
+ it('keeps the declared count while dropping tables that fall outside a truncated file', () => {
30
+ const font = createSyntheticFont();
31
+ const whole = readSfntTableDirectory(font)!;
32
+ const truncated = readSfntTableDirectory(font.subarray(0, font.length - 8))!;
33
+ expect(truncated.declaredTableCount).toBe(whole.declaredTableCount);
34
+ expect(truncated.tables.size).toBeLessThan(whole.tables.size);
35
+ });
36
+
37
+ it('returns the sentinel for bytes shorter than the header', () => {
38
+ expect(readSfntTableDirectory(new Uint8Array(11))).toBeNull();
39
+ expect(readSfntTableDirectory(new Uint8Array(0))).toBeNull();
40
+ });
41
+
42
+ it('stops rather than reading past the buffer when the header overstates the table count', () => {
43
+ const bytes = new Uint8Array(12);
44
+ new DataView(bytes.buffer).setUint16(4, 500);
45
+ const directory = readSfntTableDirectory(bytes)!;
46
+ expect(directory.declaredTableCount).toBe(500);
47
+ expect(directory.tables.size).toBe(0);
48
+ });
49
+ });
50
+
51
+ describe('readSfntTag', () => {
52
+ it('reads four bytes as the characters every specification spells a tag with', () => {
53
+ expect(readSfntTag(new Uint8Array([0x67, 0x6c, 0x79, 0x66]), 0)).toBe('glyf');
54
+ });
55
+
56
+ it('preserves a significant trailing space', () => {
57
+ // `CFF ` is four characters, the last one a space. Trimming it makes the tag miss its own table.
58
+ expect(readSfntTag(new Uint8Array([0x43, 0x46, 0x46, 0x20]), 0)).toBe('CFF ');
59
+ });
60
+
61
+ it('reads from the given offset rather than the start', () => {
62
+ expect(readSfntTag(new Uint8Array([0, 0, 0x68, 0x65, 0x61, 0x64]), 2)).toBe('head');
63
+ });
64
+ });
@@ -0,0 +1,129 @@
1
+ import type { Decompressor } from '@flighthq/types/contract';
2
+ import { Compression, CompressionFraming } from '@flighthq/types/contract';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { createSyntheticFont, encodeSyntheticWoff2 } from './openTypeTestHelper';
6
+ import { readSfntTableDirectory } from './sfntTableDirectory';
7
+ import { readWoff2Font, readWoff2TableDirectory, WOFF2_COMPRESSION } from './woff2Font';
8
+
9
+ // The synthetic container stores its stream uncompressed, so the whole container path is reachable with
10
+ // an identity decompressor and no Brotli implementation anywhere near the suite.
11
+ const identity: Decompressor = (compressed, _uncompressedLength, framing) => {
12
+ expect(framing).toBe(CompressionFraming.Raw);
13
+ return compressed as Uint8Array;
14
+ };
15
+
16
+ describe('readWoff2Font', () => {
17
+ it('rebuilds an sfnt carrying the same tables as the original', () => {
18
+ const original = createSyntheticFont();
19
+ const rebuilt = readWoff2Font(encodeSyntheticWoff2(original), identity, null)!;
20
+ const before = readSfntTableDirectory(original)!;
21
+ const after = readSfntTableDirectory(rebuilt)!;
22
+ expect([...after.tables.keys()].sort()).toEqual([...before.tables.keys()].sort());
23
+ });
24
+
25
+ it('preserves each table byte for byte through the unwrap', () => {
26
+ const original = createSyntheticFont();
27
+ const rebuilt = readWoff2Font(encodeSyntheticWoff2(original), identity, null)!;
28
+ const before = readSfntTableDirectory(original)!;
29
+ const after = readSfntTableDirectory(rebuilt)!;
30
+ for (const [tag, table] of before.tables) {
31
+ const mirror = after.tables.get(tag)!;
32
+ expect([...rebuilt.subarray(mirror.offset, mirror.offset + mirror.length)]).toEqual([
33
+ ...original.subarray(table.offset, table.offset + table.length),
34
+ ]);
35
+ }
36
+ });
37
+
38
+ it('carries the original flavor through, rather than inferring one', () => {
39
+ const original = createSyntheticFont({ flavor: 'opentype' });
40
+ const rebuilt = readWoff2Font(encodeSyntheticWoff2(original), identity, null)!;
41
+ expect(new DataView(rebuilt.buffer).getUint32(0)).toBe(0x4f54544f);
42
+ });
43
+
44
+ it('returns the sentinel when no decompressor is available', () => {
45
+ expect(readWoff2Font(encodeSyntheticWoff2(createSyntheticFont()), null, null)).toBeNull();
46
+ });
47
+
48
+ it('returns the sentinel for a transformed table when no transform reversal is supplied', () => {
49
+ // Refusing is the point: emitting the transformed bytes under the table's own tag would produce a
50
+ // font whose directory says `glyf` over something that is not one.
51
+ const wrapped = encodeSyntheticWoff2(createSyntheticFont(), ['glyf']);
52
+ expect(readWoff2Font(wrapped, identity, null)).toBeNull();
53
+ });
54
+
55
+ it('hands a transformed table to the reversal and uses what it returns', () => {
56
+ const replacement = Uint8Array.from([1, 2, 3, 4]);
57
+ const seen: string[] = [];
58
+ const wrapped = encodeSyntheticWoff2(createSyntheticFont(), ['glyf']);
59
+ const rebuilt = readWoff2Font(wrapped, identity, (tag) => {
60
+ seen.push(tag);
61
+ return replacement;
62
+ })!;
63
+ expect(seen).toEqual(['glyf']);
64
+ const glyf = readSfntTableDirectory(rebuilt)!.tables.get('glyf')!;
65
+ expect([...rebuilt.subarray(glyf.offset, glyf.offset + glyf.length)]).toEqual([...replacement]);
66
+ });
67
+
68
+ it('returns the sentinel when the transform reversal declines', () => {
69
+ const wrapped = encodeSyntheticWoff2(createSyntheticFont(), ['glyf']);
70
+ expect(readWoff2Font(wrapped, identity, () => null)).toBeNull();
71
+ });
72
+
73
+ it('returns the sentinel for a truncated container rather than a partial font', () => {
74
+ const wrapped = encodeSyntheticWoff2(createSyntheticFont());
75
+ expect(readWoff2Font(wrapped.subarray(0, 40), identity, null)).toBeNull();
76
+ });
77
+ });
78
+
79
+ describe('readWoff2TableDirectory', () => {
80
+ it('resolves tags carried as an index into the known-tag table', () => {
81
+ const directory = readWoff2TableDirectory(encodeSyntheticWoff2(createSyntheticFont()))!;
82
+ expect(directory.entries.map((entry) => entry.tag)).toContain('glyf');
83
+ expect(directory.entries.map((entry) => entry.tag)).toContain('cmap');
84
+ });
85
+
86
+ it('points streamStart past the directory rather than at a guess from the end', () => {
87
+ const wrapped = encodeSyntheticWoff2(createSyntheticFont());
88
+ const directory = readWoff2TableDirectory(wrapped)!;
89
+ expect(directory.streamStart).toBeGreaterThan(48);
90
+ // The stream runs from there to the end of the file, so the two must agree exactly. Deriving the
91
+ // start by subtracting instead lands in the format's trailing padding.
92
+ expect(directory.streamStart + directory.totalUncompressedLength).toBe(wrapped.byteLength);
93
+ });
94
+
95
+ it('reads the transform flag with the sense inverted for the glyph pair', () => {
96
+ // `glyf` is marked transformed here and `cmap` is not. Both are encoded with the version whose
97
+ // meaning is opposite between them, so a reader applying one rule to both gets both answers wrong.
98
+ const directory = readWoff2TableDirectory(encodeSyntheticWoff2(createSyntheticFont(), ['glyf']))!;
99
+ const glyf = directory.entries.find((entry) => entry.tag === 'glyf')!;
100
+ const cmap = directory.entries.find((entry) => entry.tag === 'cmap')!;
101
+ expect(glyf.transformed).toBe(true);
102
+ expect(glyf.transformVersion).toBe(0);
103
+ expect(cmap.transformed).toBe(false);
104
+ expect(cmap.transformVersion).toBe(0);
105
+ });
106
+
107
+ it('treats the glyph pair as untransformed only at version three', () => {
108
+ const directory = readWoff2TableDirectory(encodeSyntheticWoff2(createSyntheticFont()))!;
109
+ const glyf = directory.entries.find((entry) => entry.tag === 'glyf')!;
110
+ expect(glyf.transformVersion).toBe(3);
111
+ expect(glyf.transformed).toBe(false);
112
+ });
113
+
114
+ it('returns the sentinel for a container declaring no tables', () => {
115
+ const wrapped = encodeSyntheticWoff2(createSyntheticFont());
116
+ new DataView(wrapped.buffer).setUint16(12, 0);
117
+ expect(readWoff2TableDirectory(wrapped)).toBeNull();
118
+ });
119
+
120
+ it('returns the sentinel when the directory runs past the buffer', () => {
121
+ expect(readWoff2TableDirectory(encodeSyntheticWoff2(createSyntheticFont()).subarray(0, 50))).toBeNull();
122
+ });
123
+ });
124
+
125
+ describe('WOFF2_COMPRESSION', () => {
126
+ it('names Brotli through the shared vocabulary so a caller registers what this asks for', () => {
127
+ expect(WOFF2_COMPRESSION).toBe(Compression.Brotli);
128
+ });
129
+ });