Skip to content

Commit f13f84c

Browse files
JLHwungmathiasbynens
authored andcommitted
perf: delta-encode case and decomposition mappings
The `code-points.mjs` of Case_Folding, Simple_Case_Mapping, Special_Casing and Decomposition_Mapping stored the map as compressed JSON `[from, to]` pairs. Mapped code points are almost always close to their keys, so the map is now written as a stream of variable-length integers using the same base64 scheme as `ranges.mjs`: per entry, the zigzag-encoded difference between its key and the previous key, a count, and the zigzag-encoded differences between consecutive mapped code points. A count of 0 marks an entry mapping to a single code point, so files mixing scalar and array values (e.g. Unicode 3.0.1 Case_Folding/E) round-trip exactly. The stream is brotli-compressed as before and decoded by the new `decode-mapping.mjs`; `decode-ranges.mjs` now also exports `decodeDeltas` for it. All 1,699 maps across the 35 versions are identical to the previous output, entry order included. For Unicode 18.0.0, `Simple_Case_Mapping/Uppercase/code-points.mjs` shrinks from 4.1 KB to 1.1 KB, and the package from 390 KB to 378 KB packed (1.39 MB to 1.38 MB unpacked). Assisted-By: Claude Fable 5.1 Issue: #109
1 parent 6395cfa commit f13f84c

7 files changed

Lines changed: 101 additions & 3 deletions

File tree

