diff --git a/src/core/Utils.mjs b/src/core/Utils.mjs index eae86374..51f35725 100755 --- a/src/core/Utils.mjs +++ b/src/core/Utils.mjs @@ -1263,6 +1263,18 @@ class Utils { return Utils.gcd(y, x % y); } + /** + * Finds the greatest common divisor of two BigInt numbers. + * + * @author atsiv1 [atsiv1@proton.me] + * @param {BigInt} x + * @param {BigInt} y + * @returns {BigInt} + */ + static gcdBigInt(a, b) { + return b === 0n ? a : Utils.gcdBigInt(b, a % b); + } + /** * Finds the modular inverse of two values. diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index a2bd2d08..f7ade329 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -20,6 +20,8 @@ "From Binary", "To Octal", "From Octal", + "To IEEEBinary", + "From IEEEBinary", "To Base32", "From Base32", "To Base45", diff --git a/src/core/lib/IEEEBinary.mjs b/src/core/lib/IEEEBinary.mjs new file mode 100644 index 00000000..4f5096ce --- /dev/null +++ b/src/core/lib/IEEEBinary.mjs @@ -0,0 +1,389 @@ +/** + * IEEEBinary functions. + * + * Convert between: + * - exact decimal strings ↔ IEEE 754 double-precision (binary64) bits + * using BigInt rational arithmetic and correct Round-to-Nearest-Even (RNE). + * + * @author atsiv1 [atsiv1@proton.me] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import Utils from "../Utils.mjs"; + +// IEEE 754 double-precision constants +const EXP_BITS = 11n; +const MANT_BITS = 52n; +const PRECISION = MANT_BITS + 1n; +const BIAS = (1n << (EXP_BITS - 1n)) - 1n; +const MAX_EXP = (1n << EXP_BITS) - 1n; + +const EXP_BITS_NUM = Number(EXP_BITS); +const MANT_BITS_NUM = Number(MANT_BITS); + +function normaliseInput(input) { + if (input === null || input === undefined) + throw new Error("Invalid decimal number"); + return String(input).trim(); +} + +function safeBigInt(str, context="number") { + try { + return BigInt(str); + } catch { + throw new Error(`Invalid ${context}`); + } +} + +function validateDecimal(input) { + // validates optional-sign base-10 decimal input: integers, decimals with optional digits before or after the decimal point, and scientific notation + if (!/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(input)) + throw new Error("Invalid decimal number"); +} + +function validateBinary64(input) { + input = normaliseInput(input); + + if (!/^[01\s]+$/.test(input)) + throw new Error("Binary64 must contain only 0 and 1"); + + if (input.replace(/\s+/g,"").length !== 64) + throw new Error("Binary64 must be exactly 64 bits"); + + return input.replace(/\s+/g,""); +} + + +/** + * Compute 10^exp as BigInt without using ** on BigInt, to avoid + * environments that try to coerce BigInt to Number. + * + * @param {bigint} exp Non-negative BigInt exponent. + * @returns {bigint} + */ +function pow10BigInt(exp) { + if (exp < 0n) { + throw new RangeError("pow10BigInt expects non-negative exponent"); + } + let e = exp; + let result = 1n; + let base = 10n; + + // fast exponentiation by squaring + while (e > 0n) { + if ((e & 1n) === 1n) { + result *= base; + } + base *= base; + e >>= 1n; + } + return result; +} + +/** + * Converts an exact rational number (num / den) into its decimal string representation. + * + * The result is returned as a string. If the decimal is repeating, the repeating + * part will be enclosed in parentheses (e.g., "0.(3)"). + * + * @param {bigint} num - The numerator of the fraction. + * @param {bigint} den - The denominator of the fraction. + * @returns {string} The decimal representation of the fraction, including repeating decimals in parentheses. + * + * @example + * // returns "0.5" + * fractionToDecimal(1n, 2n); + * + * // returns "0.(3)" + * fractionToDecimal(1n, 3n); + * + * // returns "1.25" + * fractionToDecimal(5n, 4n); + * + * @note For many binary64 values, the repeating cycle can be long, which is + * mathematically correct but may produce large strings. + */ +function fractionToDecimal(num, den) { + const intPart = num / den; + let rem = num % den; + + if (rem === 0n) { + return intPart.toString(); + } + + const seen = new Map(); + const digits = []; + let pos = 0; + + while (rem !== 0n) { + if (seen.has(rem)) { + const p = seen.get(rem); + digits.splice(p, 0, "("); + digits.push(")"); + break; + } + seen.set(rem, pos++); + + rem *= 10n; + digits.push((rem / den).toString()); + rem %= den; + } + + return intPart.toString() + "." + digits.join(""); +} + +/** + * Converts a 64-bit IEEE-754 binary64 representation into its exact + * decimal string. + * + * Note: While binary64 can only represent a subset of all real numbers, + * every representable value has a finite decimal expansion. This function + * uses BigInt arithmetic to retrieve the exact value without the + * precision loss typically encountered in standard floating-point arithmetic. + * + * @param {string} binary64String + * @returns {string} + * + * @example + * // returns "10" + * FromIEEE754Float64("0 10000000010 0100000000000000000000000000000000000000000000000000"); + * + * // returns "7.29999999999999982236431605997495353221893310546875" + * FromIEEE754Float64("0 10000000001 1101001100110011001100110011001100110011001100110011); + */ +export function FromIEEE754Float64(binary64String) { + const bin = validateBinary64(binary64String); + + if (bin.length !== 64) { + throw new Error("Input must be 64 bits."); + } + + const signBit = bin[0]; + const expBits = bin.slice(1, 12); + const mantBits = bin.slice(12); + + const sign = signBit === "1" ? "-" : ""; + const exp = parseInt(expBits, 2); + const mant = BigInt("0b" + mantBits); + + // Special values + if (exp === Number(MAX_EXP)) { + if (mant === 0n) { + return sign + "Infinity"; + } + return "NaN"; + } + + let e; + let m; + + if (exp === 0) { + // Subnormal: exponent is 1 - BIAS, no implicit leading 1 + e = 1n - BIAS - MANT_BITS; + m = mant; + } else { + // Normal: exponent is exp - BIAS, with implicit leading 1 + e = BigInt(exp) - BIAS - MANT_BITS; + m = mant | (1n << MANT_BITS); + } + + // If exponent is non-negative, result is an integer + if (e >= 0n) { + return sign + (m << e).toString(); + } + + // Otherwise, it's a fraction m / 2^(-e) + const den = 1n << (-e); + return sign + fractionToDecimal(m, den); +} + + +/** + * + * Converts a decimal number into its IEEE-754 double-precision + * (binary64) bit representation. + * + * @param {string|number|BigInt} input + * @returns {string} + * + * @example + * // returns "0 10000000000 0000000000000000000000000000000000000000000000000000" + * ToIEEE754Float64("2"); + * + * // returns "0 10000000011 0111010011001100110011001100110011001100110011001101" + * ToIEEE754Float64("23.300000000000000710542735760100185871124267578125"); + */ +export function ToIEEE754Float64(input) { + input = normaliseInput(input); + + const expOnes = "1".repeat(EXP_BITS_NUM); + const mantZeros = "0".repeat(MANT_BITS_NUM); + + // Special values: NaN / ±Infinity + if (/^(NaN)$/i.test(input)) { + return `0 ${expOnes} 1${"0".repeat(MANT_BITS_NUM - 1)}`; + } + + if (/^[+-]?inf(inity)?$/i.test(input)) { + const s = input.startsWith("-") ? "1" : "0"; + return `${s} ${expOnes} ${mantZeros}`; + } + + validateDecimal(input); + + let sign = 0n; + if (input.startsWith("-")) { + sign = 1n; + input = input.slice(1); + } else if (input.startsWith("+")) { + input = input.slice(1); + } + + + let sci = 0n; + const sciMatch = input.match(/^(.*)e([+-]?\d+)$/i); + if (sciMatch) { + input = sciMatch[1]; + sci = safeBigInt(sciMatch[2], "exponent"); + } + + let [intStr, fracStr] = input.split("."); + intStr = (intStr || "0").replace(/_/g, ""); + fracStr = (fracStr || "").replace(/_/g, ""); + + let N = safeBigInt(intStr, "integer part"); + let D = 1n; + + if (fracStr.length > 0) { + const pow10 = pow10BigInt(BigInt(fracStr.length)); + N = N * pow10 + safeBigInt(fracStr, "fractional part"); + D = pow10; + } + + // Apply scientific exponent + if (sci > 0n) { + N *= pow10BigInt(sci); + } else if (sci < 0n) { + D *= pow10BigInt(-sci); + } + + // Zero (preserve sign, including -0) + if (N === 0n) { + const expZero = "0".repeat(EXP_BITS_NUM); + return `${sign} ${expZero} ${mantZeros}`; + } + + // Reduce the fraction + const g = Utils.gcdBigInt(N, D); + N /= g; + D /= g; + + // Compute binary exponent approximation (with fix) + const eN = BigInt(N.toString(2).length - 1); + const eD = BigInt(D.toString(2).length - 1); + let e2 = eN - eD; + + // Guard, Round, Sticky bits + const GRS = 3n; + const totalBits = PRECISION + GRS; + + // Approximate bit-length of normalised N/D: e2 + 1 + const currentBitLength = eN - eD + 1n; + + // Shift so we get 'totalBits' bits of precision in the quotient + let shift = totalBits - currentBitLength; + + let num, den; + + if (shift >= 0n) { + num = N << shift; + den = D; + } else { + num = N; + den = D << (-shift); + } + + let full = num / den; + let rem = num % den; + + // Normalisation Fix + if (full < (1n << (totalBits - 1n))) { + e2 -= 1n; + shift += 1n; + + if (shift >= 0n) { + num <<= 1n; + } else { + den >>= 1n; + } + + full = num / den; + rem = num % den; + } + + const roundMask = (1n << GRS) - 1n; + const roundBits = full & roundMask; + let mant = full >> GRS; + + // Extract G, R, S bits + const G = (roundBits >> 2n) & 1n; + const R = (roundBits >> 1n) & 1n; + const S = ((roundBits & 1n) === 1n || rem !== 0n) ? 1n : 0n; + + let roundUp = false; + + if (G === 1n) { + if (R === 1n || S === 1n) { + // strictly > 0.5 + roundUp = true; + } else if ((mant & 1n) === 1n) { + // exactly 0.5 → round to even + roundUp = true; + } + } + + if (roundUp) mant++; + + // Renormalise if mantissa overflowed + if (mant >= (1n << PRECISION)) { + mant >>= 1n; + e2 += 1n; + } + + // Overflow → Infinity + if (e2 + BIAS >= MAX_EXP) { + return `${sign} ${expOnes} ${mantZeros}`; + } + + // Normal number (exponent >= 1) + if (e2 + BIAS >= 1n) { + const expValue = e2 + BIAS; + const expBits = expValue.toString(2).padStart(EXP_BITS_NUM, "0"); + + // Remove the implicit leading 1: mantissa stores only the 52 explicit bits + const mantBits = (mant & ((1n << MANT_BITS) - 1n)) + .toString(2) + .padStart(MANT_BITS_NUM, "0"); + + return `${sign} ${expBits} ${mantBits}`; + } + + // Subnormal numbers (exponent field = 0) + const minNormalExponentValue = 1n - BIAS; + const subShift = minNormalExponentValue - e2; + + let subMant = mant >> subShift; + + // Subnormal mantissa uses all 52 explicit bits + subMant &= (1n << MANT_BITS) - 1n; + + const expZero = "0".repeat(EXP_BITS_NUM); + + if (subMant === 0n) { + return `${sign} ${expZero} ${mantZeros}`; + } + + const subMantBits = subMant.toString(2).padStart(MANT_BITS_NUM, "0"); + return `${sign} ${expZero} ${subMantBits}`; +} diff --git a/src/core/operations/FromIEEEBinary.mjs b/src/core/operations/FromIEEEBinary.mjs new file mode 100644 index 00000000..7417108f --- /dev/null +++ b/src/core/operations/FromIEEEBinary.mjs @@ -0,0 +1,57 @@ +/** + * @author atsiv1 + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import OperationError from "../errors/OperationError.mjs"; +import Operation from "../Operation.mjs"; +import { FromIEEE754Float64 } from "../lib/IEEEBinary.mjs"; + +/** + * From IEEEBinary operation + */ +class FromIEEEBinary extends Operation { + /** + * FromIEEEBinary constructor + */ + constructor() { + super(); + + this.name = "From IEEEBinary"; + this.module = "Default"; + this.description = ` + Converts a 64-bit IEEE-754 binary64 representation (float64) + into its exact decimal value.

+ Example: 0 10000000010 0100000000000000000000000000000000000000000000000000 + becomes 10.`; + this.infoURL = "https://en.wikipedia.org/wiki/IEEE_754"; + this.inputType = "string"; + this.outputType = "string"; + this.args = []; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + + run(input, args) { + if (input === null || input === undefined) + return ""; + + input = String(input); + + if (input.trim().length === 0) + return ""; + + try { + return FromIEEE754Float64(input); + } catch (err) { + throw new OperationError(err.message); + } +} +} + +export default FromIEEEBinary; diff --git a/src/core/operations/ToIEEEBinary.mjs b/src/core/operations/ToIEEEBinary.mjs new file mode 100644 index 00000000..3faa7fb8 --- /dev/null +++ b/src/core/operations/ToIEEEBinary.mjs @@ -0,0 +1,57 @@ +/** + * @author atsiv1 + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import OperationError from "../errors/OperationError.mjs"; +import Operation from "../Operation.mjs"; +import { ToIEEE754Float64 } from "../lib/IEEEBinary.mjs"; + +/** + * To IEEEBinary operation + */ +class ToIEEEBinary extends Operation { + /** + * ToIEEEBinary constructor + */ + constructor() { + super(); + + this.name = "To IEEEBinary"; + this.module = "Default"; + this.description = ` + Converts a decimal number into IEEE-754 double-precision float64.

+ Example: 2 becomes + 0 10000000000 0000000000000000000000000000000000000000000000000000.`; + this.infoURL = "https://en.wikipedia.org/wiki/IEEE_754"; + this.inputType = "string"; + this.outputType = "string"; + this.args = []; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + if (input === null || input === undefined) + return ""; + + input = String(input); + + if (input.trim().length === 0) + return ""; + + try { + return ToIEEE754Float64(input); + } catch (err) { + throw new OperationError(err.message); + + } +} + +} + +export default ToIEEEBinary; diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index f030349d..4b18124e 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -90,6 +90,7 @@ import "./tests/HaversineDistance.mjs"; import "./tests/Hex.mjs"; import "./tests/Hexdump.mjs"; import "./tests/HKDF.mjs"; +import "./tests/IEEEBinary.mjs"; import "./tests/Image.mjs"; import "./tests/IndexOfCoincidence.mjs"; import "./tests/JA3Fingerprint.mjs"; diff --git a/tests/operations/tests/IEEEBinary.mjs b/tests/operations/tests/IEEEBinary.mjs new file mode 100644 index 00000000..c7f4569c --- /dev/null +++ b/tests/operations/tests/IEEEBinary.mjs @@ -0,0 +1,185 @@ +/** + * IEEE754 Float64 tests. + * + * @author atsiv1 [atsiv1@proton.me] + + * + * @copyright Crown Copyright + * @license Apache-2.0 + */ +import TestRegister from "../../lib/TestRegister.mjs"; + +TestRegister.addTests([ + { + name: "From IEEEBinary: nothing", + input: "", + expectedOutput: "", + recipeConfig: [ + { + op: "From IEEEBinary", + args: [], + }, + ], + }, + { + name: "From IEEEBinary: Zero (positive)", + input: "0000000000000000000000000000000000000000000000000000000000000000", + expectedOutput: "0", + recipeConfig: [ + { + op: "From IEEEBinary", + args: [], + }, + ], + }, + { + name: "From IEEEBinary: Zero (negative)", + input: "1000000000000000000000000000000000000000000000000000000000000000", + expectedOutput: "-0", + recipeConfig: [ + { + op: "From IEEEBinary", + args: [], + }, + ], + }, + { + name: "From IEEEBinary: 4.5", + input: "0100000000010010000000000000000000000000000000000000000000000000", + expectedOutput: "4.5", + recipeConfig: [ + { + op: "From IEEEBinary", + args: [], + }, + ], + }, + { + name: "From IEEEBinary: 0.1", + input: "0011111110111001100110011001100110011001100110011001100110011010", + expectedOutput: + "0.1000000000000000055511151231257827021181583404541015625", + recipeConfig: [ + { + op: "From IEEEBinary", + args: [], + }, + ], + }, + { + name: "From IEEEBinary: Infinity", + input: "0111111111110000000000000000000000000000000000000000000000000000", + expectedOutput: "Infinity", + recipeConfig: [ + { + op: "From IEEEBinary", + args: [], + }, + ], + }, + { + name: "From IEEEBinary: -Infinity", + input: "1111111111110000000000000000000000000000000000000000000000000000", + expectedOutput: "-Infinity", + recipeConfig: [ + { + op: "From IEEEBinary", + args: [], + }, + ], + }, + { + name: "From IEEEBinary: NaN", + input: "0111111111111000000000000000000000000000000000000000000000000000", + expectedOutput: "NaN", + recipeConfig: [ + { + op: "From IEEEBinary", + args: [], + }, + ], + }, + { + name: "To IEEEBinary: nothing", + input: "", + expectedOutput: "", + recipeConfig: [ + { + op: "To IEEEBinary", + args: [], + }, + ], + }, + { + name: "To IEEEBinary: Zero", + input: "0", + expectedOutput: + "0 00000000000 0000000000000000000000000000000000000000000000000000", + recipeConfig: [ + { + op: "To IEEEBinary", + args: [], + }, + ], + }, + { + name: "To IEEEBinary: -0", + input: "-0", + expectedOutput: + "1 00000000000 0000000000000000000000000000000000000000000000000000", + recipeConfig: [ + { + op: "To IEEEBinary", + args: [], + }, + ], + }, + { + name: "To IEEEBinary: 4.5", + input: "4.5", + expectedOutput: + "0 10000000001 0010000000000000000000000000000000000000000000000000", + recipeConfig: [ + { + op: "To IEEEBinary", + args: [], + }, + ], + }, + { + name: "To IEEEBinary: 0.1", + input: "0.1", + expectedOutput: + "0 01111111011 1001100110011001100110011001100110011001100110011010", + recipeConfig: [ + { + op: "To IEEEBinary", + args: [], + }, + ], + }, + { + name: "To IEEEBinary: Infinity", + input: "Infinity", + expectedOutput: + "0 11111111111 0000000000000000000000000000000000000000000000000000", + recipeConfig: [ + { + op: "To IEEEBinary", + args: [], + }, + ], + }, + { + name: "To IEEEBinary: NaN", + input: "NaN", + expectedOutput: + "0 11111111111 1000000000000000000000000000000000000000000000000000", + recipeConfig: [ + { + op: "To IEEEBinary", + args: [], + }, + ], + }, +]);