@flighthq/font-formats 0.3.1-next.209.43ef1bc
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 +26 -0
- package/dist/openTypeTestHelper.d.ts.map +1 -0
- package/dist/openTypeTestHelper.js +494 -0
- package/dist/openTypeTestHelper.js.map +1 -0
- package/dist/sfntAssembly.d.ts +7 -0
- package/dist/sfntAssembly.d.ts.map +1 -0
- package/dist/sfntAssembly.js +77 -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 +6 -0
- package/dist/woff2GlyfTransform.d.ts.map +1 -0
- package/dist/woff2GlyfTransform.js +104 -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 +112 -0
- package/dist/woffFont.js.map +1 -0
- package/package.json +50 -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/openTypeGlyphOutlineSource.test.ts +298 -0
- package/src/openTypeMetrics.test.ts +103 -0
- package/src/sfntAssembly.test.ts +92 -0
- package/src/sfntTableDirectory.test.ts +64 -0
- package/src/woff2Font.test.ts +125 -0
- package/src/woff2GlyfTransform.test.ts +88 -0
- package/src/woffFont.test.ts +167 -0
|
@@ -0,0 +1,177 @@
|
|
|
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 { cffSubroutineBias, runCffCharstring } from './cffCharstring';
|
|
6
|
+
|
|
7
|
+
function createPath(): Path {
|
|
8
|
+
return { commands: [], data: [], winding: 'evenOdd' };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Charstrings are assembled as raw operator/operand bytes. One-byte operands cover -107..107, which is
|
|
12
|
+
// the whole range these fixtures need, so `n(v)` is the encoding rather than a helper hiding one.
|
|
13
|
+
function n(value: number): number {
|
|
14
|
+
return value + 139;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function run(...bytes: number[]): { ok: boolean; path: Path } {
|
|
18
|
+
const buffer = new Uint8Array(bytes);
|
|
19
|
+
const path = createPath();
|
|
20
|
+
return { ok: runCffCharstring(path, buffer, { end: buffer.length, start: 0 }, [], []), path };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('cffSubroutineBias', () => {
|
|
24
|
+
// The bias depends on the pool size, so it cannot be a constant. An unbiased index selects a
|
|
25
|
+
// real-but-wrong subroutine, which draws plausible garbage rather than failing.
|
|
26
|
+
it('steps at the two thresholds the format defines', () => {
|
|
27
|
+
expect(cffSubroutineBias(0)).toBe(107);
|
|
28
|
+
expect(cffSubroutineBias(1239)).toBe(107);
|
|
29
|
+
expect(cffSubroutineBias(1240)).toBe(1131);
|
|
30
|
+
expect(cffSubroutineBias(33899)).toBe(1131);
|
|
31
|
+
expect(cffSubroutineBias(33900)).toBe(32768);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('runCffCharstring', () => {
|
|
36
|
+
it('writes the winding rather than leaving whatever the caller had', () => {
|
|
37
|
+
// Seeded with the wrong rule for the same reason as the glyf reader's twin test: a fresh path
|
|
38
|
+
// already defaults to nonZero, so only a deliberately wrong seed can show the field is written.
|
|
39
|
+
// `createPath` in this file already seeds the WRONG rule, so every fixture here was positioned
|
|
40
|
+
// to expose this and nothing had asserted on it.
|
|
41
|
+
const { path } = run(n(10), n(20), 21, 14);
|
|
42
|
+
expect(path.winding).toBe('nonZero');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('emits a move and closes on endchar', () => {
|
|
46
|
+
const { ok, path } = run(n(10), n(20), 21, 14);
|
|
47
|
+
expect(ok).toBe(true);
|
|
48
|
+
expect(path.commands).toEqual([PathCommand.MOVE_TO, PathCommand.CLOSE]);
|
|
49
|
+
// y is negated at this seam, matching the glyf reader so both flavors agree with Path's y-down rule.
|
|
50
|
+
expect(path.data).toEqual([10, -20]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('draws relative lines', () => {
|
|
54
|
+
const { path } = run(n(0), n(0), 21, n(50), n(0), n(0), n(60), 5, 14);
|
|
55
|
+
expect(path.commands).toEqual([PathCommand.MOVE_TO, PathCommand.LINE_TO, PathCommand.LINE_TO, PathCommand.CLOSE]);
|
|
56
|
+
expect(path.data).toEqual([0, -0, 50, -0, 50, -60]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('alternates the axis for hlineto and vlineto', () => {
|
|
60
|
+
const { path } = run(n(0), n(0), 21, n(10), n(20), n(30), 6, 14);
|
|
61
|
+
// Starts horizontal, then flips per argument: x+10, y+20, x+30.
|
|
62
|
+
expect(path.data).toEqual([0, -0, 10, -0, 10, -20, 40, -20]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('emits a cubic curve, which is what distinguishes this flavor from glyf', () => {
|
|
66
|
+
const { path } = run(n(0), n(0), 21, n(10), n(0), n(10), n(10), n(0), n(10), 8, 14);
|
|
67
|
+
expect(path.commands).toEqual([PathCommand.MOVE_TO, PathCommand.CUBIC_CURVE_TO, PathCommand.CLOSE]);
|
|
68
|
+
expect(path.data).toEqual([0, -0, 10, -0, 20, -10, 20, -20]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// THE OPTIONAL LEADING WIDTH. Present on the first stack-clearing operator only, detected by argument
|
|
72
|
+
// count rather than assumed. Getting it wrong shifts every coordinate in the glyph by one argument.
|
|
73
|
+
it('drops a leading width argument on the first stack-clearing operator', () => {
|
|
74
|
+
const withWidth = run(n(99), n(10), n(20), 21, 14).path;
|
|
75
|
+
const without = run(n(10), n(20), 21, 14).path;
|
|
76
|
+
expect(withWidth.data).toEqual(without.data);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('does not treat a later extra argument as a width', () => {
|
|
80
|
+
// Second rmoveto: three arguments would be malformed, but the width has already been consumed, so
|
|
81
|
+
// the first two are the move and nothing is silently dropped from the coordinates.
|
|
82
|
+
const { path } = run(n(10), n(20), 21, n(5), n(5), 21, 14);
|
|
83
|
+
expect(path.data.slice(0, 2)).toEqual([10, -20]);
|
|
84
|
+
expect(path.data.slice(2, 4)).toEqual([15, -25]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('opens a new contour on a second move and closes the first', () => {
|
|
88
|
+
const { path } = run(n(0), n(0), 21, n(10), n(0), 5, n(50), n(50), 21, n(10), n(0), 5, 14);
|
|
89
|
+
expect(path.commands.filter((command) => command === PathCommand.MOVE_TO)).toHaveLength(2);
|
|
90
|
+
expect(path.commands.filter((command) => command === PathCommand.CLOSE)).toHaveLength(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// hintmask carries inline data whose length depends on the declared stem count, INCLUDING stems
|
|
94
|
+
// declared implicitly by leaving arguments on the stack. Miscounting desynchronises everything after.
|
|
95
|
+
it('skips the hintmask bytes implied by the stem count', () => {
|
|
96
|
+
// Two stems declared explicitly, then hintmask with its one mask byte, then a line.
|
|
97
|
+
const { ok, path } = run(n(0), n(0), n(10), n(10), 1, n(0), n(0), 21, 19, 0xff, n(10), n(0), 5, 14);
|
|
98
|
+
expect(ok).toBe(true);
|
|
99
|
+
expect(path.commands).toEqual([PathCommand.MOVE_TO, PathCommand.LINE_TO, PathCommand.CLOSE]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('counts stems declared implicitly before a hintmask', () => {
|
|
103
|
+
// Nine implicit stems need two mask bytes; reading one would desynchronise the stream.
|
|
104
|
+
const args = [
|
|
105
|
+
n(0),
|
|
106
|
+
n(0),
|
|
107
|
+
n(1),
|
|
108
|
+
n(1),
|
|
109
|
+
n(2),
|
|
110
|
+
n(2),
|
|
111
|
+
n(3),
|
|
112
|
+
n(3),
|
|
113
|
+
n(4),
|
|
114
|
+
n(4),
|
|
115
|
+
n(5),
|
|
116
|
+
n(5),
|
|
117
|
+
n(6),
|
|
118
|
+
n(6),
|
|
119
|
+
n(7),
|
|
120
|
+
n(7),
|
|
121
|
+
n(8),
|
|
122
|
+
n(8),
|
|
123
|
+
];
|
|
124
|
+
const { ok, path } = run(n(0), n(0), 21, ...args, 19, 0xff, 0xff, n(10), n(0), 5, 14);
|
|
125
|
+
expect(ok).toBe(true);
|
|
126
|
+
expect(path.commands).toEqual([PathCommand.MOVE_TO, PathCommand.LINE_TO, PathCommand.CLOSE]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('calls a local subroutine and returns', () => {
|
|
130
|
+
const subr = new Uint8Array([n(10), n(0), 5, 11]);
|
|
131
|
+
// Bias for a pool of one is 107, so the stored index must be -107 to select entry 0. Pushing 0 here
|
|
132
|
+
// would select entry 107 and fail — which is the bias doing its job.
|
|
133
|
+
const main = new Uint8Array([n(0), n(0), 21, n(-107), 10, 14]);
|
|
134
|
+
const bytes = new Uint8Array(main.length + subr.length);
|
|
135
|
+
bytes.set(main);
|
|
136
|
+
bytes.set(subr, main.length);
|
|
137
|
+
const path = createPath();
|
|
138
|
+
const ok = runCffCharstring(
|
|
139
|
+
path,
|
|
140
|
+
bytes,
|
|
141
|
+
{ end: main.length, start: 0 },
|
|
142
|
+
[{ end: bytes.length, start: main.length }],
|
|
143
|
+
[],
|
|
144
|
+
);
|
|
145
|
+
expect(ok).toBe(true);
|
|
146
|
+
expect(path.commands).toEqual([PathCommand.MOVE_TO, PathCommand.LINE_TO, PathCommand.CLOSE]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns false for a subroutine index the pool does not have', () => {
|
|
150
|
+
expect(run(n(0), n(0), 21, n(50), 10, 14).ok).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('returns false for an unknown operator rather than guessing its arity', () => {
|
|
154
|
+
expect(run(n(0), n(0), 21, 2, 14).ok).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('returns true with an empty path for a charstring that draws nothing', () => {
|
|
158
|
+
const { ok, path } = run(14);
|
|
159
|
+
expect(ok).toBe(true);
|
|
160
|
+
expect(path.commands).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('replaces the previous contents of out rather than appending', () => {
|
|
164
|
+
const buffer = new Uint8Array([n(0), n(0), 21, n(10), n(0), 5, 14]);
|
|
165
|
+
const path = createPath();
|
|
166
|
+
runCffCharstring(path, buffer, { end: buffer.length, start: 0 }, [], []);
|
|
167
|
+
const first = path.commands.length;
|
|
168
|
+
runCffCharstring(path, buffer, { end: buffer.length, start: 0 }, [], []);
|
|
169
|
+
expect(path.commands).toHaveLength(first);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('closes an unterminated contour rather than leaving it open', () => {
|
|
173
|
+
const { ok, path } = run(n(0), n(0), 21, n(10), n(0), 5);
|
|
174
|
+
expect(ok).toBe(true);
|
|
175
|
+
expect(path.commands[path.commands.length - 1]).toBe(PathCommand.CLOSE);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { CFF_OPERATOR_CHARSTRINGS, CFF_OPERATOR_PRIVATE, CFF_OPERATOR_ROS, readCffDict } from './cffDict';
|
|
4
|
+
|
|
5
|
+
function dict(...bytes: number[]): Map<number, number[]> | null {
|
|
6
|
+
const buffer = new Uint8Array(bytes);
|
|
7
|
+
return readCffDict(buffer, 0, buffer.length);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('readCffDict', () => {
|
|
11
|
+
// Operands precede their operator, which is the reverse of most bytecode and the whole reading rule.
|
|
12
|
+
it('assigns the accumulated operands to the operator that follows them', () => {
|
|
13
|
+
expect(dict(139, 17)?.get(CFF_OPERATOR_CHARSTRINGS)).toEqual([0]);
|
|
14
|
+
expect(dict(140, 141, CFF_OPERATOR_PRIVATE)?.get(CFF_OPERATOR_PRIVATE)).toEqual([1, 2]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('starts each operator with a fresh operand list rather than carrying leftovers forward', () => {
|
|
18
|
+
const parsed = dict(140, 17, 141, CFF_OPERATOR_PRIVATE)!;
|
|
19
|
+
expect(parsed.get(CFF_OPERATOR_CHARSTRINGS)).toEqual([1]);
|
|
20
|
+
expect(parsed.get(CFF_OPERATOR_PRIVATE)).toEqual([2]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('reads the one-byte operand range around its zero point', () => {
|
|
24
|
+
expect(dict(32, 17)?.get(17)).toEqual([-107]);
|
|
25
|
+
expect(dict(246, 17)?.get(17)).toEqual([107]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('reads the two-byte positive and negative operand encodings', () => {
|
|
29
|
+
expect(dict(247, 0, 17)?.get(17)).toEqual([108]);
|
|
30
|
+
expect(dict(251, 0, 17)?.get(17)).toEqual([-108]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('reads the sized integer operands', () => {
|
|
34
|
+
expect(dict(28, 0x30, 0x39, 17)?.get(17)).toEqual([12345]);
|
|
35
|
+
expect(dict(29, 0, 1, 0, 0, 17)?.get(17)).toEqual([65536]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('reads a packed real operand, including its exponent form', () => {
|
|
39
|
+
// -2.25 then 0.140625e-3, in the nibble encoding, terminated by 0xf.
|
|
40
|
+
expect(dict(30, 0xe2, 0xa2, 0x5f, 17)?.get(17)).toEqual([-2.25]);
|
|
41
|
+
expect(dict(30, 0x0a, 0x14, 0x0c, 0x3f, 17)?.get(17)).toEqual([0.14e-3]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Byte 12 introduces a second byte; keying escaped operators apart is what keeps a CID marker
|
|
45
|
+
// distinguishable from an ordinary operator with the same low number.
|
|
46
|
+
it('keys an escaped operator apart from the unescaped one', () => {
|
|
47
|
+
const parsed = dict(139, 12, 30)!;
|
|
48
|
+
expect(parsed.has(CFF_OPERATOR_ROS)).toBe(true);
|
|
49
|
+
expect(parsed.has(30)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns the sentinel for a reserved operand byte, which means this is not a DICT', () => {
|
|
53
|
+
expect(dict(22, 17)).toBeNull();
|
|
54
|
+
expect(dict(31, 17)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns the sentinel when the stream ends mid-number rather than reading arbitrary bytes', () => {
|
|
58
|
+
expect(dict(28, 0x30)).toBeNull();
|
|
59
|
+
expect(dict(29, 0, 1)).toBeNull();
|
|
60
|
+
expect(dict(247)).toBeNull();
|
|
61
|
+
expect(dict(30, 0xe2)).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns the sentinel when an escape byte ends the stream', () => {
|
|
65
|
+
expect(dict(139, 12)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { readCffFdSelect } from './cffFdSelect';
|
|
4
|
+
|
|
5
|
+
// Format 0 is one byte per glyph; format 3 is ranges with a trailing sentinel giving the final end.
|
|
6
|
+
function format0(...perGlyph: number[]): Uint8Array {
|
|
7
|
+
return new Uint8Array([0, ...perGlyph]);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function format3(ranges: readonly (readonly [number, number])[], sentinel: number): Uint8Array {
|
|
11
|
+
const bytes = new Uint8Array(3 + ranges.length * 3 + 2);
|
|
12
|
+
const view = new DataView(bytes.buffer);
|
|
13
|
+
view.setUint8(0, 3);
|
|
14
|
+
view.setUint16(1, ranges.length);
|
|
15
|
+
ranges.forEach(([first, fd], index) => {
|
|
16
|
+
view.setUint16(3 + index * 3, first);
|
|
17
|
+
view.setUint8(5 + index * 3, fd);
|
|
18
|
+
});
|
|
19
|
+
view.setUint16(3 + ranges.length * 3, sentinel);
|
|
20
|
+
return bytes;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('readCffFdSelect', () => {
|
|
24
|
+
it('reads a byte-per-glyph mapping', () => {
|
|
25
|
+
expect([...readCffFdSelect(format0(0, 1, 1, 2), 0, 4)!]).toEqual([0, 1, 1, 2]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('expands ranges into a dense per-glyph map', () => {
|
|
29
|
+
// Glyphs 0-1 use FD 0, 2-4 use FD 1, and the sentinel closes the last range at 5.
|
|
30
|
+
expect([
|
|
31
|
+
...readCffFdSelect(
|
|
32
|
+
format3(
|
|
33
|
+
[
|
|
34
|
+
[0, 0],
|
|
35
|
+
[2, 1],
|
|
36
|
+
],
|
|
37
|
+
5,
|
|
38
|
+
),
|
|
39
|
+
0,
|
|
40
|
+
5,
|
|
41
|
+
)!,
|
|
42
|
+
]).toEqual([0, 0, 1, 1, 1]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('reads a table that does not start at byte zero', () => {
|
|
46
|
+
const inner = format0(3, 3);
|
|
47
|
+
const bytes = new Uint8Array(4 + inner.length);
|
|
48
|
+
bytes.set(inner, 4);
|
|
49
|
+
expect([...readCffFdSelect(bytes, 4, 2)!]).toEqual([3, 3]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// A tail left unmapped would default to FD 0, which is a REAL pool belonging to a different font DICT.
|
|
53
|
+
// That is the silent wrong-pool outcome this module exists to prevent, so it is refused.
|
|
54
|
+
it('returns the sentinel when ranges do not cover every glyph, rather than defaulting the tail to FD 0', () => {
|
|
55
|
+
expect(readCffFdSelect(format3([[0, 0]], 3), 0, 5)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns the sentinel for a range running backwards or past the glyph count', () => {
|
|
59
|
+
expect(
|
|
60
|
+
readCffFdSelect(
|
|
61
|
+
format3(
|
|
62
|
+
[
|
|
63
|
+
[0, 0],
|
|
64
|
+
[4, 1],
|
|
65
|
+
],
|
|
66
|
+
2,
|
|
67
|
+
),
|
|
68
|
+
0,
|
|
69
|
+
5,
|
|
70
|
+
),
|
|
71
|
+
).toBeNull();
|
|
72
|
+
expect(readCffFdSelect(format3([[0, 0]], 99), 0, 5)).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns the sentinel for a format it does not read', () => {
|
|
76
|
+
expect(readCffFdSelect(new Uint8Array([7, 0, 0]), 0, 2)).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns the sentinel for a truncated table rather than a partial map', () => {
|
|
80
|
+
expect(readCffFdSelect(format0(0, 1), 0, 5)).toBeNull();
|
|
81
|
+
expect(readCffFdSelect(new Uint8Array([3, 0]), 0, 2)).toBeNull();
|
|
82
|
+
expect(readCffFdSelect(new Uint8Array(0), 0, 1)).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { readCffIndex } from './cffIndex';
|
|
4
|
+
|
|
5
|
+
// INDEXes are built here byte by byte, like every other fixture in this package: offsets are 1-based from
|
|
6
|
+
// the byte before the data block, and that off-by-one is exactly the detail a stub would paper over.
|
|
7
|
+
function buildIndex(entries: readonly string[], offSize = 1): Uint8Array {
|
|
8
|
+
if (entries.length === 0) return new Uint8Array([0, 0]);
|
|
9
|
+
const payload = entries.map((entry) => new TextEncoder().encode(entry));
|
|
10
|
+
const offsets = [1];
|
|
11
|
+
for (const entry of payload) offsets.push(offsets[offsets.length - 1]! + entry.length);
|
|
12
|
+
const bytes = new Uint8Array(3 + offsets.length * offSize + offsets[offsets.length - 1]! - 1);
|
|
13
|
+
const view = new DataView(bytes.buffer);
|
|
14
|
+
view.setUint16(0, entries.length);
|
|
15
|
+
view.setUint8(2, offSize);
|
|
16
|
+
offsets.forEach((offset, index) => {
|
|
17
|
+
for (let byte = 0; byte < offSize; byte += 1) {
|
|
18
|
+
view.setUint8(3 + index * offSize + byte, (offset >> (8 * (offSize - 1 - byte))) & 0xff);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
let cursor = 3 + offsets.length * offSize;
|
|
22
|
+
for (const entry of payload) {
|
|
23
|
+
bytes.set(entry, cursor);
|
|
24
|
+
cursor += entry.length;
|
|
25
|
+
}
|
|
26
|
+
return bytes;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function textOf(bytes: Uint8Array, entry: { end: number; start: number }): string {
|
|
30
|
+
return new TextDecoder().decode(bytes.subarray(entry.start, entry.end));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('readCffIndex', () => {
|
|
34
|
+
it('carves the data into one range per entry', () => {
|
|
35
|
+
const bytes = buildIndex(['alpha', 'be', 'gamma']);
|
|
36
|
+
const index = readCffIndex(bytes, 0)!;
|
|
37
|
+
expect(index.entries).toHaveLength(3);
|
|
38
|
+
expect(index.entries.map((entry) => textOf(bytes, entry))).toEqual(['alpha', 'be', 'gamma']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('reports the byte just past itself, so the next structure can be read without recomputing', () => {
|
|
42
|
+
const bytes = buildIndex(['alpha', 'be']);
|
|
43
|
+
expect(readCffIndex(bytes, 0)?.endOffset).toBe(bytes.byteLength);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// A font with no local subroutines relies on this being a normal answer rather than an error.
|
|
47
|
+
it('reads an empty INDEX as two bytes and no entries', () => {
|
|
48
|
+
const index = readCffIndex(new Uint8Array([0, 0, 0xff]), 0)!;
|
|
49
|
+
expect(index.entries).toEqual([]);
|
|
50
|
+
expect(index.endOffset).toBe(2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('reads multi-byte offsets', () => {
|
|
54
|
+
const bytes = buildIndex(['alpha', 'be'], 3);
|
|
55
|
+
expect(readCffIndex(bytes, 0)!.entries.map((entry) => textOf(bytes, entry))).toEqual(['alpha', 'be']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('preserves a legitimately empty entry rather than dropping it', () => {
|
|
59
|
+
const bytes = buildIndex(['a', '', 'c']);
|
|
60
|
+
const index = readCffIndex(bytes, 0)!;
|
|
61
|
+
expect(index.entries).toHaveLength(3);
|
|
62
|
+
expect(textOf(bytes, index.entries[1]!)).toBe('');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('reads an INDEX that does not start at byte zero', () => {
|
|
66
|
+
const inner = buildIndex(['x']);
|
|
67
|
+
const bytes = new Uint8Array(4 + inner.length);
|
|
68
|
+
bytes.set(inner, 4);
|
|
69
|
+
expect(readCffIndex(bytes, 4)!.entries.map((entry) => textOf(bytes, entry))).toEqual(['x']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns the sentinel when the first offset is not 1, which means these are not its offsets', () => {
|
|
73
|
+
const bytes = buildIndex(['alpha']);
|
|
74
|
+
new DataView(bytes.buffer).setUint8(3, 2);
|
|
75
|
+
expect(readCffIndex(bytes, 0)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns the sentinel for offsets that run backwards', () => {
|
|
79
|
+
const bytes = buildIndex(['alpha', 'be']);
|
|
80
|
+
new DataView(bytes.buffer).setUint8(5, 1);
|
|
81
|
+
expect(readCffIndex(bytes, 0)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns the sentinel for an offset width the format does not define', () => {
|
|
85
|
+
const bytes = buildIndex(['alpha']);
|
|
86
|
+
new DataView(bytes.buffer).setUint8(2, 9);
|
|
87
|
+
expect(readCffIndex(bytes, 0)).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// A truncated INDEX yields ranges pointing at arbitrary bytes, which every consumer downstream would
|
|
91
|
+
// read as real charstrings. The sentinel is the only safe answer.
|
|
92
|
+
it('returns the sentinel for a truncated INDEX rather than ranges into arbitrary bytes', () => {
|
|
93
|
+
const bytes = buildIndex(['alpha', 'be']);
|
|
94
|
+
expect(readCffIndex(bytes.subarray(0, bytes.length - 4), 0)).toBeNull();
|
|
95
|
+
expect(readCffIndex(new Uint8Array([0]), 0)).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { readCffTable } from './cffTable';
|
|
4
|
+
import { createSyntheticFont } from './openTypeTestHelper';
|
|
5
|
+
import { readSfntTableDirectory } from './sfntTableDirectory';
|
|
6
|
+
|
|
7
|
+
function cffOf(options: Parameters<typeof createSyntheticFont>[0] = {}) {
|
|
8
|
+
const font = createSyntheticFont({ flavor: 'opentype', ...options });
|
|
9
|
+
return { font, table: readCffTable(font, readSfntTableDirectory(font)!) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('readCffTable', () => {
|
|
13
|
+
it('reaches the charstrings through the offset the top DICT states', () => {
|
|
14
|
+
const { table } = cffOf({ charstrings: [new Uint8Array([14]), new Uint8Array([14])] });
|
|
15
|
+
expect(table?.charstrings).toHaveLength(2);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('yields an empty subroutine pool when the font declares none, rather than failing', () => {
|
|
19
|
+
// Absent and present-but-empty are identical to the biasing arithmetic, so a caller never has to
|
|
20
|
+
// distinguish them.
|
|
21
|
+
const { table } = cffOf();
|
|
22
|
+
expect(table?.localSubrs).toEqual([]);
|
|
23
|
+
expect(table?.globalSubrs).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns the sentinel when the table is absent', () => {
|
|
27
|
+
const font = createSyntheticFont();
|
|
28
|
+
expect(readCffTable(font, readSfntTableDirectory(font)!)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns the sentinel for a truncated table rather than ranges into arbitrary bytes', () => {
|
|
32
|
+
const { font } = cffOf();
|
|
33
|
+
const directory = readSfntTableDirectory(font)!;
|
|
34
|
+
const shortened = new Map(directory.tables);
|
|
35
|
+
shortened.set('CFF ', { length: 8, offset: directory.tables.get('CFF ')!.offset });
|
|
36
|
+
expect(readCffTable(font, { ...directory, tables: shortened })).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('readCffTable — CID-keyed', () => {
|
|
41
|
+
// Two FDs with DIFFERENT subroutine pools. The point of the whole slab is that glyph 0 and glyph 1
|
|
42
|
+
// resolve the SAME subroutine index to DIFFERENT bytes.
|
|
43
|
+
function cidFont() {
|
|
44
|
+
return createSyntheticFont({
|
|
45
|
+
charstrings: [new Uint8Array([139, 139, 21, 32, 10, 14]), new Uint8Array([139, 139, 21, 32, 10, 14])],
|
|
46
|
+
cid: {
|
|
47
|
+
fdSelect: [0, 1],
|
|
48
|
+
pools: [[new Uint8Array([149, 139, 5, 11])], [new Uint8Array([139, 149, 5, 11])]],
|
|
49
|
+
},
|
|
50
|
+
flavor: 'opentype',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
it('reads a CID font instead of refusing it', () => {
|
|
55
|
+
const font = cidFont();
|
|
56
|
+
const table = readCffTable(font, readSfntTableDirectory(font)!)!;
|
|
57
|
+
expect(table).not.toBeNull();
|
|
58
|
+
expect(table.charstrings).toHaveLength(2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('binds each glyph to its own pool, which is the entire reason this path exists', () => {
|
|
62
|
+
const font = cidFont();
|
|
63
|
+
const table = readCffTable(font, readSfntTableDirectory(font)!)!;
|
|
64
|
+
expect(table.localSubrsByGlyph).not.toBeNull();
|
|
65
|
+
expect(table.localSubrsByGlyph).toHaveLength(2);
|
|
66
|
+
// Different FDs, so different pools — not merely two references to one.
|
|
67
|
+
expect(table.localSubrsByGlyph![0]).not.toBe(table.localSubrsByGlyph![1]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('leaves the single table-wide pool empty, so a reader ignoring CID fails visibly', () => {
|
|
71
|
+
const font = cidFont();
|
|
72
|
+
expect(readCffTable(font, readSfntTableDirectory(font)!)!.localSubrs).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('refuses rather than falling back when FDSelect names an FD the FDArray does not have', () => {
|
|
76
|
+
const font = createSyntheticFont({
|
|
77
|
+
charstrings: [new Uint8Array([14])],
|
|
78
|
+
cid: { fdSelect: [5], pools: [[]] },
|
|
79
|
+
flavor: 'opentype',
|
|
80
|
+
});
|
|
81
|
+
// A fallback to pool 0 would be the wrong-pool outcome wearing a reasonable-looking default.
|
|
82
|
+
expect(readCffTable(font, readSfntTableDirectory(font)!)).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -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
|
+
});
|