@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.
- package/dist/cffCharstring.d.ts +13 -0
- package/dist/cffCharstring.d.ts.map +1 -0
- package/dist/cffCharstring.js +336 -0
- package/dist/cffCharstring.js.map +1 -0
- package/dist/cffDict.d.ts +8 -0
- package/dist/cffDict.d.ts.map +1 -0
- package/dist/cffDict.js +118 -0
- package/dist/cffDict.js.map +1 -0
- package/dist/cffFdSelect.d.ts +2 -0
- package/dist/cffFdSelect.d.ts.map +1 -0
- package/dist/cffFdSelect.js +60 -0
- package/dist/cffFdSelect.js.map +1 -0
- package/dist/cffIndex.d.ts +3 -0
- package/dist/cffIndex.d.ts.map +1 -0
- package/dist/cffIndex.js +52 -0
- package/dist/cffIndex.js.map +1 -0
- package/dist/cffTable.d.ts +3 -0
- package/dist/cffTable.d.ts.map +1 -0
- package/dist/cffTable.js +134 -0
- package/dist/cffTable.js.map +1 -0
- package/dist/contract.d.ts +15 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +15 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/openTypeCmap.d.ts +5 -0
- package/dist/openTypeCmap.d.ts.map +1 -0
- package/dist/openTypeCmap.js +161 -0
- package/dist/openTypeCmap.js.map +1 -0
- package/dist/openTypeGlyf.d.ts +4 -0
- package/dist/openTypeGlyf.d.ts.map +1 -0
- package/dist/openTypeGlyf.js +229 -0
- package/dist/openTypeGlyf.js.map +1 -0
- package/dist/openTypeGlyphOutlineSource.d.ts +4 -0
- package/dist/openTypeGlyphOutlineSource.d.ts.map +1 -0
- package/dist/openTypeGlyphOutlineSource.js +168 -0
- package/dist/openTypeGlyphOutlineSource.js.map +1 -0
- package/dist/openTypeMetrics.d.ts +6 -0
- package/dist/openTypeMetrics.d.ts.map +1 -0
- package/dist/openTypeMetrics.js +74 -0
- package/dist/openTypeMetrics.js.map +1 -0
- package/dist/openTypeTestHelper.d.ts +27 -0
- package/dist/openTypeTestHelper.d.ts.map +1 -0
- package/dist/openTypeTestHelper.js +520 -0
- package/dist/openTypeTestHelper.js.map +1 -0
- package/dist/sfntAssembly.d.ts +20 -0
- package/dist/sfntAssembly.d.ts.map +1 -0
- package/dist/sfntAssembly.js +237 -0
- package/dist/sfntAssembly.js.map +1 -0
- package/dist/sfntTableDirectory.d.ts +4 -0
- package/dist/sfntTableDirectory.d.ts.map +1 -0
- package/dist/sfntTableDirectory.js +39 -0
- package/dist/sfntTableDirectory.js.map +1 -0
- package/dist/woff2Font.d.ts +6 -0
- package/dist/woff2Font.d.ts.map +1 -0
- package/dist/woff2Font.js +210 -0
- package/dist/woff2Font.js.map +1 -0
- package/dist/woff2GlyfTransform.d.ts +23 -0
- package/dist/woff2GlyfTransform.d.ts.map +1 -0
- package/dist/woff2GlyfTransform.js +437 -0
- package/dist/woff2GlyfTransform.js.map +1 -0
- package/dist/woffFont.d.ts +6 -0
- package/dist/woffFont.d.ts.map +1 -0
- package/dist/woffFont.js +122 -0
- package/dist/woffFont.js.map +1 -0
- package/package.json +51 -0
- package/src/cffCharstring.test.ts +177 -0
- package/src/cffDict.test.ts +67 -0
- package/src/cffFdSelect.test.ts +84 -0
- package/src/cffIndex.test.ts +97 -0
- package/src/cffTable.test.ts +84 -0
- package/src/openTypeCmap.test.ts +124 -0
- package/src/openTypeGlyf.test.ts +168 -0
- package/src/openTypeGlyfWinding.test.ts +80 -0
- package/src/openTypeGlyphOutlineSource.test.ts +298 -0
- package/src/openTypeMetrics.test.ts +103 -0
- package/src/sfntAssembly.test.ts +228 -0
- package/src/sfntTableDirectory.test.ts +64 -0
- package/src/woff2Font.test.ts +129 -0
- package/src/woff2GlyfTransform.test.ts +315 -0
- package/src/woffFont.test.ts +207 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { findOpenTypeUnicodeSubtable, rankOpenTypeUnicodeEncoding, readOpenTypeCodepointMap } from './openTypeCmap';
|
|
4
|
+
import { createSyntheticFont } from './openTypeTestHelper';
|
|
5
|
+
import { readSfntTableDirectory } from './sfntTableDirectory';
|
|
6
|
+
|
|
7
|
+
describe('findOpenTypeUnicodeSubtable', () => {
|
|
8
|
+
it('finds the sub-table a font declares', () => {
|
|
9
|
+
const font = createSyntheticFont({ codepoints: new Map([[65, 1]]) });
|
|
10
|
+
const cmap = readSfntTableDirectory(font)!.tables.get('cmap')!;
|
|
11
|
+
const view = new DataView(font.buffer, font.byteOffset, font.byteLength);
|
|
12
|
+
expect(findOpenTypeUnicodeSubtable(view, cmap.offset, cmap.length, font.byteLength)).toBeGreaterThan(cmap.offset);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns -1 when the font declares no Unicode mapping', () => {
|
|
16
|
+
const font = createSyntheticFont({ codepoints: new Map([[65, 1]]) });
|
|
17
|
+
const cmap = readSfntTableDirectory(font)!.tables.get('cmap')!;
|
|
18
|
+
const view = new DataView(font.buffer, font.byteOffset, font.byteLength);
|
|
19
|
+
// Platform 1 is the legacy Macintosh encoding, which answers a different question than "which glyph
|
|
20
|
+
// draws this codepoint" and is deliberately not read.
|
|
21
|
+
view.setUint16(cmap.offset + 4, 1);
|
|
22
|
+
view.setUint16(cmap.offset + 6, 0);
|
|
23
|
+
expect(findOpenTypeUnicodeSubtable(view, cmap.offset, cmap.length, font.byteLength)).toBe(-1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('skips a wider-repertoire sub-table stored in a format this reader cannot read', () => {
|
|
27
|
+
// A real font is shaped exactly like this: a readable format 4 at rank 1 and a format 0 at rank 2.
|
|
28
|
+
// Ranking on encoding alone picks the format 0 and the whole font is refused, while a readable
|
|
29
|
+
// sub-table sits beside it. Built by hand rather than through the font helper, because the helper
|
|
30
|
+
// only ever writes formats this reader supports and so could never express the case.
|
|
31
|
+
const table = buildCmapWithTwoSubtables(0);
|
|
32
|
+
const view = new DataView(table.buffer);
|
|
33
|
+
const chosen = findOpenTypeUnicodeSubtable(view, 0, table.byteLength, table.byteLength);
|
|
34
|
+
expect(chosen).toBe(20);
|
|
35
|
+
expect(view.getUint16(chosen)).toBe(4);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('still prefers the wider repertoire when that sub-table IS readable', () => {
|
|
39
|
+
// The mirror of the case above, and what stops the fix from degenerating into "always take rank 1":
|
|
40
|
+
// same layout, but the rank-2 sub-table is a format 12 rather than a format 0.
|
|
41
|
+
const table = buildCmapWithTwoSubtables(12);
|
|
42
|
+
const view = new DataView(table.buffer);
|
|
43
|
+
const chosen = findOpenTypeUnicodeSubtable(view, 0, table.byteLength, table.byteLength);
|
|
44
|
+
expect(chosen).toBe(24);
|
|
45
|
+
expect(view.getUint16(chosen)).toBe(12);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// A `cmap` carrying two Unicode sub-tables: platform 3 encoding 1 (rank 1) holding a format 4 at offset
|
|
50
|
+
// 20, and platform 3 encoding 10 (rank 2) holding `widerFormat` at offset 24. Only the format words
|
|
51
|
+
// matter to the chooser, so the sub-table bodies are left empty.
|
|
52
|
+
function buildCmapWithTwoSubtables(widerFormat: number): Uint8Array {
|
|
53
|
+
const table = new Uint8Array(32);
|
|
54
|
+
const view = new DataView(table.buffer);
|
|
55
|
+
view.setUint16(2, 2);
|
|
56
|
+
view.setUint16(4, 3);
|
|
57
|
+
view.setUint16(6, 1);
|
|
58
|
+
view.setUint32(8, 20);
|
|
59
|
+
view.setUint16(12, 3);
|
|
60
|
+
view.setUint16(14, 10);
|
|
61
|
+
view.setUint32(16, 24);
|
|
62
|
+
view.setUint16(20, 4);
|
|
63
|
+
view.setUint16(24, widerFormat);
|
|
64
|
+
return table;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('rankOpenTypeUnicodeEncoding', () => {
|
|
68
|
+
// Preferring the full-repertoire mapping is what keeps emoji and CJK extensions reachable; a BMP-only
|
|
69
|
+
// sub-table silently drops exactly that material, and both are usually present in the same font.
|
|
70
|
+
it('ranks the full-repertoire mappings above the BMP-only ones', () => {
|
|
71
|
+
expect(rankOpenTypeUnicodeEncoding(3, 10)).toBeGreaterThan(rankOpenTypeUnicodeEncoding(3, 1));
|
|
72
|
+
expect(rankOpenTypeUnicodeEncoding(0, 4)).toBeGreaterThan(rankOpenTypeUnicodeEncoding(0, 3));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects a platform that is not a Unicode mapping at all', () => {
|
|
76
|
+
expect(rankOpenTypeUnicodeEncoding(1, 0)).toBe(-1);
|
|
77
|
+
expect(rankOpenTypeUnicodeEncoding(3, 0)).toBe(-1);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('readOpenTypeCodepointMap', () => {
|
|
82
|
+
it('maps every codepoint the font states', () => {
|
|
83
|
+
const font = createSyntheticFont({
|
|
84
|
+
codepoints: new Map([
|
|
85
|
+
[65, 1],
|
|
86
|
+
[66, 2],
|
|
87
|
+
[0x4e2d, 3],
|
|
88
|
+
]),
|
|
89
|
+
});
|
|
90
|
+
const map = readOpenTypeCodepointMap(font, readSfntTableDirectory(font)!)!;
|
|
91
|
+
expect(map.get(65)).toBe(1);
|
|
92
|
+
expect(map.get(66)).toBe(2);
|
|
93
|
+
expect(map.get(0x4e2d)).toBe(3);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('omits codepoints the font does not cover rather than mapping them to glyph zero', () => {
|
|
97
|
+
const font = createSyntheticFont({ codepoints: new Map([[65, 1]]) });
|
|
98
|
+
const map = readOpenTypeCodepointMap(font, readSfntTableDirectory(font)!)!;
|
|
99
|
+
// Glyph 0 is the "missing glyph" box. Mapping an uncovered codepoint to it here would make the
|
|
100
|
+
// source unable to report that the font does not cover the character.
|
|
101
|
+
expect(map.has(66)).toBe(false);
|
|
102
|
+
expect(map.size).toBe(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('does not map the 0xFFFF terminator segment the format requires', () => {
|
|
106
|
+
const font = createSyntheticFont({ codepoints: new Map([[65, 1]]) });
|
|
107
|
+
expect(readOpenTypeCodepointMap(font, readSfntTableDirectory(font)!)!.has(0xffff)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns the sentinel when cmap is absent', () => {
|
|
111
|
+
const font = createSyntheticFont({ omitTable: 'cmap' });
|
|
112
|
+
expect(readOpenTypeCodepointMap(font, readSfntTableDirectory(font)!)).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('returns the sentinel for a sub-table format it does not read', () => {
|
|
116
|
+
const font = createSyntheticFont({ codepoints: new Map([[65, 1]]) });
|
|
117
|
+
const directory = readSfntTableDirectory(font)!;
|
|
118
|
+
const cmap = directory.tables.get('cmap')!;
|
|
119
|
+
const view = new DataView(font.buffer, font.byteOffset, font.byteLength);
|
|
120
|
+
// Format 6 is a trimmed single-range table, not read here; a sentinel beats a partial map.
|
|
121
|
+
view.setUint16(cmap.offset + view.getUint32(cmap.offset + 8), 6);
|
|
122
|
+
expect(readOpenTypeCodepointMap(font, directory)).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { PathCommand } from '@flighthq/types/contract';
|
|
2
|
+
import type { Path } from '@flighthq/types/contract';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { readOpenTypeGlyphOutline, readOpenTypeGlyphRanges } from './openTypeGlyf';
|
|
6
|
+
import { readOpenTypeGlyphCount, readOpenTypeLocaFormat } from './openTypeMetrics';
|
|
7
|
+
import { createSyntheticFont, emptySyntheticGlyph, squareSyntheticGlyph } from './openTypeTestHelper';
|
|
8
|
+
import type { SyntheticGlyph } from './openTypeTestHelper';
|
|
9
|
+
import { readSfntTableDirectory } from './sfntTableDirectory';
|
|
10
|
+
|
|
11
|
+
function createPath(): Path {
|
|
12
|
+
return { commands: [], data: [], winding: 'evenOdd' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Reads a glyph out of a synthetic font through the same steps the source does, so these tests exercise
|
|
16
|
+
// the real `loca`/`glyf` pair rather than a hand-built range array.
|
|
17
|
+
function readGlyph(glyphs: readonly SyntheticGlyph[], glyphIndex: number): { ok: boolean; path: Path } {
|
|
18
|
+
const font = createSyntheticFont({ glyphs });
|
|
19
|
+
const directory = readSfntTableDirectory(font)!;
|
|
20
|
+
const ranges = readOpenTypeGlyphRanges(
|
|
21
|
+
font,
|
|
22
|
+
directory,
|
|
23
|
+
readOpenTypeGlyphCount(font, directory),
|
|
24
|
+
readOpenTypeLocaFormat(font, directory),
|
|
25
|
+
)!;
|
|
26
|
+
const path = createPath();
|
|
27
|
+
return { ok: readOpenTypeGlyphOutline(path, font, directory, ranges, glyphIndex), path };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('readOpenTypeGlyphOutline', () => {
|
|
31
|
+
it('writes the winding rather than leaving whatever the caller had', () => {
|
|
32
|
+
// Seeded with the WRONG rule on purpose. A fresh `createPath` already defaults to nonZero, so a
|
|
33
|
+
// test that built one would pass whether or not this reader writes the field — and a caller
|
|
34
|
+
// reusing a scratch path across glyphs is exactly the case that keeps a stale value and renders
|
|
35
|
+
// a counter as a solid blob.
|
|
36
|
+
const path: Path = { commands: [], data: [], winding: 'evenOdd' };
|
|
37
|
+
const font = createSyntheticFont({ glyphs: [emptySyntheticGlyph(), squareSyntheticGlyph(100)] });
|
|
38
|
+
const directory = readSfntTableDirectory(font)!;
|
|
39
|
+
const ranges = readOpenTypeGlyphRanges(
|
|
40
|
+
font,
|
|
41
|
+
directory,
|
|
42
|
+
readOpenTypeGlyphCount(font, directory),
|
|
43
|
+
readOpenTypeLocaFormat(font, directory),
|
|
44
|
+
)!;
|
|
45
|
+
readOpenTypeGlyphOutline(path, font, directory, ranges, 1);
|
|
46
|
+
expect(path.winding).toBe('nonZero');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('emits one line per edge and closes, without a redundant segment back to the start', () => {
|
|
50
|
+
const { ok, path } = readGlyph([emptySyntheticGlyph(), squareSyntheticGlyph(100)], 1);
|
|
51
|
+
expect(ok).toBe(true);
|
|
52
|
+
// Four corners: a move, three lines, and a close that implies the fourth edge.
|
|
53
|
+
expect(path.commands).toEqual([
|
|
54
|
+
PathCommand.MOVE_TO,
|
|
55
|
+
PathCommand.LINE_TO,
|
|
56
|
+
PathCommand.LINE_TO,
|
|
57
|
+
PathCommand.LINE_TO,
|
|
58
|
+
PathCommand.CLOSE,
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// The rule that makes this format different from every other path format: two consecutive off-curve
|
|
63
|
+
// points imply an on-curve point exactly halfway between them, which the file omits because it is
|
|
64
|
+
// recoverable. A reader that takes the point list literally visibly cuts corners.
|
|
65
|
+
it('reconstructs the on-curve point implied between two consecutive off-curve points', () => {
|
|
66
|
+
const glyph: SyntheticGlyph = {
|
|
67
|
+
endPoints: [2],
|
|
68
|
+
points: [
|
|
69
|
+
[0, 0, true],
|
|
70
|
+
[100, 0, false],
|
|
71
|
+
[100, 100, false],
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
const { path } = readGlyph([emptySyntheticGlyph(), glyph], 1);
|
|
75
|
+
expect(path.commands).toEqual([PathCommand.MOVE_TO, PathCommand.CURVE_TO, PathCommand.CURVE_TO, PathCommand.CLOSE]);
|
|
76
|
+
// First curve ends at the midpoint of the two controls — (100,0) and (100,100) → (100,50), negated.
|
|
77
|
+
expect(path.data.slice(2, 6)).toEqual([100, -0, 100, -50]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('starts at the midpoint when a contour opens and closes off-curve', () => {
|
|
81
|
+
const glyph: SyntheticGlyph = {
|
|
82
|
+
endPoints: [2],
|
|
83
|
+
points: [
|
|
84
|
+
[0, 100, false],
|
|
85
|
+
[50, 0, true],
|
|
86
|
+
[100, 100, false],
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
const { path } = readGlyph([emptySyntheticGlyph(), glyph], 1);
|
|
90
|
+
// Neither the first nor the last point is on-curve, so the contour begins halfway between them:
|
|
91
|
+
// (0,100) and (100,100) → (50,100), negated to y-down.
|
|
92
|
+
expect(path.data.slice(0, 2)).toEqual([50, -100]);
|
|
93
|
+
expect(path.commands[0]).toBe(PathCommand.MOVE_TO);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('emits a quadratic through a single off-curve control', () => {
|
|
97
|
+
const glyph: SyntheticGlyph = {
|
|
98
|
+
endPoints: [2],
|
|
99
|
+
points: [
|
|
100
|
+
[0, 0, true],
|
|
101
|
+
[50, 100, false],
|
|
102
|
+
[100, 0, true],
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
const { path } = readGlyph([emptySyntheticGlyph(), glyph], 1);
|
|
106
|
+
expect(path.commands).toEqual([PathCommand.MOVE_TO, PathCommand.CURVE_TO, PathCommand.CLOSE]);
|
|
107
|
+
expect(path.data).toEqual([0, -0, 50, -100, 100, -0]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('reads a second contour into the same path', () => {
|
|
111
|
+
const glyph: SyntheticGlyph = {
|
|
112
|
+
endPoints: [3, 7],
|
|
113
|
+
points: [
|
|
114
|
+
[0, 0, true],
|
|
115
|
+
[10, 0, true],
|
|
116
|
+
[10, 10, true],
|
|
117
|
+
[0, 10, true],
|
|
118
|
+
[20, 20, true],
|
|
119
|
+
[30, 20, true],
|
|
120
|
+
[30, 30, true],
|
|
121
|
+
[20, 30, true],
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
const { path } = readGlyph([emptySyntheticGlyph(), glyph], 1);
|
|
125
|
+
expect(path.commands.filter((command) => command === PathCommand.MOVE_TO)).toHaveLength(2);
|
|
126
|
+
expect(path.commands.filter((command) => command === PathCommand.CLOSE)).toHaveLength(2);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns true with an empty path for a glyph with no contours', () => {
|
|
130
|
+
const { ok, path } = readGlyph([emptySyntheticGlyph()], 0);
|
|
131
|
+
expect(ok).toBe(true);
|
|
132
|
+
expect(path.commands).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('replaces the previous contents of out rather than appending to them', () => {
|
|
136
|
+
const font = createSyntheticFont({ glyphs: [emptySyntheticGlyph(), squareSyntheticGlyph(100)] });
|
|
137
|
+
const directory = readSfntTableDirectory(font)!;
|
|
138
|
+
const ranges = readOpenTypeGlyphRanges(font, directory, readOpenTypeGlyphCount(font, directory), 1)!;
|
|
139
|
+
const path = createPath();
|
|
140
|
+
readOpenTypeGlyphOutline(path, font, directory, ranges, 1);
|
|
141
|
+
const first = path.commands.length;
|
|
142
|
+
readOpenTypeGlyphOutline(path, font, directory, ranges, 1);
|
|
143
|
+
expect(path.commands).toHaveLength(first);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('returns false for an index outside the range table', () => {
|
|
147
|
+
expect(readGlyph([emptySyntheticGlyph()], 5).ok).toBe(false);
|
|
148
|
+
expect(readGlyph([emptySyntheticGlyph()], -1).ok).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('readOpenTypeGlyphRanges', () => {
|
|
153
|
+
it('yields one more entry than there are glyphs, so every glyph has an end', () => {
|
|
154
|
+
const font = createSyntheticFont({ glyphs: [emptySyntheticGlyph(), squareSyntheticGlyph(10)] });
|
|
155
|
+
const directory = readSfntTableDirectory(font)!;
|
|
156
|
+
expect(readOpenTypeGlyphRanges(font, directory, 2, 1)).toHaveLength(3);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('returns the sentinel when loca is absent', () => {
|
|
160
|
+
const font = createSyntheticFont({ omitTable: 'loca' });
|
|
161
|
+
expect(readOpenTypeGlyphRanges(font, readSfntTableDirectory(font)!, 1, 1)).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('returns the sentinel when loca is too short for the glyph count it must index', () => {
|
|
165
|
+
const font = createSyntheticFont({ glyphs: [emptySyntheticGlyph()] });
|
|
166
|
+
expect(readOpenTypeGlyphRanges(font, readSfntTableDirectory(font)!, 999, 1)).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { PathCommand } from '@flighthq/types/contract';
|
|
2
|
+
import type { Path } from '@flighthq/types/contract';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { createGlyphOutlineSourceFromOpenTypeFont } from './openTypeGlyphOutlineSource';
|
|
6
|
+
import { createSyntheticFont, emptySyntheticGlyph, ringSyntheticGlyph } from './openTypeTestHelper';
|
|
7
|
+
|
|
8
|
+
// Contour DIRECTION, which nothing else in this suite can see. A bounding box is min/max over a point
|
|
9
|
+
// set and a contour count is a count — both are identical whichever way a contour is wound, so a
|
|
10
|
+
// reversed counter turns a ring into a solid disc with every other number unchanged.
|
|
11
|
+
//
|
|
12
|
+
// The check is the SIGNED AREA per contour, whose sign IS the winding direction. It needs no
|
|
13
|
+
// rasterizer, and it was validated against one: a browser's nonzero fill paints a hole for opposite
|
|
14
|
+
// signs, fills it for matching signs, and — the case this test must NOT flag — paints the identical
|
|
15
|
+
// hole when BOTH contours are reversed, because only their relative direction matters.
|
|
16
|
+
function signedContourAreas(path: Readonly<Path>): number[] {
|
|
17
|
+
const areas: number[] = [];
|
|
18
|
+
let points: [number, number][] = [];
|
|
19
|
+
let at = 0;
|
|
20
|
+
const closeContour = (): void => {
|
|
21
|
+
if (points.length > 2) {
|
|
22
|
+
let twiceArea = 0;
|
|
23
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
24
|
+
const [x1, y1] = points[index]!;
|
|
25
|
+
const [x2, y2] = points[(index + 1) % points.length]!;
|
|
26
|
+
twiceArea += x1 * y2 - x2 * y1;
|
|
27
|
+
}
|
|
28
|
+
areas.push(twiceArea / 2);
|
|
29
|
+
}
|
|
30
|
+
points = [];
|
|
31
|
+
};
|
|
32
|
+
for (const command of path.commands) {
|
|
33
|
+
if (command === PathCommand.MOVE_TO || command === PathCommand.LINE_TO) {
|
|
34
|
+
points.push([path.data[at]!, path.data[at + 1]!]);
|
|
35
|
+
at += 2;
|
|
36
|
+
} else if (command === PathCommand.CURVE_TO) {
|
|
37
|
+
points.push([path.data[at + 2]!, path.data[at + 3]!]);
|
|
38
|
+
at += 4;
|
|
39
|
+
} else if (command === PathCommand.CUBIC_CURVE_TO) {
|
|
40
|
+
points.push([path.data[at + 4]!, path.data[at + 5]!]);
|
|
41
|
+
at += 6;
|
|
42
|
+
} else if (command === PathCommand.CLOSE) {
|
|
43
|
+
closeContour();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
closeContour();
|
|
47
|
+
return areas;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ringAreas(reverseCounter: boolean, flipBoth = false): number[] {
|
|
51
|
+
const font = createSyntheticFont({
|
|
52
|
+
glyphs: [emptySyntheticGlyph(), ringSyntheticGlyph(reverseCounter, flipBoth)],
|
|
53
|
+
});
|
|
54
|
+
const source = createGlyphOutlineSourceFromOpenTypeFont(font)!;
|
|
55
|
+
const path: Path = { commands: [], data: [], winding: 'nonZero' };
|
|
56
|
+
expect(source.getGlyphOutline(path, 1)).toBe(true);
|
|
57
|
+
return signedContourAreas(path);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('openTypeGlyfWinding', () => {
|
|
61
|
+
it('winds a counter opposite its outer contour, which is what makes it a hole', () => {
|
|
62
|
+
const areas = ringAreas(false);
|
|
63
|
+
expect(areas).toHaveLength(2);
|
|
64
|
+
// Magnitudes are the squares' own areas, so this also pins that the right points reached the path.
|
|
65
|
+
expect(areas.map(Math.abs)).toEqual([360_000, 40_000]);
|
|
66
|
+
expect(Math.sign(areas[0]!)).not.toBe(Math.sign(areas[1]!));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('detects a counter wound the wrong way, which every other check reads as identical', () => {
|
|
70
|
+
const areas = ringAreas(true);
|
|
71
|
+
expect(Math.sign(areas[0]!)).toBe(Math.sign(areas[1]!));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('ignores both contours being reversed, which a nonzero fill renders identically', () => {
|
|
75
|
+
// The negative control. A check that flagged this would be firing on a non-defect — verified
|
|
76
|
+
// against a browser, where both-flipped paints the same hole as the correct winding.
|
|
77
|
+
const areas = ringAreas(false, true);
|
|
78
|
+
expect(Math.sign(areas[0]!)).not.toBe(Math.sign(areas[1]!));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { PathCommand } from '@flighthq/types/contract';
|
|
2
|
+
import type { Path } from '@flighthq/types/contract';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { createGlyphOutlineSourceFromOpenTypeFont, explainOpenTypeFont } from './openTypeGlyphOutlineSource';
|
|
6
|
+
import {
|
|
7
|
+
createSyntheticFont,
|
|
8
|
+
emptySyntheticGlyph,
|
|
9
|
+
encodeSyntheticWoff,
|
|
10
|
+
squareSyntheticGlyph,
|
|
11
|
+
} from './openTypeTestHelper';
|
|
12
|
+
import { readSfntTableDirectory } from './sfntTableDirectory';
|
|
13
|
+
|
|
14
|
+
// Every font here is assembled byte by byte by the helper. Nothing third-party is read, fetched, or
|
|
15
|
+
// committed, and each test states the table contents its assertion depends on.
|
|
16
|
+
// A WOFF is a real font behind a compression wrapper — a different remedy from unreadable bytes, which is
|
|
17
|
+
// why it gets its own reason.
|
|
18
|
+
// A font whose only outline table is CFF2 — a different charstring dialect with variation support, which
|
|
19
|
+
// this package deliberately does not read. It keeps the stated-absence treatment `CFF ` had until the
|
|
20
|
+
// charstring interpreter landed.
|
|
21
|
+
function cff2OnlyFont(): Uint8Array {
|
|
22
|
+
const font = createSyntheticFont({ flavor: 'opentype' });
|
|
23
|
+
const directory = readSfntTableDirectory(font)!;
|
|
24
|
+
const record = [...directory.tables.keys()].sort().indexOf('CFF ');
|
|
25
|
+
// Rename the table tag in place: same bytes, different tag, so the container is well-formed and only
|
|
26
|
+
// the dialect differs.
|
|
27
|
+
for (const [index, character] of [...'CFF2'].entries()) font[12 + record * 16 + index] = character.charCodeAt(0);
|
|
28
|
+
return font;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function woff2Bytes(): Uint8Array {
|
|
32
|
+
const bytes = new Uint8Array(20);
|
|
33
|
+
bytes.set([0x77, 0x4f, 0x46, 0x32]);
|
|
34
|
+
return bytes;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Every table present, but `head` states a zero unitsPerEm — the one value that cannot be defaulted,
|
|
38
|
+
// since it is the scale denominator every coordinate divides by.
|
|
39
|
+
function malformedUnitsPerEmFont(): Uint8Array {
|
|
40
|
+
const font = createSyntheticFont();
|
|
41
|
+
const head = readSfntTableDirectory(font)!.tables.get('head')!;
|
|
42
|
+
new DataView(font.buffer, font.byteOffset, font.byteLength).setUint16(head.offset + 18, 0);
|
|
43
|
+
return font;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createPath(): Path {
|
|
47
|
+
return { commands: [], data: [], winding: 'evenOdd' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('createGlyphOutlineSourceFromOpenTypeFont', () => {
|
|
51
|
+
it('produces a source from a well-formed TrueType font', () => {
|
|
52
|
+
expect(createGlyphOutlineSourceFromOpenTypeFont(createSyntheticFont())).not.toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('reports the font-wide metrics in design units, with descent as a positive distance', () => {
|
|
56
|
+
const source = createGlyphOutlineSourceFromOpenTypeFont(createSyntheticFont({ unitsPerEm: 2048 }));
|
|
57
|
+
// The fixture stores descender as -200, as the format requires; the seam flips it.
|
|
58
|
+
expect(source?.getGlyphOutlineMetrics()).toEqual({ ascent: 800, descent: 200, lineGap: 100, unitsPerEm: 2048 });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('maps a codepoint to its glyph index and returns -1 for one the font does not cover', () => {
|
|
62
|
+
const source = createGlyphOutlineSourceFromOpenTypeFont(
|
|
63
|
+
createSyntheticFont({
|
|
64
|
+
codepoints: new Map([[65, 1]]),
|
|
65
|
+
glyphs: [emptySyntheticGlyph(), squareSyntheticGlyph(100)],
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
expect(source?.getGlyphOutlineIndexForCodePoint(65)).toBe(1);
|
|
69
|
+
expect(source?.getGlyphOutlineIndexForCodePoint(66)).toBe(-1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('reads a square contour into the path, with y negated into the downward convention', () => {
|
|
73
|
+
const source = createGlyphOutlineSourceFromOpenTypeFont(
|
|
74
|
+
createSyntheticFont({ glyphs: [emptySyntheticGlyph(), squareSyntheticGlyph(100)] }),
|
|
75
|
+
);
|
|
76
|
+
const path = createPath();
|
|
77
|
+
expect(source?.getGlyphOutline(path, 1)).toBe(true);
|
|
78
|
+
expect(path.commands).toEqual([
|
|
79
|
+
PathCommand.MOVE_TO,
|
|
80
|
+
PathCommand.LINE_TO,
|
|
81
|
+
PathCommand.LINE_TO,
|
|
82
|
+
PathCommand.LINE_TO,
|
|
83
|
+
PathCommand.CLOSE,
|
|
84
|
+
]);
|
|
85
|
+
// The font states y-up (0,0) (100,0) (100,100) (0,100); ink above the baseline is negative y here.
|
|
86
|
+
expect(path.data).toEqual([0, -0, 100, -0, 100, -100, 0, -100]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns true with an empty path for a glyph that legitimately has no outline', () => {
|
|
90
|
+
const source = createGlyphOutlineSourceFromOpenTypeFont(createSyntheticFont({ glyphs: [emptySyntheticGlyph()] }));
|
|
91
|
+
const path = createPath();
|
|
92
|
+
// True, not false: a space still has to advance, so its absence of ink must stay observable.
|
|
93
|
+
expect(source?.getGlyphOutline(path, 0)).toBe(true);
|
|
94
|
+
expect(path.commands).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns false for a glyph index the font does not have', () => {
|
|
98
|
+
const source = createGlyphOutlineSourceFromOpenTypeFont(createSyntheticFont());
|
|
99
|
+
expect(source?.getGlyphOutline(createPath(), 99)).toBe(false);
|
|
100
|
+
expect(source?.getGlyphOutline(createPath(), -1)).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('reports each glyph its own advance', () => {
|
|
104
|
+
const source = createGlyphOutlineSourceFromOpenTypeFont(
|
|
105
|
+
createSyntheticFont({ advances: [300, 750], glyphs: [emptySyntheticGlyph(), squareSyntheticGlyph(50)] }),
|
|
106
|
+
);
|
|
107
|
+
expect(source?.getGlyphOutlineAdvance(0)).toBe(300);
|
|
108
|
+
expect(source?.getGlyphOutlineAdvance(1)).toBe(750);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// The run-length rule in `hmtx`: glyphs past `numberOfHMetrics` repeat the last stated advance. A
|
|
112
|
+
// reader treating the table as flat gives every one of them a wrong width, which is how a monospaced
|
|
113
|
+
// or CJK font silently mis-measures.
|
|
114
|
+
it('repeats the last stated advance for glyphs past the long-metric count', () => {
|
|
115
|
+
const source = createGlyphOutlineSourceFromOpenTypeFont(
|
|
116
|
+
createSyntheticFont({
|
|
117
|
+
advances: [600],
|
|
118
|
+
glyphs: [emptySyntheticGlyph(), squareSyntheticGlyph(10), squareSyntheticGlyph(20)],
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
expect(source?.getGlyphOutlineAdvance(1)).toBe(600);
|
|
122
|
+
expect(source?.getGlyphOutlineAdvance(2)).toBe(600);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('returns the sentinel rather than throwing for bytes that are not a font', () => {
|
|
126
|
+
expect(
|
|
127
|
+
createGlyphOutlineSourceFromOpenTypeFont(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])),
|
|
128
|
+
).toBeNull();
|
|
129
|
+
expect(createGlyphOutlineSourceFromOpenTypeFont(new Uint8Array(0))).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// CFF outlines are read now. This assertion is the inverse of the one it replaces, and that inversion
|
|
133
|
+
// IS the feature: an .otf used to reject with `unsupported-outlines` and now produces a source.
|
|
134
|
+
it('produces a source from a CFF-outline font, which used to be a stated rejection', () => {
|
|
135
|
+
expect(createGlyphOutlineSourceFromOpenTypeFont(createSyntheticFont({ flavor: 'opentype' }))).not.toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('reads a CFF glyph outline as cubic curves, end to end from the container', () => {
|
|
139
|
+
const charstring = new Uint8Array([139, 139, 21, 149, 139, 149, 149, 139, 149, 8, 14]);
|
|
140
|
+
const font = createSyntheticFont({ flavor: 'opentype', charstrings: [new Uint8Array([14]), charstring] });
|
|
141
|
+
const source = createGlyphOutlineSourceFromOpenTypeFont(font)!;
|
|
142
|
+
const path = createPath();
|
|
143
|
+
expect(source.getGlyphOutline(path, 1)).toBe(true);
|
|
144
|
+
expect(path.commands).toEqual([PathCommand.MOVE_TO, PathCommand.CUBIC_CURVE_TO, PathCommand.CLOSE]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// THE CID CASE, END TO END. Both glyphs call subroutine index -107, and each FD's pool holds a
|
|
148
|
+
// DIFFERENT subroutine — one drawing a horizontal line, one vertical. If both glyphs resolved against
|
|
149
|
+
// one pool they would draw the same shape, which is exactly the plausible-garbage failure the
|
|
150
|
+
// per-glyph binding prevents.
|
|
151
|
+
it('resolves the same subroutine index differently per glyph in a CID font', () => {
|
|
152
|
+
const call = new Uint8Array([139, 139, 21, 32, 10, 14]);
|
|
153
|
+
const font = createSyntheticFont({
|
|
154
|
+
charstrings: [call, call],
|
|
155
|
+
cid: {
|
|
156
|
+
fdSelect: [0, 1],
|
|
157
|
+
pools: [[new Uint8Array([149, 139, 5, 11])], [new Uint8Array([139, 149, 5, 11])]],
|
|
158
|
+
},
|
|
159
|
+
flavor: 'opentype',
|
|
160
|
+
});
|
|
161
|
+
const source = createGlyphOutlineSourceFromOpenTypeFont(font)!;
|
|
162
|
+
const first = createPath();
|
|
163
|
+
const second = createPath();
|
|
164
|
+
expect(source.getGlyphOutline(first, 0)).toBe(true);
|
|
165
|
+
expect(source.getGlyphOutline(second, 1)).toBe(true);
|
|
166
|
+
expect(first.data).not.toEqual(second.data);
|
|
167
|
+
// FD 0 draws +10 on x; FD 1 draws +10 on y, negated into the y-down convention.
|
|
168
|
+
expect(first.data).toEqual([0, -0, 10, -0]);
|
|
169
|
+
expect(second.data).toEqual([0, -0, 0, -10]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// WOFF END TO END. The whole design claim is that it needs no new outline code: unwrap once, and the
|
|
173
|
+
// directory, both flavors and CID all read the rebuilt sfnt without knowing it arrived wrapped.
|
|
174
|
+
it('produces a source from a WOFF, which used to be an unsupported container', () => {
|
|
175
|
+
const woff = encodeSyntheticWoff(
|
|
176
|
+
createSyntheticFont({ glyphs: [emptySyntheticGlyph(), squareSyntheticGlyph(100)] }),
|
|
177
|
+
);
|
|
178
|
+
const source = createGlyphOutlineSourceFromOpenTypeFont(woff)!;
|
|
179
|
+
expect(source).not.toBeNull();
|
|
180
|
+
const path = createPath();
|
|
181
|
+
expect(source.getGlyphOutline(path, 1)).toBe(true);
|
|
182
|
+
expect(path.data).toEqual([0, -0, 100, -0, 100, -100, 0, -100]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('reads a CFF font through the WOFF wrapper, since the wrapper is flavor-agnostic', () => {
|
|
186
|
+
const woff = encodeSyntheticWoff(createSyntheticFont({ flavor: 'opentype' }));
|
|
187
|
+
expect(createGlyphOutlineSourceFromOpenTypeFont(woff)).not.toBeNull();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('still rejects CFF2, which is a different charstring dialect and remains a stated absence', () => {
|
|
191
|
+
const font = createSyntheticFont({ flavor: 'opentype', omitTable: 'CFF ' });
|
|
192
|
+
// Re-add only CFF2, so the font carries charstrings this package deliberately does not read.
|
|
193
|
+
expect(explainOpenTypeFont(font).reason).toBe('missing-required-table');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('returns the sentinel when a required table is missing', () => {
|
|
197
|
+
for (const table of ['cmap', 'head', 'hhea', 'hmtx', 'maxp', 'loca', 'glyf']) {
|
|
198
|
+
expect(createGlyphOutlineSourceFromOpenTypeFont(createSyntheticFont({ omitTable: table }))).toBeNull();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('returns the sentinel for a truncated font rather than reading past the buffer', () => {
|
|
203
|
+
const font = createSyntheticFont({ glyphs: [emptySyntheticGlyph(), squareSyntheticGlyph(100)] });
|
|
204
|
+
expect(createGlyphOutlineSourceFromOpenTypeFont(font.subarray(0, font.length - 8))).toBeNull();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('explainOpenTypeFont', () => {
|
|
209
|
+
it('accepts a font the producer accepts, and the two never disagree', () => {
|
|
210
|
+
const font = createSyntheticFont();
|
|
211
|
+
const explanation = explainOpenTypeFont(font);
|
|
212
|
+
expect(explanation.accepted).toBe(true);
|
|
213
|
+
expect(explanation.reason).toBe('ok');
|
|
214
|
+
expect(createGlyphOutlineSourceFromOpenTypeFont(font)).not.toBeNull();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ALL SEVEN REJECTION REASONS, EACH ASSERTING **BOTH** THE EXPLANATION AND THE PRODUCER ON THE SAME
|
|
218
|
+
// BYTES. Previously only the accept path was cross-checked, so an explanation could have named a reason
|
|
219
|
+
// the producer never failed for — or named one while the producer happily returned a source — and the
|
|
220
|
+
// suite would have stayed green. The two share one parse so they cannot disagree BY CONSTRUCTION, and
|
|
221
|
+
// probably-holds-by-construction is exactly the claim this pins instead of assuming.
|
|
222
|
+
it.each([
|
|
223
|
+
['too-short', new Uint8Array([0, 1, 0, 0])],
|
|
224
|
+
['unrecognized', new Uint8Array(20)],
|
|
225
|
+
['unsupported-container', woff2Bytes()],
|
|
226
|
+
['missing-required-table', createSyntheticFont({ omitTable: 'cmap' })],
|
|
227
|
+
['unsupported-outlines', cff2OnlyFont()],
|
|
228
|
+
['malformed-table', malformedUnitsPerEmFont()],
|
|
229
|
+
])('rejects with reason %s, and the producer returns the sentinel on the same bytes', (reason, bytes) => {
|
|
230
|
+
const explanation = explainOpenTypeFont(bytes);
|
|
231
|
+
expect(explanation.reason).toBe(reason);
|
|
232
|
+
expect(explanation.accepted).toBe(false);
|
|
233
|
+
expect(createGlyphOutlineSourceFromOpenTypeFont(bytes)).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// The seventh reason, `ok`, is the inverse pairing: the explanation accepts AND the producer succeeds.
|
|
237
|
+
it('accepts with reason ok, and the producer returns a source on the same bytes', () => {
|
|
238
|
+
const font = createSyntheticFont();
|
|
239
|
+
expect(explainOpenTypeFont(font).reason).toBe('ok');
|
|
240
|
+
expect(explainOpenTypeFont(font).accepted).toBe(true);
|
|
241
|
+
expect(createGlyphOutlineSourceFromOpenTypeFont(font)).not.toBeNull();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('reports the container it recognized even on success', () => {
|
|
245
|
+
expect(explainOpenTypeFont(createSyntheticFont()).format).toBe('truetype');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('accepts a CFF font now that its charstrings are read', () => {
|
|
249
|
+
const explanation = explainOpenTypeFont(createSyntheticFont({ flavor: 'opentype' }));
|
|
250
|
+
expect(explanation.reason).toBe('ok');
|
|
251
|
+
expect(explanation.accepted).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('names the specific missing table', () => {
|
|
255
|
+
expect(explainOpenTypeFont(createSyntheticFont({ omitTable: 'cmap' })).table).toBe('cmap');
|
|
256
|
+
expect(explainOpenTypeFont(createSyntheticFont({ omitTable: 'hmtx' })).table).toBe('hmtx');
|
|
257
|
+
expect(explainOpenTypeFont(createSyntheticFont({ omitTable: 'loca' })).table).toBe('loca');
|
|
258
|
+
expect(explainOpenTypeFont(createSyntheticFont({ omitTable: 'cmap' })).reason).toBe('missing-required-table');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('reports a missing glyf as a missing table when no charstring table stands in its place', () => {
|
|
262
|
+
const explanation = explainOpenTypeFont(createSyntheticFont({ omitTable: 'glyf' }));
|
|
263
|
+
expect(explanation.reason).toBe('missing-required-table');
|
|
264
|
+
expect(explanation.table).toBe('glyf');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('distinguishes bytes that are no font at all from a font it will not open', () => {
|
|
268
|
+
expect(explainOpenTypeFont(new Uint8Array(20)).reason).toBe('unrecognized');
|
|
269
|
+
// WOFF2 needs Brotli and a table-transform reversal, so it stays a container this package does not
|
|
270
|
+
// open — unlike WOFF, which is read now.
|
|
271
|
+
const woff2 = new Uint8Array(20);
|
|
272
|
+
woff2.set([0x77, 0x4f, 0x46, 0x32]);
|
|
273
|
+
expect(explainOpenTypeFont(woff2).reason).toBe('unsupported-container');
|
|
274
|
+
expect(explainOpenTypeFont(woff2).format).toBe('woff2');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// The decompressor is deliberately not bundled, so an unregistered one is a real outcome with a
|
|
278
|
+
// one-line remedy — distinct from a container needing a different producer entirely.
|
|
279
|
+
it('reports a missing decompressor as its own reason rather than an unsupported container', () => {
|
|
280
|
+
const woff = encodeSyntheticWoff(createSyntheticFont());
|
|
281
|
+
new DataView(woff.buffer).setUint32(44 + 8, 4);
|
|
282
|
+
expect(explainOpenTypeFont(woff).reason).toBe('missing-decompressor');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('reports too-short for bytes below the sfnt header', () => {
|
|
286
|
+
expect(explainOpenTypeFont(new Uint8Array([0, 1, 0, 0])).reason).toBe('too-short');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('counts declared tables against readable ones, localizing a truncation', () => {
|
|
290
|
+
const font = createSyntheticFont();
|
|
291
|
+
const whole = explainOpenTypeFont(font);
|
|
292
|
+
expect(whole.tableCount).toBe(whole.readableTableCount);
|
|
293
|
+
|
|
294
|
+
const truncated = explainOpenTypeFont(font.subarray(0, font.length - 8));
|
|
295
|
+
// The directory still declares every table; fewer of them now fit inside the file.
|
|
296
|
+
expect(truncated.readableTableCount).toBeLessThan(truncated.tableCount);
|
|
297
|
+
});
|
|
298
|
+
});
|