613 lines
19 KiB
TypeScript
613 lines
19 KiB
TypeScript
import { base32 } from '@scure/base';
|
|
import pako from 'pako';
|
|
|
|
export const FILETYPE_NAMES = {
|
|
P: 'PSBT',
|
|
T: 'Transaction',
|
|
J: 'JSON',
|
|
U: 'Unicode Text',
|
|
X: 'Executable',
|
|
B: 'Binary'
|
|
} as const;
|
|
|
|
export const FILETYPES = new Set(Object.keys(FILETYPE_NAMES));
|
|
|
|
export const QR_DATA_CAPACITY = {
|
|
1: {
|
|
L: { 0: 152, 1: 41, 2: 25, 4: 17, 8: 10 },
|
|
M: { 0: 128, 1: 34, 2: 20, 4: 14, 8: 8 },
|
|
Q: { 0: 104, 1: 27, 2: 16, 4: 11, 8: 7 },
|
|
H: { 0: 72, 1: 17, 2: 10, 4: 7, 8: 4 }
|
|
},
|
|
2: {
|
|
L: { 0: 272, 1: 77, 2: 47, 4: 32, 8: 20 },
|
|
M: { 0: 224, 1: 63, 2: 38, 4: 26, 8: 16 },
|
|
Q: { 0: 176, 1: 48, 2: 29, 4: 20, 8: 12 },
|
|
H: { 0: 128, 1: 34, 2: 20, 4: 14, 8: 8 }
|
|
},
|
|
3: {
|
|
L: { 0: 440, 1: 127, 2: 77, 4: 53, 8: 32 },
|
|
M: { 0: 352, 1: 101, 2: 61, 4: 42, 8: 26 },
|
|
Q: { 0: 272, 1: 77, 2: 47, 4: 32, 8: 20 },
|
|
H: { 0: 208, 1: 58, 2: 35, 4: 24, 8: 15 }
|
|
},
|
|
4: {
|
|
L: { 0: 640, 1: 187, 2: 114, 4: 78, 8: 48 },
|
|
M: { 0: 512, 1: 149, 2: 90, 4: 62, 8: 38 },
|
|
Q: { 0: 384, 1: 111, 2: 67, 4: 46, 8: 28 },
|
|
H: { 0: 288, 1: 82, 2: 50, 4: 34, 8: 21 }
|
|
},
|
|
5: {
|
|
L: { 0: 864, 1: 255, 2: 154, 4: 106, 8: 65 },
|
|
M: { 0: 688, 1: 202, 2: 122, 4: 84, 8: 52 },
|
|
Q: { 0: 496, 1: 144, 2: 87, 4: 60, 8: 37 },
|
|
H: { 0: 368, 1: 106, 2: 64, 4: 44, 8: 27 }
|
|
},
|
|
6: {
|
|
L: { 0: 1088, 1: 322, 2: 195, 4: 134, 8: 82 },
|
|
M: { 0: 864, 1: 255, 2: 154, 4: 106, 8: 65 },
|
|
Q: { 0: 608, 1: 178, 2: 108, 4: 74, 8: 45 },
|
|
H: { 0: 480, 1: 139, 2: 84, 4: 58, 8: 36 }
|
|
},
|
|
7: {
|
|
L: { 0: 1248, 1: 370, 2: 224, 4: 154, 8: 95 },
|
|
M: { 0: 992, 1: 293, 2: 178, 4: 122, 8: 75 },
|
|
Q: { 0: 704, 1: 207, 2: 125, 4: 86, 8: 53 },
|
|
H: { 0: 528, 1: 154, 2: 93, 4: 64, 8: 39 }
|
|
},
|
|
8: {
|
|
L: { 0: 1552, 1: 461, 2: 279, 4: 192, 8: 118 },
|
|
M: { 0: 1232, 1: 365, 2: 221, 4: 152, 8: 93 },
|
|
Q: { 0: 880, 1: 259, 2: 157, 4: 108, 8: 66 },
|
|
H: { 0: 688, 1: 202, 2: 122, 4: 84, 8: 52 }
|
|
},
|
|
9: {
|
|
L: { 0: 1856, 1: 552, 2: 335, 4: 230, 8: 141 },
|
|
M: { 0: 1456, 1: 432, 2: 262, 4: 180, 8: 111 },
|
|
Q: { 0: 1056, 1: 312, 2: 189, 4: 130, 8: 80 },
|
|
H: { 0: 800, 1: 235, 2: 143, 4: 98, 8: 60 }
|
|
},
|
|
10: {
|
|
L: { 0: 2192, 1: 652, 2: 395, 4: 271, 8: 167 },
|
|
M: { 0: 1728, 1: 513, 2: 311, 4: 213, 8: 131 },
|
|
Q: { 0: 1232, 1: 364, 2: 221, 4: 151, 8: 93 },
|
|
H: { 0: 976, 1: 288, 2: 174, 4: 119, 8: 74 }
|
|
},
|
|
11: {
|
|
L: { 0: 2592, 1: 772, 2: 468, 4: 321, 8: 198 },
|
|
M: { 0: 2032, 1: 604, 2: 366, 4: 251, 8: 155 },
|
|
Q: { 0: 1440, 1: 427, 2: 259, 4: 177, 8: 109 },
|
|
H: { 0: 1120, 1: 331, 2: 200, 4: 137, 8: 85 }
|
|
},
|
|
12: {
|
|
L: { 0: 2960, 1: 883, 2: 535, 4: 367, 8: 226 },
|
|
M: { 0: 2320, 1: 691, 2: 419, 4: 287, 8: 177 },
|
|
Q: { 0: 1648, 1: 489, 2: 296, 4: 203, 8: 125 },
|
|
H: { 0: 1264, 1: 374, 2: 227, 4: 155, 8: 96 }
|
|
},
|
|
13: {
|
|
L: { 0: 3424, 1: 1022, 2: 619, 4: 425, 8: 262 },
|
|
M: { 0: 2672, 1: 796, 2: 483, 4: 331, 8: 204 },
|
|
Q: { 0: 1952, 1: 580, 2: 352, 4: 241, 8: 149 },
|
|
H: { 0: 1440, 1: 427, 2: 259, 4: 177, 8: 109 }
|
|
},
|
|
14: {
|
|
L: { 0: 3688, 1: 1101, 2: 667, 4: 458, 8: 282 },
|
|
M: { 0: 2920, 1: 871, 2: 528, 4: 362, 8: 223 },
|
|
Q: { 0: 2088, 1: 621, 2: 376, 4: 258, 8: 159 },
|
|
H: { 0: 1576, 1: 468, 2: 283, 4: 194, 8: 120 }
|
|
},
|
|
15: {
|
|
L: { 0: 4184, 1: 1250, 2: 758, 4: 520, 8: 320 },
|
|
M: { 0: 3320, 1: 991, 2: 600, 4: 412, 8: 254 },
|
|
Q: { 0: 2360, 1: 703, 2: 426, 4: 292, 8: 180 },
|
|
H: { 0: 1784, 1: 530, 2: 321, 4: 220, 8: 136 }
|
|
},
|
|
16: {
|
|
L: { 0: 4712, 1: 1408, 2: 854, 4: 586, 8: 361 },
|
|
M: { 0: 3624, 1: 1082, 2: 656, 4: 450, 8: 277 },
|
|
Q: { 0: 2600, 1: 775, 2: 470, 4: 322, 8: 198 },
|
|
H: { 0: 2024, 1: 602, 2: 365, 4: 250, 8: 154 }
|
|
},
|
|
17: {
|
|
L: { 0: 5176, 1: 1548, 2: 938, 4: 644, 8: 397 },
|
|
M: { 0: 4056, 1: 1212, 2: 734, 4: 504, 8: 310 },
|
|
Q: { 0: 2936, 1: 876, 2: 531, 4: 364, 8: 224 },
|
|
H: { 0: 2264, 1: 674, 2: 408, 4: 280, 8: 173 }
|
|
},
|
|
18: {
|
|
L: { 0: 5768, 1: 1725, 2: 1046, 4: 718, 8: 442 },
|
|
M: { 0: 4504, 1: 1346, 2: 816, 4: 560, 8: 345 },
|
|
Q: { 0: 3176, 1: 948, 2: 574, 4: 394, 8: 243 },
|
|
H: { 0: 2504, 1: 746, 2: 452, 4: 310, 8: 191 }
|
|
},
|
|
19: {
|
|
L: { 0: 6360, 1: 1903, 2: 1153, 4: 792, 8: 488 },
|
|
M: { 0: 5016, 1: 1500, 2: 909, 4: 624, 8: 384 },
|
|
Q: { 0: 3560, 1: 1063, 2: 644, 4: 442, 8: 272 },
|
|
H: { 0: 2728, 1: 813, 2: 493, 4: 338, 8: 208 }
|
|
},
|
|
20: {
|
|
L: { 0: 6888, 1: 2061, 2: 1249, 4: 858, 8: 528 },
|
|
M: { 0: 5352, 1: 1600, 2: 970, 4: 666, 8: 410 },
|
|
Q: { 0: 3880, 1: 1159, 2: 702, 4: 482, 8: 297 },
|
|
H: { 0: 3080, 1: 919, 2: 557, 4: 382, 8: 235 }
|
|
},
|
|
21: {
|
|
L: { 0: 7456, 1: 2232, 2: 1352, 4: 929, 8: 572 },
|
|
M: { 0: 5712, 1: 1708, 2: 1035, 4: 711, 8: 438 },
|
|
Q: { 0: 4096, 1: 1224, 2: 742, 4: 509, 8: 314 },
|
|
H: { 0: 3248, 1: 969, 2: 587, 4: 403, 8: 248 }
|
|
},
|
|
22: {
|
|
L: { 0: 8048, 1: 2409, 2: 1460, 4: 1003, 8: 618 },
|
|
M: { 0: 6256, 1: 1872, 2: 1134, 4: 779, 8: 480 },
|
|
Q: { 0: 4544, 1: 1358, 2: 823, 4: 565, 8: 348 },
|
|
H: { 0: 3536, 1: 1056, 2: 640, 4: 439, 8: 270 }
|
|
},
|
|
23: {
|
|
L: { 0: 8752, 1: 2620, 2: 1588, 4: 1091, 8: 672 },
|
|
M: { 0: 6880, 1: 2059, 2: 1248, 4: 857, 8: 528 },
|
|
Q: { 0: 4912, 1: 1468, 2: 890, 4: 611, 8: 376 },
|
|
H: { 0: 3712, 1: 1108, 2: 672, 4: 461, 8: 284 }
|
|
},
|
|
24: {
|
|
L: { 0: 9392, 1: 2812, 2: 1704, 4: 1171, 8: 721 },
|
|
M: { 0: 7312, 1: 2188, 2: 1326, 4: 911, 8: 561 },
|
|
Q: { 0: 5312, 1: 1588, 2: 963, 4: 661, 8: 407 },
|
|
H: { 0: 4112, 1: 1228, 2: 744, 4: 511, 8: 315 }
|
|
},
|
|
25: {
|
|
L: { 0: 10208, 1: 3057, 2: 1853, 4: 1273, 8: 784 },
|
|
M: { 0: 8000, 1: 2395, 2: 1451, 4: 997, 8: 614 },
|
|
Q: { 0: 5744, 1: 1718, 2: 1041, 4: 715, 8: 440 },
|
|
H: { 0: 4304, 1: 1286, 2: 779, 4: 535, 8: 330 }
|
|
},
|
|
26: {
|
|
L: { 0: 10960, 1: 3283, 2: 1990, 4: 1367, 8: 842 },
|
|
M: { 0: 8496, 1: 2544, 2: 1542, 4: 1059, 8: 652 },
|
|
Q: { 0: 6032, 1: 1804, 2: 1094, 4: 751, 8: 462 },
|
|
H: { 0: 4768, 1: 1425, 2: 864, 4: 593, 8: 365 }
|
|
},
|
|
27: {
|
|
L: { 0: 11744, 1: 3514, 2: 2132, 4: 1465, 8: 902 },
|
|
M: { 0: 9024, 1: 2701, 2: 1637, 4: 1125, 8: 692 },
|
|
Q: { 0: 6464, 1: 1933, 2: 1172, 4: 805, 8: 496 },
|
|
H: { 0: 5024, 1: 1501, 2: 910, 4: 625, 8: 385 }
|
|
},
|
|
28: {
|
|
L: { 0: 12248, 1: 3669, 2: 2223, 4: 1528, 8: 940 },
|
|
M: { 0: 9544, 1: 2857, 2: 1732, 4: 1190, 8: 732 },
|
|
Q: { 0: 6968, 1: 2085, 2: 1263, 4: 868, 8: 534 },
|
|
H: { 0: 5288, 1: 1581, 2: 958, 4: 658, 8: 405 }
|
|
},
|
|
29: {
|
|
L: { 0: 13048, 1: 3909, 2: 2369, 4: 1628, 8: 1002 },
|
|
M: { 0: 10136, 1: 3035, 2: 1839, 4: 1264, 8: 778 },
|
|
Q: { 0: 7288, 1: 2181, 2: 1322, 4: 908, 8: 559 },
|
|
H: { 0: 5608, 1: 1677, 2: 1016, 4: 698, 8: 430 }
|
|
},
|
|
30: {
|
|
L: { 0: 13880, 1: 4158, 2: 2520, 4: 1732, 8: 1066 },
|
|
M: { 0: 10984, 1: 3289, 2: 1994, 4: 1370, 8: 843 },
|
|
Q: { 0: 7880, 1: 2358, 2: 1429, 4: 982, 8: 604 },
|
|
H: { 0: 5960, 1: 1782, 2: 1080, 4: 742, 8: 457 }
|
|
},
|
|
31: {
|
|
L: { 0: 14744, 1: 4417, 2: 2677, 4: 1840, 8: 1132 },
|
|
M: { 0: 11640, 1: 3486, 2: 2113, 4: 1452, 8: 894 },
|
|
Q: { 0: 8264, 1: 2473, 2: 1499, 4: 1030, 8: 634 },
|
|
H: { 0: 6344, 1: 1897, 2: 1150, 4: 790, 8: 486 }
|
|
},
|
|
32: {
|
|
L: { 0: 15640, 1: 4686, 2: 2840, 4: 1952, 8: 1201 },
|
|
M: { 0: 12328, 1: 3693, 2: 2238, 4: 1538, 8: 947 },
|
|
Q: { 0: 8920, 1: 2670, 2: 1618, 4: 1112, 8: 684 },
|
|
H: { 0: 6760, 1: 2022, 2: 1226, 4: 842, 8: 518 }
|
|
},
|
|
33: {
|
|
L: { 0: 16568, 1: 4965, 2: 3009, 4: 2068, 8: 1273 },
|
|
M: { 0: 13048, 1: 3909, 2: 2369, 4: 1628, 8: 1002 },
|
|
Q: { 0: 9368, 1: 2805, 2: 1700, 4: 1168, 8: 719 },
|
|
H: { 0: 7208, 1: 2157, 2: 1307, 4: 898, 8: 553 }
|
|
},
|
|
34: {
|
|
L: { 0: 17528, 1: 5253, 2: 3183, 4: 2188, 8: 1347 },
|
|
M: { 0: 13800, 1: 4134, 2: 2506, 4: 1722, 8: 1060 },
|
|
Q: { 0: 9848, 1: 2949, 2: 1787, 4: 1228, 8: 756 },
|
|
H: { 0: 7688, 1: 2301, 2: 1394, 4: 958, 8: 590 }
|
|
},
|
|
35: {
|
|
L: { 0: 18448, 1: 5529, 2: 3351, 4: 2303, 8: 1417 },
|
|
M: { 0: 14496, 1: 4343, 2: 2632, 4: 1809, 8: 1113 },
|
|
Q: { 0: 10288, 1: 3081, 2: 1867, 4: 1283, 8: 790 },
|
|
H: { 0: 7888, 1: 2361, 2: 1431, 4: 983, 8: 605 }
|
|
},
|
|
36: {
|
|
L: { 0: 19472, 1: 5836, 2: 3537, 4: 2431, 8: 1496 },
|
|
M: { 0: 15312, 1: 4588, 2: 2780, 4: 1911, 8: 1176 },
|
|
Q: { 0: 10832, 1: 3244, 2: 1966, 4: 1351, 8: 832 },
|
|
H: { 0: 8432, 1: 2524, 2: 1530, 4: 1051, 8: 647 }
|
|
},
|
|
37: {
|
|
L: { 0: 20528, 1: 6153, 2: 3729, 4: 2563, 8: 1577 },
|
|
M: { 0: 15936, 1: 4775, 2: 2894, 4: 1989, 8: 1224 },
|
|
Q: { 0: 11408, 1: 3417, 2: 2071, 4: 1423, 8: 876 },
|
|
H: { 0: 8768, 1: 2625, 2: 1591, 4: 1093, 8: 673 }
|
|
},
|
|
38: {
|
|
L: { 0: 21616, 1: 6479, 2: 3927, 4: 2699, 8: 1661 },
|
|
M: { 0: 16816, 1: 5039, 2: 3054, 4: 2099, 8: 1292 },
|
|
Q: { 0: 12016, 1: 3599, 2: 2181, 4: 1499, 8: 923 },
|
|
H: { 0: 9136, 1: 2735, 2: 1658, 4: 1139, 8: 701 }
|
|
},
|
|
39: {
|
|
L: { 0: 22496, 1: 6743, 2: 4087, 4: 2809, 8: 1729 },
|
|
M: { 0: 17728, 1: 5313, 2: 3220, 4: 2213, 8: 1362 },
|
|
Q: { 0: 12656, 1: 3791, 2: 2298, 4: 1579, 8: 972 },
|
|
H: { 0: 9776, 1: 2927, 2: 1774, 4: 1219, 8: 750 }
|
|
},
|
|
40: {
|
|
L: { 0: 23648, 1: 7089, 2: 4296, 4: 2953, 8: 1817 },
|
|
M: { 0: 18672, 1: 5596, 2: 3391, 4: 2331, 8: 1435 },
|
|
Q: { 0: 13328, 1: 3993, 2: 2420, 4: 1663, 8: 1024 },
|
|
H: { 0: 10208, 1: 3057, 2: 1852, 4: 1273, 8: 784 }
|
|
}
|
|
} as const;
|
|
|
|
export const ENCODING_NAMES = {
|
|
H: 'HEX',
|
|
Z: 'Zlib compressed',
|
|
'2': 'Base32'
|
|
} as const;
|
|
|
|
export type FileType = keyof typeof FILETYPE_NAMES;
|
|
export type Encoding = keyof typeof ENCODING_NAMES;
|
|
export type Version = keyof typeof QR_DATA_CAPACITY;
|
|
|
|
export type SplitOptions = {
|
|
encoding?: Encoding;
|
|
minSplit?: number;
|
|
maxSplit?: number;
|
|
minVersion?: Version;
|
|
maxVersion?: Version;
|
|
};
|
|
|
|
export type SplitResult = {
|
|
version: Version;
|
|
parts: string[];
|
|
encoding: Encoding;
|
|
};
|
|
|
|
export function isValidVersion(v: number): v is Version {
|
|
// act as a TS type guard but also a runtime check
|
|
|
|
return v in QR_DATA_CAPACITY;
|
|
}
|
|
|
|
export function isValidSplit(s: number) {
|
|
return s >= 1 && s <= 1295;
|
|
}
|
|
|
|
export function validateSplitOptions(opts: SplitOptions) {
|
|
// ensure all split options are valid, filling in defaults as needed
|
|
|
|
const allOpts = {
|
|
minVersion: opts.minVersion ?? 5,
|
|
maxVersion: opts.maxVersion ?? 40,
|
|
minSplit: opts.minSplit ?? 1,
|
|
maxSplit: opts.maxSplit ?? 1295,
|
|
encoding: opts.encoding ?? 'Z'
|
|
} as const;
|
|
|
|
if (
|
|
allOpts.minVersion > allOpts.maxVersion ||
|
|
!isValidVersion(allOpts.minVersion) ||
|
|
!isValidVersion(allOpts.maxVersion)
|
|
) {
|
|
throw new Error('min/max version out of range');
|
|
}
|
|
|
|
if (
|
|
!isValidSplit(allOpts.minSplit) ||
|
|
!isValidSplit(allOpts.maxSplit) ||
|
|
allOpts.minSplit > allOpts.maxSplit
|
|
) {
|
|
throw new Error('min/max split out of range');
|
|
}
|
|
|
|
return allOpts;
|
|
}
|
|
|
|
export function encodeData(raw: Uint8Array, encoding?: Encoding) {
|
|
// return new encoding (if we upgraded) and the
|
|
// characters after encoding (a string)
|
|
// - default is Zlib or if compression doesn't help, base32
|
|
// - returned data can be split, but must be done modX where X provided
|
|
|
|
encoding = encoding ?? 'Z';
|
|
|
|
if (encoding === 'H') {
|
|
return {
|
|
encoding,
|
|
encoded: raw
|
|
.reduce(
|
|
(acc, byte) => acc + byte.toString(16).padStart(2, '0'),
|
|
''
|
|
)
|
|
.toUpperCase(),
|
|
splitMod: 2
|
|
};
|
|
}
|
|
|
|
if (encoding === 'Z') {
|
|
// trial compression, but skip if it embiggens the data
|
|
|
|
const compressed = pako.deflate(raw, { windowBits: -10 });
|
|
|
|
if (compressed.length >= raw.length) {
|
|
encoding = '2';
|
|
} else {
|
|
encoding = 'Z';
|
|
raw = compressed;
|
|
}
|
|
}
|
|
|
|
return {
|
|
encoding,
|
|
// base32 without padding
|
|
encoded: base32.encode(raw).replace(/=*$/, ''),
|
|
splitMod: 8
|
|
};
|
|
}
|
|
|
|
export function versionToChars(v: Version) {
|
|
// return number of **chars** that fit into indicated version QR
|
|
// - assumes L for ECC
|
|
// - assumes alnum encoding
|
|
|
|
if (!isValidVersion(v)) {
|
|
throw new Error('Invalid version');
|
|
}
|
|
|
|
const ecc = 'L';
|
|
const encoding = 2; // alnum
|
|
|
|
return QR_DATA_CAPACITY[v][ecc][encoding];
|
|
}
|
|
|
|
// Fixed-length header
|
|
export const HEADER_LEN = 8;
|
|
|
|
function numQRNeeded(version: Version, length: number, splitMod: number) {
|
|
const baseCap = versionToChars(version) - HEADER_LEN;
|
|
|
|
// adjust capacity to be a multiple of splitMod
|
|
const adjustedCap = baseCap - (baseCap % splitMod);
|
|
|
|
const estimatedCount = Math.ceil(length / adjustedCap);
|
|
|
|
if (estimatedCount === 1) {
|
|
// if it fits in one QR, we're done
|
|
return { count: 1, perEach: length };
|
|
}
|
|
|
|
// the total capacity of our estimated count
|
|
// all but the last QR need to use adjusted capacity to ensure proper split
|
|
const estimatedCap = (estimatedCount - 1) * adjustedCap + baseCap;
|
|
|
|
return {
|
|
count: estimatedCap >= length ? estimatedCount : estimatedCount + 1,
|
|
perEach: adjustedCap
|
|
};
|
|
}
|
|
|
|
function findBestVersion(
|
|
length: number,
|
|
splitMod: number,
|
|
opts: Required<SplitOptions>
|
|
) {
|
|
const options: { version: Version; count: number; perEach: number }[] = [];
|
|
|
|
for (let version = opts.minVersion; version <= opts.maxVersion; version++) {
|
|
const { count, perEach } = numQRNeeded(version, length, splitMod);
|
|
|
|
if (opts.minSplit <= count && count <= opts.maxSplit) {
|
|
options.push({ version, count, perEach });
|
|
}
|
|
}
|
|
|
|
if (!options.length) {
|
|
throw new Error('Cannot make it fit');
|
|
}
|
|
|
|
// pick smallest number of QR, lowest version
|
|
options.sort((a, b) => a.count - b.count || a.version - b.version);
|
|
|
|
return options[0];
|
|
}
|
|
|
|
export function intToBase36(n: number) {
|
|
// convert an integer 0-1295 to two digits of base 36 - 00-ZZ
|
|
|
|
if (n < 0 || n > 1295 || !Number.isInteger(n)) {
|
|
throw new Error('Out of range');
|
|
}
|
|
|
|
return n.toString(36).toUpperCase().padStart(2, '0');
|
|
}
|
|
|
|
export function splitQRs(
|
|
raw: Uint8Array,
|
|
fileType: FileType,
|
|
opts: SplitOptions = {}
|
|
): SplitResult {
|
|
if (!FILETYPES.has(fileType)) {
|
|
throw new Error(`Invalid value for fileType: ${fileType}`);
|
|
}
|
|
|
|
const validatedOpts = validateSplitOptions(opts);
|
|
|
|
const {
|
|
encoding: actualEncoding,
|
|
encoded,
|
|
splitMod
|
|
} = encodeData(raw, validatedOpts.encoding);
|
|
|
|
const { version, count, perEach } = findBestVersion(
|
|
encoded.length,
|
|
splitMod,
|
|
validatedOpts
|
|
);
|
|
|
|
const parts: string[] = [];
|
|
|
|
for (
|
|
let n = 0, offset = 0;
|
|
offset < encoded.length;
|
|
n++, offset += perEach
|
|
) {
|
|
parts.push(
|
|
`B$${actualEncoding}${fileType}` +
|
|
intToBase36(count) +
|
|
intToBase36(n) +
|
|
encoded.slice(offset, offset + perEach)
|
|
);
|
|
}
|
|
|
|
return { version, parts, encoding: actualEncoding };
|
|
}
|
|
|
|
export const ENCODINGS = new Set(Object.keys(ENCODING_NAMES));
|
|
|
|
// Default split options that can be reused across the application
|
|
export const DEFAULT_SPLIT_OPTIONS: Required<SplitOptions> = {
|
|
encoding: 'Z', // Zlib compressed base32 encoding
|
|
minSplit: 1, // minimum number of parts to return
|
|
maxSplit: 1295, // maximum number of parts to return
|
|
minVersion: 5, // minimum QR code version
|
|
maxVersion: 40 // maximum QR code version
|
|
} as const;
|
|
|
|
export type JoinResult = {
|
|
fileType: FileType;
|
|
encoding: Encoding;
|
|
raw: Uint8Array;
|
|
};
|
|
|
|
function joinByteParts(parts: Uint8Array[]) {
|
|
// perf-optimized way to join Uint8Arrays
|
|
|
|
const length = parts.reduce((acc, bytes) => acc + bytes.length, 0);
|
|
|
|
const rv = new Uint8Array(length);
|
|
|
|
let offset = 0;
|
|
for (const bytes of parts) {
|
|
rv.set(bytes, offset);
|
|
offset += bytes.length;
|
|
}
|
|
|
|
return rv;
|
|
}
|
|
|
|
export function decodeData(parts: string[], encoding: Encoding) {
|
|
// decode the parts back into a Uint8Array
|
|
|
|
if (encoding === 'H') {
|
|
return joinByteParts(parts.map((p) => hexToBytes(p)));
|
|
}
|
|
|
|
const bytes = joinByteParts(
|
|
parts.map((p) => {
|
|
const padding = (8 - (p.length % 8)) % 8;
|
|
|
|
return base32.decode(p + '='.repeat(padding));
|
|
})
|
|
);
|
|
|
|
if (encoding === 'Z') {
|
|
return pako.inflate(bytes, { windowBits: -10 });
|
|
}
|
|
|
|
return bytes;
|
|
}
|
|
|
|
export function hexToBytes(hex: string) {
|
|
// convert a hex string to a Uint8Array
|
|
|
|
const match = hex.match(/.{1,2}/g) ?? [];
|
|
|
|
return Uint8Array.from(match.map((byte) => parseInt(byte, 16)));
|
|
}
|
|
|
|
/**
|
|
* Decodes and joins QR code parts back to binary data.
|
|
*
|
|
* @param parts Array of QR code parts
|
|
* @returns Object containing the file type, encoding, and raw binary data.
|
|
*/
|
|
export function joinQRs(parts: string[]): JoinResult {
|
|
const headers = new Set(parts.map((p) => p.slice(0, 6)));
|
|
|
|
if (headers.size !== 1) {
|
|
throw new Error('conflicting/variable filetype/encodings/sizes');
|
|
}
|
|
|
|
const header = [...headers][0];
|
|
|
|
if (header.slice(0, 2) !== 'B$') {
|
|
throw new Error('fixed header not found, expected B$');
|
|
}
|
|
|
|
if (!ENCODINGS.has(header[2])) {
|
|
throw new Error(`bad encoding: ${header[2]}`);
|
|
}
|
|
if (!FILETYPES.has(header[3])) {
|
|
throw new Error(`bad file type: ${header[3]}`);
|
|
}
|
|
|
|
const encoding = header[2] as Encoding;
|
|
const fileType = header[3] as FileType;
|
|
|
|
const numParts = parseInt(header.slice(4, 6), 36);
|
|
|
|
if (numParts < 1) {
|
|
throw new Error('zero parts?');
|
|
}
|
|
|
|
const data = new Map<number, string>();
|
|
|
|
for (const p of parts) {
|
|
const idx = parseInt(p.slice(6, 8), 36);
|
|
|
|
if (idx >= numParts) {
|
|
throw new Error(`got part ${idx} but only expecting ${numParts}`);
|
|
}
|
|
|
|
if (data.has(idx) && data.get(idx) !== p.slice(8)) {
|
|
throw new Error(
|
|
`Duplicate part 0x${idx.toString(16)} has wrong content`
|
|
);
|
|
}
|
|
|
|
data.set(idx, p.slice(8));
|
|
}
|
|
|
|
const orderedParts = [];
|
|
|
|
for (let i = 0; i < numParts; i++) {
|
|
const p = data.get(i);
|
|
|
|
if (!p) {
|
|
throw new Error(`Part ${i} is missing`);
|
|
}
|
|
|
|
orderedParts.push(p);
|
|
}
|
|
|
|
const raw = decodeData(orderedParts, encoding);
|
|
|
|
return { fileType, encoding, raw };
|
|
}
|