Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/core/config/Categories.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
"From Base92",
"To Base85",
"From Base85",
"To Base91",
"From Base91",
"To Base",
"From Base",
"To BCD",
Expand Down
94 changes: 94 additions & 0 deletions src/core/operations/FromBase91.mjs
Original file line number Diff line number Diff line change
@@ -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.<br><br>Example:<br><code>fPNKd</code> becomes <code>test</code>";
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;

94 changes: 94 additions & 0 deletions src/core/operations/ToBase91.mjs
Original file line number Diff line number Diff line change
@@ -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.<br><br>Example:<br><code>test</code> becomes <code>fPNKd</code>";
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;

2 changes: 1 addition & 1 deletion tests/node/tests/nodeApi.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down