‎index.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,8 @@ const generateData = async (version) => {
355355
'mapping-to-symbols.d.mts',
356356
'decode-names.mjs',
357357
'decode-names.d.mts',
358+
'decode-mapping.mjs',
359+
'decode-mapping.d.mts',
358360
];
359361
for (const file of staticFiles) {
360362
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 { base64enc, encodeRanges, encodeRegenerate };
60+
export { base64enc, encodeDeltas, encodeRanges, encodeRegenerate };

‎scripts/utils.mjs‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { fileURLToPath } from 'node:url';
44
import zlib from 'node:zlib';
55
import jsesc from 'jsesc';
66
import regenerate from 'regenerate';
7-
import { base64enc, encodeRanges, encodeRegenerate } from './encode-ranges.mjs';
7+
import {
8+
base64enc,
9+
encodeDeltas,
10+
encodeRanges,
11+
encodeRegenerate,
12+
} from './encode-ranges.mjs';
813

914
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1015

@@ -112,6 +117,35 @@ const encodeNames = (flatRuns) => {
112117
return JSON.stringify(runs) + '\n' + lines.join('\n');
113118
};
114119

120+
// Zigzag encoding maps signed integers to unsigned ones: 0 → 0, -1 → 1, 1 → 2,
121+
// -2 → 3, … so that small differences in either direction stay small.
122+
const zigzag = (value) => ((value << 1) ^ (value >> 31)) >>> 0;
123+
124+
// Delta-encode a mapping from code points to code point(s), e.g. a case
125+
// mapping or a decomposition mapping, preserving entry order and whether
126+
// each entry maps to a single code point or to an array.
127+
// See static/decode-mapping.mjs for the format and the decoder.
128+
const encodeMapping = (codePoints) => {
129+
const output = [];
130+
let previousFrom = 0;
131+
for (const [from, to] of codePoints) {
132+
output.push(zigzag(from - previousFrom));
133+
previousFrom = from;
134+
let previous = from;
135+
if (Array.isArray(to)) {
136+
console.assert(to.length > 0, `empty mapping for U+${from.toString(16)}`);
137+
output.push(to.length);
138+
for (const codePoint of to) {
139+
output.push(zigzag(codePoint - previous));
140+
previous = codePoint;
141+
}
142+
} else {
143+
output.push(0, zigzag(to - previous));
144+
}
145+
}
146+
return encodeDeltas(output);
147+
};
148+
115149
// Only write the file if its content has changed.
116150
// Reading files is cheaper than writing files, and most files don’t change between builds.
117151
const writeFileIfChanged = (filePath, content) => {
@@ -258,7 +292,9 @@ const writeFiles = (options) => {
258292
codePointsFileContent = `export default ${jsesc(codePoints)};\n`;
259293
symbolsFileContent = `export default ${jsesc(symbols)};\n`;
260294
} else {
261-
codePointsFileContent = `import { brotliDecompressSync } from 'node:zlib';\n\nexport default ${brotliInline(codePoints)};\n`;
295+
codePointsFileContent = `import { brotliDecompressSync } from 'node:zlib';\nimport decodeMapping from '../../decode-mapping.mjs';\n\nexport default decodeMapping(${brotliInlineBuffer(
296+
encodeMapping(codePoints),
297+
)}.toString());\n`;
262298
// The symbols map is derived from the code points map at runtime
263299
// rather than shipped as a second copy of the same data.
264300
symbolsFileContent = `import codePoints from './code-points.mjs';\nimport mappingToSymbols from '../../mapping-to-symbols.mjs';\n\nexport default mappingToSymbols(codePoints);\n`;

‎static/decode-mapping.d.mts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Decode a delta-encoded mapping from code points to code point(s), e.g. a
3+
* case mapping or a decomposition mapping.
4+
*/
5+
declare function decodeMapping(input: string): Map<number, number | number[]>;
6+
7+
export default decodeMapping;

‎static/decode-mapping.mjs‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* This file ships to end users.
3+
* See `encodeMapping` in scripts/utils.mjs for the encoder.
4+
*/
5+
6+
import { decodeDeltas } from './decode-ranges.mjs';
7+
8+
// Undo zigzag encoding: 0 → 0, 1 → -1, 2 → 1, 3 → -2, …
9+
const unzigzag = (value) => (value >>> 1) ^ -(value & 1);
10+
11+
/**
12+
* Decode a delta-encoded mapping from code points to code point(s), e.g. a
13+
* case mapping or a decomposition mapping.
14+
*
15+
* `input` is a base64 stream of variable-length integers (see
16+
* `decodeDeltas`). Each entry consists of the zigzag-encoded difference
17+
* between its key and the previous key, a count, and then `count` values
18+
* (or one value, if `count` is 0). Each value is the zigzag-encoded difference
19+
* from the previous code point in the entry, starting from the key. A count
20+
* of 0 means the entry maps to a single code point rather than to an array.
21+
* @param {string} input
22+
* @returns {Map<number, number | number[]>}
23+
*/
24+
const decodeMapping = (input) => {
25+
const deltas = decodeDeltas(input);
26+
const map = new Map();
27+
for (let from = 0, i = 0; i < deltas.length;) {
28+
from += unzigzag(deltas[i++]);
29+
const count = deltas[i++];
30+
let codePoint = from;
31+
if (count === 0) {
32+
codePoint += unzigzag(deltas[i++]);
33+
map.set(from, codePoint);
34+
} else {
35+
const codePoints = [];
36+
for (let j = 0; j < count; j++) {
37+
codePoint += unzigzag(deltas[i++]);
38+
codePoints.push(codePoint);
39+
}
40+
map.set(from, codePoints);
41+
}
42+
}
43+
return map;
44+
};
45+
46+
export default decodeMapping;

‎static/decode-ranges.d.mts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,10 @@ declare namespace decodeRanges {
1717
*/
1818
declare function decodeRanges(input: string): decodeRanges.UnicodeRange[];
1919

20+
/**
21+
* Base64 decode variable-length deltas (5/10/15/21-bit).
22+
*/
23+
declare function decodeDeltas(input: string): number[];
24+
25+
export { decodeDeltas };
2026
export default decodeRanges;

‎static/decode-ranges.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,5 @@ const decodeRanges = (input) => {
8282
return ranges;
8383
};
8484

85+
export { decodeDeltas };
8586
export default decodeRanges;

0 commit comments

Comments
 (0)