diff --git a/src/base64.js b/src/base64.js deleted file mode 100644 index 23bd6b6..0000000 --- a/src/base64.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Base64 Encodes an arraybuffer - * @param {ArrayBuffer} arraybuffer - * @returns {string} - */ -export function encode64(arraybuffer) { - const dv = new DataView(arraybuffer); - let binaryString = ""; - - for (let i = 0; i < arraybuffer.byteLength; i++) { - binaryString += String.fromCharCode(dv.getUint8(i)); - } - - return binaryToAscii(binaryString); -} - -/** - * Decodes a base64 string into an arraybuffer - * @param {string} string - * @returns {ArrayBuffer} - */ -export function decode64(string) { - const binaryString = asciiToBinary(string); - const arraybuffer = new ArrayBuffer(binaryString.length); - const dv = new DataView(arraybuffer); - - for (let i = 0; i < arraybuffer.byteLength; i++) { - dv.setUint8(i, binaryString.charCodeAt(i)); - } - - return arraybuffer; -} - -const KEY_STRING = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -/** - * Substitute for atob since it's deprecated in node. - * Does not do any input validation. - * - * @see https://github.com/jsdom/abab/blob/master/lib/atob.js - * - * @param {string} data - * @returns {string} - */ -function asciiToBinary(data) { - if (data.length % 4 === 0) { - data = data.replace(/==?$/, ""); - } - - let output = ""; - let buffer = 0; - let accumulatedBits = 0; - - for (let i = 0; i < data.length; i++) { - buffer <<= 6; - buffer |= KEY_STRING.indexOf(data[i]); - accumulatedBits += 6; - if (accumulatedBits === 24) { - output += String.fromCharCode((buffer & 0xff0000) >> 16); - output += String.fromCharCode((buffer & 0xff00) >> 8); - output += String.fromCharCode(buffer & 0xff); - buffer = accumulatedBits = 0; - } - } - if (accumulatedBits === 12) { - buffer >>= 4; - output += String.fromCharCode(buffer); - } else if (accumulatedBits === 18) { - buffer >>= 2; - output += String.fromCharCode((buffer & 0xff00) >> 8); - output += String.fromCharCode(buffer & 0xff); - } - return output; -} - -/** - * Substitute for btoa since it's deprecated in node. - * Does not do any input validation. - * - * @see https://github.com/jsdom/abab/blob/master/lib/btoa.js - * - * @param {string} str - * @returns {string} - */ -function binaryToAscii(str) { - let out = ""; - for (let i = 0; i < str.length; i += 3) { - /** @type {[number, number, number, number]} */ - const groupsOfSix = [undefined, undefined, undefined, undefined]; - groupsOfSix[0] = str.charCodeAt(i) >> 2; - groupsOfSix[1] = (str.charCodeAt(i) & 0x03) << 4; - if (str.length > i + 1) { - groupsOfSix[1] |= str.charCodeAt(i + 1) >> 4; - groupsOfSix[2] = (str.charCodeAt(i + 1) & 0x0f) << 2; - } - if (str.length > i + 2) { - groupsOfSix[2] |= str.charCodeAt(i + 2) >> 6; - groupsOfSix[3] = str.charCodeAt(i + 2) & 0x3f; - } - for (let j = 0; j < groupsOfSix.length; j++) { - if (typeof groupsOfSix[j] === "undefined") { - out += "="; - } else { - out += KEY_STRING[groupsOfSix[j]]; - } - } - } - return out; -} diff --git a/src/parse.js b/src/parse.js index f0fe2c1..380f387 100644 --- a/src/parse.js +++ b/src/parse.js @@ -1,4 +1,4 @@ -import { decode64 } from './base64.js'; +import { decode85 } from './z85.js'; import { HOLE, NAN, @@ -102,31 +102,26 @@ export function unflatten(parsed, revivers) { } break; - case "Int8Array": - case "Uint8Array": - case "Uint8ClampedArray": - case "Int16Array": - case "Uint16Array": - case "Int32Array": - case "Uint32Array": - case "Float32Array": - case "Float64Array": - case "BigInt64Array": - case "BigUint64Array": { - const TypedArrayConstructor = globalThis[type]; - const base64 = value[1]; - const arraybuffer = decode64(base64); - const typedArray = new TypedArrayConstructor(arraybuffer); - hydrated[index] = typedArray; - break; - } - - case "ArrayBuffer": { - const base64 = value[1]; - const arraybuffer = decode64(base64); - hydrated[index] = arraybuffer; - break; - } + case "Int8Array": + case "Uint8Array": + case "Uint8ClampedArray": + case "Int16Array": + case "Uint16Array": + case "Int32Array": + case "Uint32Array": + case "Float32Array": + case "Float64Array": + case "BigInt64Array": + case "BigUint64Array": { + const TypedArrayConstructor = globalThis[type]; + hydrated[index] = new TypedArrayConstructor(decode85(value[1])); + break; + } + + case "ArrayBuffer": { + hydrated[index] = decode85(value[1]); + break; + } default: throw new Error(`Unknown type ${type}`); diff --git a/src/stringify.js b/src/stringify.js index df291fd..dcc6361 100644 --- a/src/stringify.js +++ b/src/stringify.js @@ -15,7 +15,7 @@ import { POSITIVE_INFINITY, UNDEFINED } from './constants.js'; -import { encode64 } from './base64.js'; +import { encode85 } from './z85.js'; /** * Turn a value into a JSON string that can be parsed with `devalue.parse` @@ -153,20 +153,18 @@ export function stringify(value, reducers) { case "BigUint64Array": { /** @type {import("./types.js").TypedArray} */ const typedArray = thing; - const base64 = encode64(typedArray.buffer); - str = '["' + type + '","' + base64 + '"]'; + str = '["' + type + '","' + encode85(typedArray.buffer) + '"]'; break; } - + case "ArrayBuffer": { /** @type {ArrayBuffer} */ const arraybuffer = thing; - const base64 = encode64(arraybuffer); - - str = `["ArrayBuffer","${base64}"]`; + + str = `["ArrayBuffer","${encode85(arraybuffer)}"]`; break; } - + default: if (!is_plain_object(thing)) { throw new DevalueError( diff --git a/src/z85.js b/src/z85.js new file mode 100644 index 0000000..e64b998 --- /dev/null +++ b/src/z85.js @@ -0,0 +1,62 @@ +const ENCODE = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-:+=^!/*?&<>()[]{}@%$#"; +const DECODE = [-1, 68, -1, 84, 83, 82, 72, -1, 75, 76, 70, 65, -1, 63, 62, 69, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 64, -1, 73, 66, 74, 71, 81, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 77, -1, 78, 67, -1, -1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 79, -1, 80, -1, -1]; +const POW_85 = [1, 85, 7225, 614125, 52200625]; +const POW_256 = [1, 256, 65536, 16777216]; + +/** + * Encodes binary data into a Z85 string. + * @param {ArrayBuffer} data - The binary data to encode + * @returns {string} The Z85 encoded string + */ +export function encode85(data) { + const u8a = new Uint8Array(data); + const length = u8a.length; + const padding = (4 - (length % 4)) % 4; + + let result = '', value = 0; + for (let i = 0; i < length + padding; ++i) { + const isPadding = i >= length; + value = value * 256 + (isPadding ? 0 : u8a[i]); + if ((i + 1) % 4 !== 0) continue; + + if (!isPadding) { + result += ENCODE[Math.floor(value / POW_85[4]) % 85]; + result += ENCODE[Math.floor(value / POW_85[3]) % 85]; + result += ENCODE[Math.floor(value / POW_85[2]) % 85]; + result += ENCODE[Math.floor(value / POW_85[1]) % 85]; + result += ENCODE[value % 85]; + } else { + for (let j = 5; j > padding; --j) { + result += ENCODE[Math.floor(value / POW_85[j - 1]) % 85]; + } + } + value = 0; + } + + return result; +}; + +/** + * Decodes a Z85 string into binary data. + * @param {string} string - The Z85 encoded string + * @returns {ArrayBuffer} The decoded binary data + */ +export function decode85(string) { + const remainder = string.length % 5; + const padding = 5 - (remainder === 0 ? 5 : remainder); + string = string.padEnd(string.length + padding, ENCODE[ENCODE.length - 1]); + const length = string.length; + + let buffer = new Uint8Array((length * 4 / 5) - padding); + let value = 0, char = 0, byte = 0; + for (let i = 0; i < length; ++i) { + value = value * 85 + DECODE[string.charCodeAt(char++) - 32]; + if (char % 5 !== 0) continue; + + for (let j = 3; j >= 0; --j) + buffer[byte++] = Math.floor(value / POW_256[j]) % 256; + value = 0; + } + + return buffer.buffer; +} diff --git a/test/test.js b/test/test.js index ebd4147..eefa6a5 100644 --- a/test/test.js +++ b/test/test.js @@ -164,13 +164,13 @@ const fixtures = { name: 'Uint8Array', value: new Uint8Array([1, 2, 3]), js: 'new Uint8Array([1,2,3])', - json: '[["Uint8Array","AQID"]]' + json: '[["Uint8Array","0rJu"]]' }, { name: 'ArrayBuffer', value: new Uint8Array([1, 2, 3]).buffer, js: 'new Uint8Array([1,2,3]).buffer', - json: '[["ArrayBuffer","AQID"]]' + json: '[["ArrayBuffer","0rJu"]]' } ],