diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 8b62046e3..c2b2f62c1 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -37,6 +37,8 @@ "From Base92", "To Base85", "From Base85", + "To Base91", + "From Base91", "To Base", "From Base", "To BCD", diff --git a/src/core/operations/FromBase91.mjs b/src/core/operations/FromBase91.mjs new file mode 100644 index 000000000..e47c2ed6a --- /dev/null +++ b/src/core/operations/FromBase91.mjs @@ -0,0 +1,94 @@ +/** + * @author rayane-ara [] + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; + +/** + * From Base91 operation + */ +class FromBase91 extends Operation { + + /** + * FromBase91 constructor + */ + constructor() { + super(); + + this.name = "From Base91"; + this.module = "Default"; + this.description = "Decodes Base91 encoded data back into its original binary format.

Example:
fPNKd becomes test"; + this.infoURL = "https://en.wikipedia.org/wiki/Binary-to-text_encoding"; + this.inputType = "string"; + this.outputType = "byteArray"; + this.args = []; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {byteArray} + */ + run(input, args) { + const TABLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&()*+,./:;<=>?@[]^_`{|}~"'; + + if (TABLE.length !== 91) { + throw new OperationError("Base91 table is invalid (must contain exactly 91 characters)."); + } + + const DECODE_TABLE = {}; + for (let i = 0; i < TABLE.length; i++) { + DECODE_TABLE[TABLE[i]] = i; + } + + let b = 0; // Bit accumulator (buffer) + let n = 0; // Number of bits currently in the buffer + let v = -1; // Pending value waiting for its second character (-1 = none) + const o = []; // Output byte array + + for (let i = 0; i < input.length; i++) { + const c = input[i]; + + // Skip characters that are not part of the Base91 alphabet + if (!(c in DECODE_TABLE)) continue; + + const p = DECODE_TABLE[c]; + + if (v < 0) { + // First character of a pair: store the value and wait for the second + v = p; + } else { + // Second character of a pair: reconstruct the encoded value + v += p * 91; + + // Push the lower 13 bits into the bit buffer + b |= v << n; + n += (v & 8191) > 88 ? 13 : 14; + + // Reset v for the next pair + v = -1; + + // Extract all complete bytes from the buffer + do { + o.push(b & 255); // Extract the lowest 8 bits as a byte + b >>= 8; + n -= 8; + } while (n > 7); + } + } + + // Handle the final leftover character (if the input had an odd length) + if (v > -1) { + o.push((b | v << n) & 255); + } + + return o; + } + +} + +export default FromBase91; + diff --git a/src/core/operations/ToBase91.mjs b/src/core/operations/ToBase91.mjs new file mode 100644 index 000000000..0d1c945f0 --- /dev/null +++ b/src/core/operations/ToBase91.mjs @@ -0,0 +1,94 @@ +/** + * @author rayane-ara [] + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; + +/** + * To Base91 operation + */ +class ToBase91 extends Operation { + + /** + * ToBase91 constructor + */ + constructor() { + super(); + + this.name = "To Base91"; + this.module = "Default"; + this.description = "Encodes binary data into Base91 format. Base91 is an advanced method for encoding binary data as ASCII characters, resulting in a more compact string than Base64.

Example:
test becomes fPNKd"; + this.infoURL = "https://en.wikipedia.org/wiki/Binary-to-text_encoding"; + this.inputType = "byteArray"; + this.outputType = "string"; + this.args = []; + } + + /** + * @param {byteArray} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + // Base91 alphabet: 91 printable ASCII characters + const TABLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&()*+,./:;<=>?@[]^_`{|}~"'; + + if (TABLE.length !== 91) { + throw new OperationError("Base91 table is invalid (must contain exactly 91 characters)."); + } + + let b = 0; // Bit accumulator (buffer) + let n = 0; // Number of bits currently in the buffer + let o = ""; // Output string + + for (let i = 0; i < input.length; i++) { + // Append the current byte to the bit buffer (LSB first) + b |= input[i] << n; + n += 8; + + // We need at least 13 bits to attempt encoding + if (n > 13) { + // Extract the lower 13 bits + let v = b & 8191; // 8191 = 0x1FFF = 2^13 - 1 + + if (v > 88) { + // 13 bits are sufficient to encode this value: + // consume 13 bits from the buffer + b >>= 13; + n -= 13; + } else { + // Value is too small (=< 88): a 14-bit encoding avoids + // wasting range, so we take one extra bit instead + v = b & 16383; // 16383 = 0x3FFF = 2^14 - 1 + b >>= 14; + n -= 14; + } + + // Each value is encoded as exactly 2 characters + o += TABLE[v % 91] + TABLE[Math.floor(v / 91)]; + } + } + + // Handle remaining bits in the buffer after the main loop + if (n) { + // Always emit at least one character for the leftover bits + o += TABLE[b % 91]; + + // Emit a second character only if needed: + // more than 7 leftover bits (i.e. a full byte was split), OR + // the remaining value exceeds the single-character range (> 90) + if (n > 7 || b > 90) { + o += TABLE[Math.floor(b / 91)]; + } + } + + return o; + } + +} + +export default ToBase91; + diff --git a/tests/node/tests/nodeApi.mjs b/tests/node/tests/nodeApi.mjs index 92d4d9911..9db4cef34 100644 --- a/tests/node/tests/nodeApi.mjs +++ b/tests/node/tests/nodeApi.mjs @@ -136,7 +136,7 @@ TestRegister.addApiTests([ it("chef.help: returns multiple results", () => { const result = chef.help("base 64"); - assert.strictEqual(result.length, 13); + assert.strictEqual(result.length, 14); }), it("chef.help: looks in description for matches too", () => {