Skip to content

Commit 6395cfa

Browse files
JLHwungmathiasbynens
authored andcommitted
perf: front-code the Names table
`Names/index.mjs` stored every character name as a JSON string inside the run-length array shared with the other property maps. Names are sorted by code point and neighbouring names share long prefixes (e.g. `LATIN SMALL LETTER A WITH …`), so the table is now written as text: a first line with the `[gap, length]` run pairs as JSON, followed by one front-coded name per line (a base64 digit for the number of characters shared with the previous name, then the remainder). The new `decode-names.mjs` rebuilds the same `Map<number, string>`, with the same entries in the same order; this was verified against all 35 versions, and a new test checks representative lookups. For Unicode 18.0.0 `Names/index.mjs` shrinks from 158 KB to 123 KB, and the package from 410 KB to 390 KB packed (1.43 MB to 1.39 MB unpacked). Assisted-By: Claude Fable 5.1 Issue: #109
1 parent 62d5371 commit 6395cfa

6 files changed

Lines changed: 118 additions & 13 deletions

File tree

‎index.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,8 @@ const generateData = async (version) => {
353353
'decode-ranges.d.mts',
354354
'mapping-to-symbols.mjs',
355355
'mapping-to-symbols.d.mts',
356+
'decode-names.mjs',
357+
'decode-names.d.mts',
356358
];
357359
for (const file of staticFiles) {
358360
fs.copyFileSync(

‎scripts/encode-ranges.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,4 @@ const encodeRegenerate = (regenerateSet) => {
5757
return encodeDeltas(deltas);
5858
};
5959

60-
export { encodeRanges, encodeRegenerate };
60+
export { base64enc, encodeRanges, encodeRegenerate };

‎scripts/utils.mjs‎

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,30 @@ import { fileURLToPath } from 'node:url';
44
import zlib from 'node:zlib';
55
import jsesc from 'jsesc';
66
import regenerate from 'regenerate';
7-
import { encodeRanges, encodeRegenerate } from './encode-ranges.mjs';
7+
import { base64enc, encodeRanges, encodeRegenerate } from './encode-ranges.mjs';
88

99
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1010

11-
const brotliInline = (data) => {
12-
if (data instanceof Map) {
13-
return `new Map(${brotliInline([...data])})`;
14-
}
15-
const json = JSON.stringify(data);
16-
const brotliBuffer = zlib.brotliCompressSync(json, {
11+
// Brotli-compress `text` and return a JS expression that decompresses it into
12+
// a Buffer at runtime.
13+
const brotliInlineBuffer = (text) => {
14+
const brotliBuffer = zlib.brotliCompressSync(text, {
1715
params: {
1816
[zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY,
1917
[zlib.constants.BROTLI_PARAM_LGWIN]:
2018
zlib.constants.BROTLI_MAX_WINDOW_BITS,
21-
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: Buffer.byteLength(json),
19+
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: Buffer.byteLength(text),
2220
},
2321
});
2422
const str = brotliBuffer.toString('base64');
25-
return `JSON.parse(brotliDecompressSync(Buffer.from('${str}', 'base64')))`;
23+
return `brotliDecompressSync(Buffer.from('${str}', 'base64'))`;
24+
};
25+
26+
const brotliInline = (data) => {
27+
if (data instanceof Map) {
28+
return `new Map(${brotliInline([...data])})`;
29+
}
30+
return `JSON.parse(${brotliInlineBuffer(JSON.stringify(data))})`;
2631
};
2732

2833
const range = (start, stop) => {
@@ -83,6 +88,30 @@ const samePropertyRuns = (codePointProperties) => {
8388
return result;
8489
};
8590

91+
// Front-code the names in the flat runs produced by `samePropertyRuns`.
92+
// See static/decode-names.mjs for the format and the decoder.
93+
const encodeNames = (flatRuns) => {
94+
const runs = [];
95+
const lines = [];
96+
let previous = '';
97+
for (let i = 0; i < flatRuns.length; i += 3) {
98+
runs.push(flatRuns[i], flatRuns[i + 1]);
99+
const name = flatRuns[i + 2];
100+
let shared = 0;
101+
while (
102+
shared < 63 &&
103+
shared < name.length &&
104+
shared < previous.length &&
105+
name.charCodeAt(shared) === previous.charCodeAt(shared)
106+
) {
107+
shared++;
108+
}
109+
lines.push(base64enc[shared] + name.slice(shared));
110+
previous = name;
111+
}
112+
return JSON.stringify(runs) + '\n' + lines.join('\n');
113+
};
114+
86115
// Only write the file if its content has changed.
87116
// Reading files is cheaper than writing files, and most files don’t change between builds.
88117
const writeFileIfChanged = (filePath, content) => {
@@ -304,9 +333,14 @@ const writeFiles = (options) => {
304333
// or `bidi-brackets/index.mjs`
305334
// or `Names/index.mjs`
306335
const flatRuns = samePropertyRuns(auxMap[type]);
307-
const output = `import { brotliDecompressSync } from 'node:zlib';\nimport decodePropertyMap from '../decode-property-map.mjs';\n\nexport default decodePropertyMap(${brotliInline(
308-
flatRuns,
309-
)});\n`;
336+
const output =
337+
type === 'Names'
338+
? `import { brotliDecompressSync } from 'node:zlib';\nimport decodeNames from '../decode-names.mjs';\n\nexport default decodeNames(${brotliInlineBuffer(
339+
encodeNames(flatRuns),
340+
)}.toString());\n`
341+
: `import { brotliDecompressSync } from 'node:zlib';\nimport decodePropertyMap from '../decode-property-map.mjs';\n\nexport default decodePropertyMap(${brotliInline(
342+
flatRuns,
343+
)});\n`;
310344
writeFileIfChanged(path.resolve(dir, 'index.mjs'), output);
311345
writeFileIfChanged(
312346
path.resolve(dir, 'index.d.mts'),

‎static/decode-names.d.mts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Decode a front-coded table of character names into a map from code point
3+
* to name.
4+
*/
5+
declare function decodeNames(input: string): Map<number, string>;
6+
7+
export default decodeNames;

‎static/decode-names.mjs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* This file ships to end users.
3+
* See `encodeNames` in scripts/utils.mjs for the encoder.
4+
*/
5+
6+
const base64enc =
7+
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
8+
9+
/**
10+
* Decode a front-coded table of character names into a map from code point
11+
* to name.
12+
*
13+
* The first line of `input` is a JSON array of `[gap, length]` pairs, one per
14+
* name, describing the run of code points each name applies to: `gap` is the
15+
* number of unnamed code points since the end of the previous run, and
16+
* `length` is the number of consecutive code points sharing the name.
17+
*
18+
* Every following line holds one name, front-coded against the previous
19+
* name: the first character is the base64 digit for the number of leading
20+
* characters shared with the previous name, and the rest is the remainder.
21+
* @param {string} input
22+
*/
23+
const decodeNames = (input) => {
24+
const lines = input.split('\n');
25+
const runs = JSON.parse(lines[0]);
26+
const map = new Map();
27+
let previous = '';
28+
for (let codePoint = 0, i = 1, j = 0; i < lines.length; i++, j += 2) {
29+
const line = lines[i];
30+
const name = previous.slice(0, base64enc.indexOf(line[0])) + line.slice(1);
31+
previous = name;
32+
codePoint += runs[j];
33+
for (const end = codePoint + runs[j + 1]; codePoint < end; codePoint++) {
34+
map.set(codePoint, name);
35+
}
36+
}
37+
return map;
38+
};
39+
40+
export default decodeNames;

‎tests/tests.mjs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,28 @@ suite(`The generated latest Unicode js`, () => {
143143
);
144144
t.assert.snapshot([...map.entries()]);
145145
});
146+
test('Names should decode to the expected names', async (t) => {
147+
const { default: names } = await import(
148+
`../output/unicode-${newest}/Names/index.mjs`
149+
);
150+
const entries = [...names];
151+
t.assert.deepStrictEqual(entries[0], [0, '<control>']);
152+
t.assert.strictEqual(names.get(0x41), 'LATIN CAPITAL LETTER A');
153+
t.assert.strictEqual(names.get(0x4e00), 'CJK Ideograph');
154+
t.assert.strictEqual(names.get(0xac00), 'Hangul Syllable');
155+
t.assert.strictEqual(names.get(0xf900), 'CJK COMPATIBILITY IDEOGRAPH-F900');
156+
t.assert.strictEqual(names.get(0x1f600), 'GRINNING FACE');
157+
t.assert.deepStrictEqual(entries.at(-1), [
158+
0x10fffd,
159+
'Plane 16 Private Use',
160+
]);
161+
// Entries are in code point order.
162+
for (let i = 1; i < entries.length; i++) {
163+
if (entries[i][0] <= entries[i - 1][0]) {
164+
t.assert.fail(`entry ${i} out of order`);
165+
}
166+
}
167+
});
146168
test('every mapping symbols.mjs should match code-points.mjs', async (t) => {
147169
await assertSymbolsMatchCodePoints(t, newest);
148170
});

0 commit comments

Comments
 (0)