/** * Base spreadsheet calculation error class. */ class CellException extends Error { /** * Brief symbol to show in the cell to signify an error occurred during * evaluation. * * @type {string} */ errorSymbol; /** * @param {string} message - error message * @param {string} errorSymbol - symbol to show in the cell to indicate an error */ constructor(message, errorSymbol='#ERROR') { super(message); this.errorSymbol = errorSymbol; } } /** * Exception thrown when a spreadsheet expression is invalid. */ class CellSyntaxException extends CellException { constructor(message, errorSymbol='#SYNTAX') { super(message, errorSymbol); } } /** * Exception thrown when a problem occurrs during evaluation. */ class CellEvaluationException extends CellException {} /** * Exception thrown when cell dependencies cannot be resolved. */ class CellDependencyException extends CellException { constructor(message, errorSymbol='#REF') { super(message, errorSymbol); } } /** * Expression parsing token types. */ class CellExpressionTokenType { static Name = new this('Name'); static Address = new this('Address'); static NameOrAddress = new this('NameOrAddress'); static String = new this('String'); static Number = new this('Number'); static OpenParen = new this('OpenParen'); static CloseParen = new this('CloseParen'); static Colon = new this('Colon'); static Plus = new this('Plus'); static Minus = new this('Minus'); static Multiply = new this('Multiply'); static Divide = new this('Divide'); static Comma = new this('Comma'); static Semicolon = new this('Semicolon'); static Ampersand = new this('Ampersand'); static LessThan = new this('LessThan'); static LessThanEqual = new this('LessThanEqual'); static GreaterThan = new this('GreaterThan'); static GreaterThanEqual = new this('GreaterThanEqual'); static Equal = new this('Equal'); static Unequal = new this('Unequal'); static Not = new this('Not'); /** @type {string} */ name; constructor(name) { this.name = name; } /** * @returns {boolean} */ isPotentialName() { return this === CellExpressionTokenType.Name || this === CellExpressionTokenType.NameOrAddress; } /** * @returns {boolean} */ isPotentialAddress() { return this === CellExpressionTokenType.Address || this === CellExpressionTokenType.NameOrAddress; } toString() { return this.name; } equals(other) { if (!(other instanceof CellExpressionTokenType)) return false; return other.name === this.name; } } /** * Type of operation for a `CellExpression`. Named functions all fall under * `Function`. */ class CellExpressionOperation { /** Arg is `number` */ static Number = new this('Number'); /** Arg is `string` without quotes */ static String = new this('String'); /** Arg is `boolean` */ static Boolean = new this('Boolean'); /** Arg is `CellAddress` */ static Reference = new this('Reference'); /** Args are start and end `CellAddress`es (e.g. "A5", "C7") */ static Range = new this('Range'); /** Args are two operand `CellExpression`s. */ static Add = new this('Add'); /** Args are two operand `CellExpression`s */ static Subtract = new this('Subtract'); /** Args are two operand `CellExpression`s */ static Multiply = new this('Multiply'); /** Args are two operand `CellExpression`s */ static Divide = new this('Divide'); /** Args are two operand `CellExpression`s. */ static Concatenate = new this('Concatenate'); /** Arg is operand `CellExpression` */ static UnaryMinus = new this('UnaryMinus'); /** Args are two operand `CellExpression`s. */ static GreaterThan = new this('GreaterThan'); /** Args are two operand `CellExpression`s. */ static GreaterThanEqual = new this('GreaterThanEqual'); /** Args are two operand `CellExpression`s. */ static LessThan = new this('LessThan'); /** Args are two operand `CellExpression`s. */ static LessThanEqual = new this('LessThanEqual'); /** Args are two operand `CellExpression`s. */ static Equal = new this('Equal'); /** Args are two operand `CellExpression`s. */ static Unequal = new this('Unequal'); /** Arg is operand `CellExpression`. */ static UnaryNot = new this('UnaryNot'); /** Args are 0+ `CellExpression`s */ static Function = new this('Function'); /** @type {string} */ name; constructor(name) { this.name = name; } toString() { return `${this.constructor.name}.${this.name}`; } equals(other) { if (!(other instanceof CellExpressionOperation)) return false; return other.name == this.name; } } /** * Collection of all calculated cells in a table. Evaluates formulas. */ class CellExpressionSet { /** @type {SpreadsheetGrid} */ #grid; /** * @param {SpreadsheetGrid} grid */ constructor(grid) { this.#grid = grid; } /** * Populates the `outputValue` fields of every cell in the table. Cells * with formulas will attempt to be calculated or populated with error * values. */ calculateCells() { const rowCount = this.#grid.rowCount; const colCount = this.#grid.columnCount; // Make queue of cell addresses with expressions in them /** @type {CellAddress[]} */ var expressionAddressQueue = []; var range = new CellAddressRange(new CellAddress(0, 0), new CellAddress(colCount - 1, rowCount - 1)); for (const address of range.cellsIn(this.#grid)) { const cell = this.#grid.cellAt(address); const value = cell.originalValue; if (value.type != CellValue.TYPE_FORMULA) { cell.outputValue = value; cell.isCalculated = false; continue; } try { const expression = CellExpression.parse(value.formattedValue, address); if (!expression) { throw new CellSyntaxException("Invalid expression"); } cell.parsedExpression = expression; cell.isCalculated = true; expressionAddressQueue.push(address); this.#enqueueFilledBlanks(expression, expressionAddressQueue); } catch (e) { if (e instanceof CellSyntaxException || e instanceof CellEvaluationException) { cell.outputValue = CellValue.fromValue(e); } else { throw e; } } } // Try to evaluate each cell. If one depends on cells not yet calculated, // move it to the back of the queue and try again later. this.#processExpressionQueue(expressionAddressQueue); // Anything left in the queue is a circular reference. this.#processCircularReferences(expressionAddressQueue); } /** * Attempts to evaluate expressions at the given `addresses`. If an * expression has unevaluated references, the expression is moved to the * end of the queue and tried again later. When this method returns, any * elements left in `addresses` can be considered circular references. * * @param {CellAddress[]} addresses - mutable queue of formula addresses */ #processExpressionQueue(addresses) { var requeueCount = 0; while (addresses.length > 0 && requeueCount < addresses.length) { const address = addresses[0]; addresses.splice(0, 1); const cell = this.#grid.cellAt(address); try { const result = this.#evaluate(cell.parsedExpression, address); cell.isCalculated = true; if (result instanceof CellValue) { cell.outputValue = result; requeueCount = 0; } else if (Array.isArray(result)) { if (result.length == 1) { cell.outputValue = result[0]; requeueCount = 0; } else { throw new CellEvaluationException(`Expression resolved to ${result.length} values, single value expected`); } } else { throw new CellEvaluationException(`Expression resolved to ${result && result.constructor ? result.constructor.name : typeof result}, expected CellValue`); } } catch (e) { if (e instanceof CellDependencyException) { // Depends on a value that hasn't been calculated yet addresses.push(address); requeueCount++; } else if (e instanceof CellSyntaxException || e instanceof CellEvaluationException) { cell.outputValue = CellValue.fromValue(e); requeueCount = 0; } else { throw e; } } } } /** * Autofills a formula, transposing the formula to each affected cell and * stored in `parsedExpression`, and each address is queued in `addresses` * for evaluation. * * @param {CellExpression} expression - autofilled formula * @param {CellAddress[]} addresses - mutable address queue */ #enqueueFilledBlanks(expression, addresses) { for (const range of expression.fillRanges ?? []) { for (const filledAddress of range.cellsIn(this.#grid)) { const filledCell = this.#grid.cellAt(filledAddress); if (filledCell.originalValue.type == CellValue.TYPE_BLANK && (!filledCell.outputValue || filledCell.outputValue.type == CellValue.TYPE_BLANK)) { filledCell.parsedExpression = expression.transpose(expression.location, filledAddress); filledCell.isCalculated = true; addresses.push(filledAddress); } } } } /** * Marks all cells at the given addresses as circular references. * * @param {CellAddress[]} addresses - mutable address queue */ #processCircularReferences(addresses) { for (const address of addresses) { const cell = this.#grid.cellAt(address); cell.outputValue = CellValue.fromValue(new CellDependencyException(`Circular reference at ${address.name}`)); } } /** * Evaluates an expression if possible. * * @param {CellExpression} expr - expression to evaluate * @param {CellAddress} address - location of expression * @returns {CellValue|CellValue[]} - results */ #evaluate(expr, address) { const result = this.#preevaluate(expr, address); if (result instanceof CellValue) { // Expression included formatting override. Apply it to value. if (expr.outputType !== null) { return CellValue.fromValue(result.value, expr.outputType ?? result.type, expr.outputDecimals); } } return result; } /** * Evaluates an expression if possible. No custom formatting is applied to * result. * * @param {CellExpression} expr - expression to evaluate * @param {CellAddress} address - location of expression * @returns {CellValue|CellValue[]} - results * @throws {CellException} if evaluation fails for any reason */ #preevaluate(expr, address) { switch (expr.op) { case CellExpressionOperation.Number: case CellExpressionOperation.String: case CellExpressionOperation.Boolean: return expr.arguments[0]; case CellExpressionOperation.Reference: { const refAddress = expr.arguments[0]; const cell = this.#grid.cellAt(refAddress); if (cell === null) { throw new CellEvaluationException(`No cell at ${refAddress.name}`, '#REF'); } if (cell.outputValue === null) { throw new CellDependencyException(`Need calculated value for ${refAddress} to evaluate`); } return cell.outputValue; } case CellExpressionOperation.Range: { const range = expr.arguments[0]; var values = []; for (const rAddress of range.cellsIn(this.#grid)) { const cell = this.#grid.cellAt(rAddress); if (rAddress.equals(address)) continue; const val = this.#grid.outputValueAt(rAddress); if (val === null) { throw new CellDependencyException(`Need calculated value for ${rAddress.name} to evaluate`); } values.push(val); } return values; } case CellExpressionOperation.Add: { const op1 = this.#evaluate(expr.arguments[0], address); const op2 = this.#evaluate(expr.arguments[1], address); return op1.add(op2); } case CellExpressionOperation.Subtract: { const op1 = this.#evaluate(expr.arguments[0], address); const op2 = this.#evaluate(expr.arguments[1], address); return op1.subtract(op2); } case CellExpressionOperation.Multiply: { const op1 = this.#evaluate(expr.arguments[0], address); const op2 = this.#evaluate(expr.arguments[1], address); return op1.multiply(op2); } case CellExpressionOperation.Divide: { const op1 = this.#evaluate(expr.arguments[0], address); const op2 = this.#evaluate(expr.arguments[1], address); return op1.divide(op2); } case CellExpressionOperation.UnaryMinus: { const op = this.#evaluate(expr.arguments[0], address); return CellValue.fromValue(0).subtract(op); } case CellExpressionOperation.GreaterThan: { const op1 = this.#evaluate(expr.arguments[0], address); const op2 = this.#evaluate(expr.arguments[1], address); return op1.gt(op2); } case CellExpressionOperation.GreaterThanEqual: { const op1 = this.#evaluate(expr.arguments[0], address); const op2 = this.#evaluate(expr.arguments[1], address); return op1.gte(op2); } case CellExpressionOperation.LessThan: { const op1 = this.#evaluate(expr.arguments[0], address); const op2 = this.#evaluate(expr.arguments[1], address); return op1.lt(op2); } case CellExpressionOperation.LessThanEqual: { const op1 = this.#evaluate(expr.arguments[0], address); const op2 = this.#evaluate(expr.arguments[1], address); return op1.lte(op2); } case CellExpressionOperation.Equal: { const op1 = this.#evaluate(expr.arguments[0], address); const op2 = this.#evaluate(expr.arguments[1], address); return op1.eq(op2); } case CellExpressionOperation.Unequal: { const op1 = this.#evaluate(expr.arguments[0], address); const op2 = this.#evaluate(expr.arguments[1], address); return op1.neq(op2); } case CellExpressionOperation.UnaryNot: { const op = this.#evaluate(expr.arguments[0], address); return op.not(); } case CellExpressionOperation.Concatenate: { const op1 = this.#evaluate(expr.arguments[0], address); const op2 = this.#evaluate(expr.arguments[1], address); return op1.concatenate(op2); } case CellExpressionOperation.Function: return this.#callFunction(expr.qualifier, expr.arguments, address); } throw new CellSyntaxException(`Unhandled operation ${expr.op.name}`); } /** * Evaluates a named function, e.g. `ABS`, `SUM`, etc. * * @param {string} functionName - function name * @param {Array} args - raw arguments * @param {CellAddress} address - location of the expression * @returns {CellValue} - result * @throws {CellException} if evaluation fails for any reason */ #callFunction(functionName, args, address) { switch (functionName.toUpperCase()) { case 'ABS': return this.#funcAbs(args, address); case 'AND': return this.#funcAnd(args, address); case 'AVERAGE': return this.#funcAverage(args, address); case 'CEILING': return this.#funcCeiling(args, address); case 'EXP': return this.#funcExp(args, address); case 'FLOOR': return this.#funcFloor(args, address); case 'IF': return this.#funcIf(args, address); case 'IFS': return this.#funcIfs(args, address); case 'LN': return this.#funcLn(args, address); case 'LOG': return this.#funcLog(args, address); case 'LOWER': return this.#funcLower(args, address); case 'MAX': return this.#funcMax(args, address); case 'MIN': return this.#funcMin(args, address); case 'MOD': return this.#funcMod(args, address); case 'NOT': return this.#funcNot(args, address); case 'OR': return this.#funcOr(args, address); case 'POWER': return this.#funcPower(args, address); case 'ROUND': return this.#funcRound(args, address); case 'SQRT': return this.#funcSqrt(args, address); case 'SUBSTITUTE': return this.#funcSubstitute(args, address); case 'SUM': return this.#funcSum(args, address); case 'UPPER': return this.#funcUpper(args, address); case 'XOR': return this.#funcXor(args, address); default: throw new CellSyntaxException(`Unknown function "${functionName}"`); } } /** * Checks and evaluates arguments for a numeric function. If successful, * an array of `CellValue`s with numeric values is returned. * * @param {string} functionName - name of the function (for debugging) * @param {number} minArgs - minimum required arguments * @param {number} maxArgs - maximum required arguments * @param {Array} args - raw arguments * @param {CellAddress} address - address of the formula * @returns {CellValue[]} numeric arguments * @throws {CellSyntaxException} if wrong number of arguments is passed * @throws {CellEvaluationException} if an argument does not resolve to a numeric value */ #assertNumericArguments(functionName, minArgs, maxArgs, args, address) { const argCount = args.length; if (argCount < minArgs || argCount > maxArgs) { if (minArgs == maxArgs) { throw new CellSyntaxException(`${functionName}() expects ${minArgs} arguments, got ${argCount}`); } throw new CellSyntaxException(`${functionName}() expects between ${minArgs} and ${maxArgs} arguments, got ${argCount}`); } var out = []; for (const argument of args) { const evaled = this.#evaluate(argument, address); if (!(evaled instanceof CellValue) || !evaled.isNumeric()) { throw new CellEvaluationException(`${functionName}() expects numeric arguments`); } out.push(evaled); } return out; } /** * Evaluates and flattens numeric arguments. For functions that can take * arbitrary numbers of values including whole cell ranges. * * @param {string} functionName - function name (for debugging) * @param {Array} args - raw arguments * @param {CellAddress} address - location of expression * @param {boolean} errorOnNonnumeric - whether to throw an exception if * non-numeric arguments are encountered, otherwise they're skipped silently * @returns {CellValue[]} flattened array of numeric values */ #flattenedNumericArguments(functionName, args, address, errorOnNonnumeric=true) { var flattened = []; for (const argument of args) { const evaled = this.#evaluate(argument, address); if (evaled instanceof CellValue) { if (!evaled.isNumeric()) { if (errorOnNonnumeric) { throw new CellEvaluationException(`${functionName} requires numeric arguments`); } continue; } flattened.push(evaled); } else if (Array.isArray(evaled)) { const arr = evaled; for (const arrayArgument of arr) { if (arrayArgument instanceof CellValue) { if (!arrayArgument.isNumeric()) { if (errorOnNonnumeric) { throw new CellEvaluationException(`${functionName} requires numeric arguments`); } continue; } flattened.push(arrayArgument); } } } } return flattened; } /** * `ABS(value)` - absolute value of a numeric argument * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcAbs(args, address) { const arg = this.#assertNumericArguments('ABS', 1, 1, args, address)[0]; if (arg.value < 0.0) { return CellValue.fromValue(0).subtract(arg); } return arg; } /** * `AND(arg, arg, ..., arg)` - Boolean AND of any number of Boolean arguments * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcAnd(args, address) { if (args.length == 0) { throw new CellEvaluationException("AND requires one or more arguments"); } const values = args.map((arg) => this.#evaluate(arg, address)); for (const value of values) { const result = value.booleanValue(); if (result === null) { throw new CellEvaluationException("AND requires boolean arguments"); } if (!result) return CellValue.fromValue(false); } return CellValue.fromValue(true); } /** * `AVERAGE(arg, arg, ..., arg)` - Averages values. Non-numeric values are * omitted from the calculation. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcAverage(args, address) { var sum = CellValue.fromValue(0); var count = 0; for (const arg of args) { const val = this.#evaluate(arg, address); if (Array.isArray(val)) { for (const elem of val) { if (!elem.isNumeric()) continue; sum = sum.add(elem); count++; } } else if (val.isNumeric()) { sum = sum.add(val); count++; } } return (count > 0) ? sum.divide(CellValue.fromValue(count)) : CellValue.fromValue(0); } /** * `CEILING(value)` - Ceiling of a numeric value. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcCeiling(args, address) { const arg = this.#assertNumericArguments('CEILING', 1, 1, args, address)[0]; const newValue = Math.ceil(arg.value); return CellValue.fromValue(newValue, arg.type); } /** * `EXP(value)` - Computes _e_ raised to the given exponent. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcExp(args, address) { const arg = this.#assertNumericArguments('EXP', 1, 1, args, address)[0]; const newValue = Math.exp(arg.value); return CellValue.fromValue(newValue, arg.type); } /** * `FLOOR(value)` - Numeric value rounded down. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcFloor(args, address) { const arg = this.#assertNumericArguments('FLOOR', 1, 1, args, address)[0]; const newValue = Math.floor(arg.value); return CellValue.fromValue(newValue, arg.type); } /** * `IF(test, trueVal, falseVal)` - Conditional test. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcIf(args, address) { if (args.length != 3) { throw new CellEvaluationException("IF expects three arguments"); } const evaled = args.map((arg) => this.#evaluate(arg, address)); const test = evaled[0].booleanValue(); if (test === null) { throw new CellEvaluationException("IF expects a boolean for the first argument"); } if (test) { return evaled[1]; } else { return evaled[2]; } } /** * `IFS(test1, result1, test2, result2, ..., fallbackResult)` - Multiple if * statement. Takes 3 or more arguments of an odd number consisting of pairs * of a Boolean test followed by the value to return if true. The very last * value is the fallback value to return if none of the Boolean tests are true. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcIfs(args, address) { if (args.length < 3) { throw new CellEvaluationException("IFS expects at least 3 arguments"); } if ((args.length % 2) != 1) { throw new CellEvaluationException("IFS expects an odd number of arguments"); } const evaled = args.map((arg) => this.#evaluate(arg, address)); for (var i = 0; i < evaled.length - 1; i += 2) { const test = evaled[i].booleanValue(); if (test === null) { throw new CellEvaluationException(`IFS expects a boolean for argument ${i + 1}`); } if (test) { return evaled[i + 1]; } } return evaled[evaled.length - 1]; } /** * `LN(value)` - Natural log. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcLn(args, address) { const arg = this.#assertNumericArguments('LN', 1, 1, args, address)[0]; const newValue = Math.log(arg.value); return CellValue.fromValue(newValue, arg.type); } /** * `LOG(value, [base])` - Logarithm. Base is 10 if not provided. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcLog(args, address) { const evaled = this.#assertNumericArguments('LOG', 1, 2, args, address); const exponent = evaled[0]; const base = (evaled.length > 1) ? evaled[1].value : 10.0; const newValue = Math.log(exponent.value) / Math.log(base); return CellValue.fromValue(newValue, exponent.type); } /** * `LOWER(text)` - Lowercase version of a string. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcLower(args, address) { if (args.length != 1) { throw new CellEvaluationException("LOWER requires one argument"); } const evaled = args.map((arg) => this.#evaluate(arg, address)); const s = evaled[0].stringValue(true); if (s === null) { throw new CellEvaluationException("LOWER requires one string argument"); } return CellValue.fromValue(s.toLowerCase()); } /** * `MAX(arg, arg, ... arg)` - Returns the maximum numeric value. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcMax(args, address) { var maxValue = null; const flattened = this.#flattenedNumericArguments('MAX', args, address); if (flattened.length == 0) { throw new CellEvaluationException("MAX requires at least one numeric argument"); } for (const argument of flattened) { if (maxValue === null || argument.value > maxValue.value) { maxValue = argument; } } return maxValue ?? CellValue.fromValue(0); } /** * `MIN(arg, arg, ... arg)` - Returns the minimum numeric value. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcMin(args, address) { var minValue = null; const flattened = this.#flattenedNumericArguments('MIN', args, address); if (flattened.length == 0) { throw new CellEvaluationException("MIN requires at least one numeric argument"); } for (const argument of flattened) { if (minValue === null || argument.value < minValue.value) { minValue = argument; } } return minValue ?? CellValue.fromValue(0); } /** * `MOD(value, divisor)` - Returns the remainder after a division. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcMod(args, address) { if (args.length != 2) { throw new CellEvaluationException("MOD requires two numeric arguments"); } const values = args.map((arg) => this.#evaluate(arg, address)); return values[0].modulo(values[1]); } /** * `NOT(value)` - Boolean NOT of a Boolean value. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcNot(args, address) { if (args.length != 1) { throw new CellEvaluationException("NOT expects one argument"); } const evaled = args.map((arg) => this.#evaluate(arg, address)); const b = evaled[0].booleanValue(); if (b === null) { throw new CellEvaluationException("NOT expects boolean argument"); } return CellValue.fromValue(!b); } /** * `OR(arg, arg, ... arg)` - Boolean OR of one or more Boolean arguments. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcOr(args, address) { if (args.length == 0) { throw new CellEvaluationException("OR requires one or more arguments"); } const values = args.map((arg) => this.#evaluate(arg, address)); for (const value of values) { const result = value.booleanValue(); if (result === null) { throw new CellEvaluationException("OR requires boolean arguments"); } if (result) return CellValue.fromValue(true); } return CellValue.fromValue(false); } /** * `POWER(base, exponent)` - Raises a base to an exponent. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcPower(args, address) { const evaled = this.#assertNumericArguments('POWER', 2, 2, args, address); const base = evaled[0]; const exp = evaled[1]; const val = Math.pow(base.value, exp.value); return CellValue.fromValue(val, base.type); } /** * `ROUND(value, [places])` - Rounds a number, optionally to the given number * of digits after the decimal place. Negative places round to the nearest * 10, 100, 1000, etc. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcRound(args, address) { const evaled = this.#assertNumericArguments('ROUND', 1, 2, args, address); const val = evaled[0]; const places = evaled.length > 1 ? evaled[1].value : 0; const divider = Math.pow(10.0, places); const newValue = Math.round(val.value * divider) / divider; return CellValue.fromValue(newValue, val.type); } /** * `SQRT(value)` - Square root. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcSqrt(args, address) { const arg = this.#assertNumericArguments('SQRT', 1, 1, args, address)[0]; const val = arg.numericValue(); return CellValue.fromValue(Math.sqrt(val)); } /** * `SUBSTITUTE(text, pattern, replacement)` - Substitutes all occurrences of * `pattern` within `text` with `replacement`. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcSubstitute(args, address) { if (args.length != 3) { throw new CellEvaluationException("SUBSTITUTE expects 3 string arguments"); } const values = args.map((arg) => this.#evaluate(arg, address)); const text = values[0].stringValue(); const search = values[1].stringValue(); const replace = values[2].stringValue(); if (text === null || search === null || replace === null) { throw new CellEvaluationException("SUBSTITUTE expects 3 string arguments"); } const result = text.replace(new RegExp(MDUtils.escapeRegex(search), 'gi'), replace); return CellValue.fromValue(result); } /** * `SUM(arg, arg, ... arg)` - Calculates the sum of numeric arguments. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcSum(args, address) { var sum = CellValue.fromValue(0); for (const arg of args) { const val = this.#evaluate(arg, address); if (Array.isArray(val)) { for (const elem of val) { if (elem.isNumeric()) sum = sum.add(elem); } } else if (val.isNumeric()) { sum = sum.add(val); } } return sum; } /** * `UPPER(text)` - Uppercase of a text argument. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcUpper(args, address) { if (args.length != 1) { throw new CellEvaluationException("UPPER requires one argument"); } const evaled = args.map((arg) => this.#evaluate(arg, address)); const s = evaled[0].stringValue(true); if (s === null) { throw new CellEvaluationException("UPPER requires one string argument"); } return CellValue.fromValue(s.toUpperCase()); } /** * `XOR(arg, arg, ... arg)` - Boolean XOR of one or more Boolean arguments. * * @param {Array} args - raw arguments * @param {CellAddress} address - expression location * @returns {CellValue} - result */ #funcXor(args, address) { if (args.length == 0) { throw new CellEvaluationException("XOR requires one or more arguments"); } const values = args.map((arg) => this.#evaluate(arg, address)); var result = null; for (const value of values) { const b = value.booleanValue(); if (b === null) { throw new CellEvaluationException("XOR requires boolean arguments"); } result = (result === null) ? b : (result ^ b); } return CellValue.fromValue(result != 0); } } /** * A spreadsheet formula expression. Evaluation is done by `CellExpressionSet`. */ class CellExpression { /** * Operation. * @type {CellExpressionOperation} */ op; /** * Mixed array of `CellValue`, `CellAddress`, `CellAddressRange`, * `CellExpression`, perhaps more. * @type {Array} */ arguments; /** * For `Function`, the function name. * @type {string|null} */ qualifier; /** * Optional format override. One of `number`, `currency`, `percent`. * @type {string|null} */ outputType = null; /** * Optional decimal place formatting override. * @type {number|null} */ outputDecimals = null; /** * Address ranges to copy this expression into for any blank cells. Used * by formulas with the `FILL` modifier. * @type {CellAddressRange[]|null} fillRanges */ fillRanges = null; /** * Source address of the formula. * @type {CellAddress|null} */ location = null; /** * @param {CellExpressionOperation} op * @param {Array} args * @param {string|null} qualifier */ constructor(op, args, qualifier = null) { this.op = op; this.arguments = args; this.qualifier = qualifier; } /** * Attempts to parse a formula expression. * * @param {string} expression - formula string, including leading `=` * @param {CellAddress} address - location of the formula * @returns {CellExpression|null} - parsed expression, or `null` if it failed */ static parse(expression, address) { const tokens = this.expressionToTokens(expression); if (tokens.length == 0) return null; const expr = this.expressionFromTokens(tokens, address); expr.location = address; return expr; } /** * Writes an expression tree to console.error for debugging purposes. * * @param {CellExpression} expression * @param {string} indent */ static dumpExpression(expression, indent = '') { if (expression.arguments.length == 0) { console.error(indent + "expr " + expression.op.name + '()'); } else { console.error(indent + expression.op.name + '('); for (const argument of expression.arguments) { if (typeof argument == 'number') { console.error(indent + `\t${argument}`); } else if (typeof argument == 'string') { console.error(indent + `\t"${argument}"`); } else if (typeof argument == 'boolean') { console.error(indent + "\t" + (argument ? "true" : "false")); } else if (argument instanceof CellAddress) { console.error(indent + "\t" + argument.name); } else if (argument instanceof CellAddressRange) { console.error(indent + "\t" + argument.name); } else if (argument instanceof CellValue) { console.error(indent + "\t" + argument.type + " " + argument.formattedValue); } else if (argument instanceof CellExpression) { this.dumpExpression(argument, indent + "\t"); } else { console.error(indent + "\t" + typeof argument); } } console.error(indent + ')'); } } #clone() { const cp = new CellExpression(); cp.op = this.op; cp.arguments = this.arguments.slice(); cp.qualifier = this.qualifier; cp.outputType = this.outputType; cp.outputDecimals = this.outputDecimals; cp.fillRanges = this.fillRanges !== null ? this.fillRanges.slice() : null; cp.location = this.location; return cp; } /** * Returns a copy of this expression with cell references transposed by * the delta between `start` and `end` addresses. Used for repeating an * autofilled formula into blank cells. * * @param {CellAddress} start * @param {CellAddress} end * @returns {CellExpression|null} */ transpose(start, end) { var transposed = this.#clone(); // structuredClone makes a mess of typing transposed.arguments = []; for (const argument of this.arguments) { if (argument instanceof CellExpression) { transposed.arguments.push(argument.transpose(start, end)); } else if (argument instanceof CellAddress) { transposed.arguments.push(argument.transpose(start, end)); } else if (argument instanceof CellAddressRange) { transposed.arguments.push(argument.transpose(start, end)); } else { transposed.arguments.push(argument); } } return transposed; } // -- Tokenizing -------------------------------------------------------- /** * Converts an expression into an array of tokens. * @param {string} text - expression * @returns {CellExpressionToken[]} tokens */ static expressionToTokens(text) { var tokens = []; var pos = [0]; this.#skipWhitespace(text, pos); if (text.substring(pos[0], pos[0] + 1) == '=') { // Ignore equals pos[0]++; } this.#skipWhitespace(text, pos); var l = text.length; while (pos[0] < l) { tokens.push(this.#readNextToken(text, pos)); this.#skipWhitespace(text, pos); } return tokens; } /** * @param {string} text * @param {number[]} pos * @returns {CellExpressionToken} */ static #readNextToken(text, pos) { // Single char tokens var token; if (token = this.#readNextSimpleToken(text, pos, '==', CellExpressionTokenType.Equal)) return token; if (token = this.#readNextSimpleToken(text, pos, '!=', CellExpressionTokenType.Unequal)) return token; if (token = this.#readNextSimpleToken(text, pos, '<=', CellExpressionTokenType.LessThanEqual)) return token; if (token = this.#readNextSimpleToken(text, pos, '>=', CellExpressionTokenType.GreaterThanEqual)) return token; if (token = this.#readNextSimpleToken(text, pos, '<', CellExpressionTokenType.LessThan)) return token; if (token = this.#readNextSimpleToken(text, pos, '>', CellExpressionTokenType.GreaterThan)) return token; if (token = this.#readNextSimpleToken(text, pos, '!', CellExpressionTokenType.Not)) return token; if (token = this.#readNextSimpleToken(text, pos, '+', CellExpressionTokenType.Plus)) return token; if (token = this.#readNextSimpleToken(text, pos, '-', CellExpressionTokenType.Minus)) return token; if (token = this.#readNextSimpleToken(text, pos, '*', CellExpressionTokenType.Multiply)) return token; if (token = this.#readNextSimpleToken(text, pos, '/', CellExpressionTokenType.Divide)) return token; if (token = this.#readNextSimpleToken(text, pos, ',', CellExpressionTokenType.Comma)) return token; if (token = this.#readNextSimpleToken(text, pos, '(', CellExpressionTokenType.OpenParen)) return token; if (token = this.#readNextSimpleToken(text, pos, ')', CellExpressionTokenType.CloseParen)) return token; if (token = this.#readNextSimpleToken(text, pos, ':', CellExpressionTokenType.Colon)) return token; if (token = this.#readNextSimpleToken(text, pos, ';', CellExpressionTokenType.Semicolon)) return token; if (token = this.#readNextSimpleToken(text, pos, '&', CellExpressionTokenType.Ampersand)) return token; // Other tokens if (token = this.#readNextAddressToken(text, pos)) return token; if (token = this.#readNextNameToken(text, pos)) return token; if (token = this.#readNextNumberToken(text, pos)) return token; if (token = this.#readNextStringToken(text, pos)) return token; throw new CellSyntaxException(`Unexpected character "${text.substring(pos[0], pos[0] + 1)}" at ${pos[0]}`); } /** * @param {string} text * @param {number[]} pos */ static #skipWhitespace(text, pos) { const l = text.length; while (pos[0] < l) { const ch = text.substring(pos[0], pos[0] + 1); if (ch == ' ' || ch == "\t" || ch == "\n" || ch == "\r") { pos[0]++; } else { return; } } } /** * @param {string} text * @param {number[]} pos * @param {string} target * @param {CellExpressionTokenType} type * @returns {CellExpressionToken|null} */ static #readNextSimpleToken(text, pos, target, type) { const remaining = text.length - pos[0]; const l = target.length; if (l > remaining) return null; const test = text.substring(pos[0], pos[0] + l); if (test.toUpperCase() != target.toUpperCase()) return null; pos[0] += l; return new CellExpressionToken(type, test); } /** * @param {string} text * @param {number[]} pos * @returns {CellExpressionToken|null} */ static #readNextAddressToken(text, pos) { var p = [ pos[0] ]; var ch = text.substring(p[0], p[0] + 1); var address = ''; var isName = true; if (ch == '$') { address += ch; isName = false; p[0]++; } var col = this.#readChars(text, p, (s) => this.#isLetter(s), 1, 2); if (col === null) return null; address += col; ch = text.substring(p[0], p[0] + 1); if (ch == '$') { address += ch; isName = false; p[0]++; const row = this.#readChars(text, p, this.#isDigit, 1); if (row === null) return null; address += row; } else { const row = this.#readChars(text, p, this.#isDigit, 0); if (row === null) return null; address += row; } pos[0] = p[0]; return new CellExpressionToken( isName ? CellExpressionTokenType.NameOrAddress : CellExpressionTokenType.Address, address); } /** * @param {string} text * @param {number[]} pos * @returns {CellExpressionToken|null} */ static #readNextNameToken(text, pos) { var p = [ pos[0] ]; const name = this.#readChars(text, p, (s) => this.#isLetter(s), 1); if (name === null) return null; pos[0] = p[0]; if (CellAddress.isAddress(name)) { return new CellExpressionToken(CellExpressionTokenType.NameOrAddress, name); } return new CellExpressionToken(CellExpressionTokenType.Name, name); } /** * @param {string} text * @param {number[]} pos * @returns {CellExpressionToken|null} */ static #readNextNumberToken(text, pos) { var ch = text.substring(pos[0], pos[0] + 1); if (!this.#isDigit(ch)) return null; const l = text.length; var numStr = ch; pos[0]++; while (pos[0] < l) { ch = text.substring(pos[0], pos[0] + 1); if (this.#isDigit(ch)) { pos[0]++; numStr += ch; } else { break; } } if (pos[0] < l) { ch = text.substring(pos[0], pos[0] + 1); if (ch == '.') { numStr += ch; pos[0]++; while (pos[0] < l) { ch = text.substring(pos[0], pos[0] + 1); if (this.#isDigit(ch)) { pos[0]++; numStr += ch; } else { break; } } } } return new CellExpressionToken(CellExpressionTokenType.Number, numStr); } /** * @param {string} text * @param {number[]} pos * @returns {CellExpressionToken|null} */ static #readNextStringToken(text, pos) { var ch = text.substring(pos[0], pos[0] + 1); if (ch !== '"') return null; var str = ''; pos[0]++; const l = text.length; var inEscape = false; while (pos[0] < l) { ch = text.substring(pos[0], pos[0] + 1); pos[0]++; if (inEscape) { inEscape = false; if (ch == '\\' || ch == '"') { str += ch; } else { throw new CellSyntaxException(`Bad string escape sequence "\\${ch}"`); } } else if (ch == '\\') { inEscape = true; } else if (ch == '"') { return new CellExpressionToken(CellExpressionTokenType.String, str); } else { str += ch; } } throw new CellSyntaxException('Unterminated string'); } /** * Reads the next chars that pass a test function and returns the result. * @param {string} text * @param {number[]} pos * @param {function} charTest * @param {number|null} minimumLength * @param {number|null} maximumLength * @returns {string|null} */ static #readChars(text, pos, charTest, minimumLength = null, maximumLength = null) { var p = pos[0]; const l = text.length; var s = ''; var sl = 0; while (p < l && (maximumLength === null || sl < maximumLength)) { const ch = text.substring(p, p + 1); if (!charTest(ch)) break; s += ch; sl++; p++; } if (p < l && charTest(text.substring(p, p + 1))) { return null; } if (minimumLength !== null && sl < minimumLength) { return null; } pos[0] = p; return s; } /** * @param {string} ch * @returns {boolean} */ static #isLetter(ch) { const ord = ch.codePointAt(0); return (ord >= 65 && ord <= 90) || (ord >= 97 && ord <= 122); } /** * @param {string} ch * @returns {boolean} */ static #isDigit(ch) { const ord = ch.codePointAt(0); return (ord >= 48 && ord <= 57); } // -- Parsing ----------------------------------------------------------- /** * @param {Array} tokens * @param {CellAddress} address * @returns {CellExpression|null} */ static expressionFromTokens(tokens, address) { var expr; if (expr = this.#tryExpressionAndFormat(tokens, 0, tokens.length - 1, address)) return expr; if (expr = this.#tryExpressionAndFill(tokens, 0, tokens.length - 1, address)) return expr; if (expr = this.#tryExpression(tokens, 0, tokens.length - 1, address)) return expr; return null; } /** * @param {Array} tokens * @param {number} start * @param {number} end * @param {CellAddress} address * @returns {CellExpression|null} */ static #tryExpressionAndFormat(tokens, start, end, address) { for (var t = start + 1; t < end; t++) { if (tokens[t].type == CellExpressionTokenType.Semicolon) { const expr = this.#tryExpressionAndFill(tokens, start, t - 1, address) ?? this.#tryExpression(tokens, start, t - 1, address); if (expr === null) return null; const format = this.#tryFormat(tokens, t + 1, end, address); if (format === null) return null; [ expr.outputType, expr.outputDecimals ] = format; return expr; } } return null; } /** * @param {CellExpressionToken[]} tokens * @param {number} start * @param {number} end * @param {CellAddress} address * @returns {CellExpression|null} */ static #tryExpressionAndFill(tokens, start, end, address) { var count = end - start + 1; if (count < 2) return null; if (!tokens[end].type.isPotentialName()) return null; var name = tokens[end].content.toUpperCase(); if (name != 'FILL') return null; const exp = this.#tryExpression(tokens, start, end - 1, address); const columnIndex = address.columnIndex; exp.fillRanges = [ new CellAddressRange(new CellAddress(columnIndex, -1), new CellAddress(columnIndex, -1)), ]; return exp; } /** * Tries to parse a format suffix after a semicolon. Examples: * * ; number * ; number 3 * ; currency 2 * ; percent 0 * @param {CellExpressionToken[]} tokens * @param {number} start * @param {number} end * @param {CellAddress} address * @return {any[]} CellValue type and decimal places */ static #tryFormat(tokens, start, end, address) { const count = end - start + 1; if (count < 0 || count > 2) return null; if (!tokens[start].type.isPotentialName()) return null; const type = tokens[start].content.toLowerCase(); if (!CellValue.isTypeNumeric(type)) return null; var decimals; if (count > 1) { if (tokens[start + 1].type != CellExpressionTokenType.Number) return null; decimals = parseInt(tokens[start + 1].content); } else { decimals = null; } return [ type, decimals ]; } /** * @param {CellExpressionToken[]} tokens * @param {number} start * @param {number} end * @param {CellAddress} address * @returns {CellExpression} */ static #tryExpression(tokens, start, end, address) { var expr; if (expr = this.#tryParenExpression(tokens, start, end, address)) return expr; if (expr = this.#tryNumber(tokens, start, end, address)) return expr; if (expr = this.#tryString(tokens, start, end, address)) return expr; if (expr = this.#tryBoolean(tokens, start, end, address)) return expr; if (expr = this.#tryFunction(tokens, start, end, address)) return expr; if (expr = this.#tryRange(tokens, start, end, address)) return expr; if (expr = this.#tryReference(tokens, start, end, address)) return expr; if (expr = this.#tryInfix(tokens, start, end, address)) return expr; if (expr = this.#tryUnary(tokens, start, end, address)) return expr; throw new CellSyntaxException("Invalid expression"); } /** * @param {CellExpressionToken[]} tokens * @param {number} start * @param {number} end * @param {CellAddress} address * @returns {CellExpression|null} */ static #tryParenExpression(tokens, start, end, address) { if (tokens[start].type != CellExpressionTokenType.OpenParen) return null; if (tokens[end].type != CellExpressionTokenType.CloseParen) return null; var parenLevel = 0; for (var t = start + 1; t < end; t++) { if (tokens[t].type == CellExpressionTokenType.OpenParen) { parenLevel++; } else if (tokens[t].type == CellExpressionTokenType.CloseParen) { parenLevel--; } if (parenLevel < 0) return null; } if (parenLevel != 0) return null; return this.#tryExpression(tokens, start + 1, end - 1, address); } /** * @param {CellExpressionToken[]} tokens * @param {number} start * @param {number} end * @param {CellAddress} address * @returns {CellExpression|null} */ static #tryNumber(tokens, start, end, address) { if (tokens[end].type != CellExpressionTokenType.Number) return null; if (end > start + 1) return null; const val = CellValue.fromCellString(tokens[end].content); if (end > start) { if (tokens[start].type != CellExpressionTokenType.Minus) return null; val.value = -val.value; } return new CellExpression(CellExpressionOperation.Number, [ val ]); } /** * @param {CellExpressionToken[]} tokens * @param {number} start * @param {number} end * @param {CellAddress} address * @returns {CellExpression|null} */ static #tryString(tokens, start, end, address) { if (start != end) return null; if (tokens[start].type != CellExpressionTokenType.String) return null; const str = tokens[start].content; return new CellExpression(CellExpressionOperation.String, [ new CellValue(str, str, CellValue.TYPE_STRING, 0) ]); } /** * @param {CellExpressionToken[]} tokens * @param {number} start * @param {number} end * @param {CellAddress} address * @returns {CellExpression|null} */ static #tryBoolean(tokens, start, end, address) { if (start != end) return null; if (!tokens[start].type.isPotentialName()) return null; const str = tokens[start].content.toUpperCase(); if (str != 'TRUE' && str != 'FALSE') return null; return new CellExpression(CellExpressionOperation.Boolean, [ new CellValue(str, str == 'TRUE', CellValue.TYPE_BOOLEAN) ]); } /** * @param {CellExpressionToken[]} tokens * @param {number} start * @param {number} end * @param {CellAddress} address * @returns {CellExpression|null} */ static #tryFunction(tokens, start, end, address) { const count = end - start + 1; if (count < 3) return null; if (!tokens[start].type.isPotentialName()) return null; const qualifier = tokens[start].content; if (tokens[start + 1].type != CellExpressionTokenType.OpenParen) return null; if (tokens[end].type != CellExpressionTokenType.CloseParen) return null; const argList = this.#tryArgumentList(tokens, start + 2, end - 1, address); if (argList === null) return null; return new CellExpression(CellExpressionOperation.Function, argList, qualifier); } /** * @param {CellExpressionToken[]} tokens * @param {number} start * @param {number} end * @param {CellAddress} address * @returns {CellExpression[]|null} */ static #tryArgumentList(tokens, start, end, address) { const count = end - start + 1; if (count == 0) return []; var parenDepth = 0; const argCount = 1; // Populate argTokens with tuples of start and end token indices for each arg. /** @type {int[][]} */ var argTokens = []; // argindex -> [ start, end ] var exprStartToken = start; for (var i = start; i <= end; i++) { if (tokens[i].type == CellExpressionTokenType.OpenParen) { parenDepth++; } else if (tokens[i].type == CellExpressionTokenType.CloseParen) { parenDepth--; } else if (tokens[i].type == CellExpressionTokenType.Comma && parenDepth == 0) { const exprEndToken = i - 1; argTokens.push([ exprStartToken, exprEndToken ]); exprStartToken = i + 1; } } argTokens.push([ exprStartToken, end ]); // Convert token ranges to expressions var args = []; for (const argToken of argTokens) { const arg = this.#tryExpression(tokens, argToken[0], argToken[1], address); if (arg === null) return null; args.push(arg); } return args; } /** * @param {CellExpressionToken[]} tokens * @param {number} start * @param {number} end * @param {CellAddress} address * @returns {CellExpression|null} */ static #tryRange(tokens, start, end, address) { const count = end - start + 1; if (count != 3) return null; if (!tokens[start].type.isPotentialAddress()) return null; const first = tokens[start].content.toUpperCase(); if (tokens[start + 1].type != CellExpressionTokenType.Colon) return null; if (!tokens[end].type.isPotentialAddress()) return null; const last = tokens[end].content.toUpperCase(); const firstAddress = new CellAddress(first); const lastAddress = new CellAddress(last); const range = new CellAddressRange(firstAddress, lastAddress); return new CellExpression(CellExpressionOperation.Range, [ range ]); } /** * @param {CellExpressionToken[]} tokens * @param {number} start * @param {number} end * @param {CellAddress} address * @returns {CellExpression|null} */ static #tryReference(tokens, start, end, address) { if (start != end) return null; if (!tokens[start].type.isPotentialAddress()) return null; const ref = tokens[start].content.toUpperCase(); const refAddress = CellAddress.fromString(ref, address, true); return new CellExpression(CellExpressionOperation.Reference, [ refAddress ]); } /** * @param {CellExpressionToken[]} tokens * @param {number} start * @param {number} end * @param {CellAddress} address * @returns {CellExpression|null} */ static #tryInfix(tokens, start, end, address) { const count = end - start + 1; if (count < 3) return null; const opPriorities = {} opPriorities[CellExpressionTokenType.Multiply.name] = 4; opPriorities[CellExpressionTokenType.Divide.name] = 3; opPriorities[CellExpressionTokenType.Plus.name] = 2; opPriorities[CellExpressionTokenType.Minus.name] = 1; opPriorities[CellExpressionTokenType.Ampersand.name] = 10; opPriorities[CellExpressionTokenType.GreaterThan.name] = 20; opPriorities[CellExpressionTokenType.GreaterThanEqual.name] = 20; opPriorities[CellExpressionTokenType.LessThan.name] = 20; opPriorities[CellExpressionTokenType.LessThanEqual.name] = 20; opPriorities[CellExpressionTokenType.Equal.name] = 20; opPriorities[CellExpressionTokenType.Unequal.name] = 20; var candidates = []; var parenLevel = 0; var i; for (i = start; i <= end; i++) { if (tokens[i].type == CellExpressionTokenType.OpenParen) { parenLevel++; } else if (tokens[i].type == CellExpressionTokenType.CloseParen) { parenLevel--; } else if (parenLevel == 0 && i > start && i < end) { const op = tokens[i].type.name; const priority = opPriorities[op] ?? false; if (priority === false) continue; //console.info(`Found infix candidate at ${i} for ${op} priority ${priority}`); candidates.push({ priority: priority, i: i }); } } candidates.sort((a, b) => a.priority - b.priority); var bestCandidate = null; var operand1, operand2; for (const candidate of candidates) { try { i = candidate.i; operand1 = this.#tryExpression(tokens, start, i - 1, address); if (operand1 === null) continue; operand2 = this.#tryExpression(tokens, i + 1, end, address); if (operand2 === null) continue; bestCandidate = candidate; break; } catch (e) { if (!(e instanceof CellSyntaxException)) { throw e; } } } if (bestCandidate === null) { //console.info("No best candidate found"); return null; } i = bestCandidate.i; //console.info(`Best candidate at token ${i}, priority ${bestCandidate.priority}`); switch (tokens[bestCandidate.i].type) { case CellExpressionTokenType.Plus: return new CellExpression(CellExpressionOperation.Add, [ operand1, operand2 ]); case CellExpressionTokenType.Minus: return new CellExpression(CellExpressionOperation.Subtract, [ operand1, operand2 ]); case CellExpressionTokenType.Multiply: return new CellExpression(CellExpressionOperation.Multiply, [ operand1, operand2 ]); case CellExpressionTokenType.Divide: return new CellExpression(CellExpressionOperation.Divide, [ operand1, operand2 ]); case CellExpressionTokenType.GreaterThan: return new CellExpression(CellExpressionOperation.GreaterThan, [ operand1, operand2 ]); case CellExpressionTokenType.GreaterThanEqual: return new CellExpression(CellExpressionOperation.GreaterThanEqual, [ operand1, operand2 ]); case CellExpressionTokenType.LessThan: return new CellExpression(CellExpressionOperation.LessThan, [ operand1, operand2 ]); case CellExpressionTokenType.LessThanEqual: return new CellExpression(CellExpressionOperation.LessThanEqual, [ operand1, operand2 ]); case CellExpressionTokenType.Equal: return new CellExpression(CellExpressionOperation.Equal, [ operand1, operand2 ]); case CellExpressionTokenType.Unequal: return new CellExpression(CellExpressionOperation.Unequal, [ operand1, operand2 ]); case CellExpressionTokenType.Ampersand: return new CellExpression(CellExpressionOperation.Concatenate, [ operand1, operand2 ]); } return null; } /** * @param {CellExpressionToken[]} tokens * @param {number} start * @param {number} end * @param {CellAddress} address * @returns {CellExpression|null} */ static #tryUnary(tokens, start, end, address) { const count = end - start + 1; if (count < 2) return null; const ops = [ [ CellExpressionTokenType.Minus, CellExpressionOperation.UnaryMinus ], [ CellExpressionTokenType.Not, CellExpressionOperation.UnaryNot ], ]; for (const op of ops) { if (tokens[start].type != op[0]) continue; const operand = this.#tryExpression(tokens, start + 1, end, address); if (operand === null) return null; return new CellExpression(op[1], [ operand ]); } return null; } } /** * Parsing token for an expression. */ class CellExpressionToken { /** @type {CellExpressionTokenType} */ type; /** @type {string} */ content; /** * @param {CellExpressionTokenType} type * @param {string} content */ constructor(type, content) { this.type = type; this.content = content; } } /** * The location of a cell in a table. If the address was specified without a * row, the address is considered "unresolved" and needs more context to * uniquely identify a cell. */ class CellAddress { #name; /** * @type {string} */ get name() { return this.#name; } #isColumnFixed = false; /** * Whether the column should remain unchanged when transposed. This is * symbolized by prefixing the column name with a `$` (e.g. `$C3`). * @type {boolean} */ get isColumnFixed() { return this.#isColumnFixed; } #columnIndex = -1; /** * Zero-based column index. * @type {number} */ get columnIndex() { return this.#columnIndex; }; /** * Letter code for the column. * @type {string} */ get columnLetter() { return CellAddress.#columnIndexToLetters(this.#columnIndex); } #isRowFixed = false; /** * Whether the row should remain unchanged when transposed. This is * symbolized by prefixing the row number with a `$` (e.g. `C$3`). * @type {boolean} */ get isRowFixed() { return this.#isRowFixed; } #rowIndex = -1; /** * Zero-based row index. * @type {number} */ get rowIndex() { return this.#rowIndex; } /** * One-based row number. This is the human-facing row number. */ get rowNumber() { return this.#rowIndex + 1; } /** * Whether this address has both a definite column and row. * @type {boolean} */ get isResolved() { return this.columnIndex >= 0 && this.rowIndex >= 0; } /** * @param {number} columnIndex - 0-based column index * @param {number} rowIndex - 0-based row index * @param {boolean} isColumnFixed - whether the column name is fixed in * place during transpositions. Denoted with a `$` in front of the column letters. * @param {boolean} isRowFixed - whether the row number is fixed in place * during transpositions. Denoted with a `$` in front of the row digits. */ constructor(columnIndex, rowIndex, isColumnFixed=false, isRowFixed=false) { if (typeof columnIndex != 'number') { throw new Error(`columnIndex must be number, got ${typeof columnIndex}`); } if (typeof rowIndex != 'number') { throw new Error(`rowIndex must be number, got ${typeof rowIndex}`); } this.#columnIndex = columnIndex; this.#rowIndex = rowIndex; this.#isColumnFixed = isColumnFixed; this.#isRowFixed = isRowFixed; this.#name = CellAddress.#formatAddress(columnIndex, rowIndex, isColumnFixed, isRowFixed); } /** * Tests if a string is formatted like an address. * @param {string} text * @returns {boolean} */ static isAddress(text) { return this.fromString(text) != null; } /** * Returns a converted form of this address reference in a formula that has * been copied from its original location. In other words, if a formula * refers to a cell one to the left and that formula is copied to the next * cell down, that copy's reference should point to the cell on the next * row as well. Addresses with an absolute column or row (e.g. "A5") will * not be altered on that axis. * * Examples: * - C6.transpose(A5, A9) = C10 (A9-A5 = +4 rows, C6 + 4 rows = C10) * - C6.transpose(A5, B9) = D10 (B9-A5 = +4 rows +1 cols, C6 + 4 rows + 1 col = D10) * - C$6.transpose(A5, A9) = C6 (A9-A5 = +4 rows, but row is fixed, so still C6) * - B.transpose(A5, A9) = B9 (A9-A4 = +4 rows, B has no row so last row used = B9) * - A1.transpose(C3, A1) = null (out of bounds) * * @param {CellAddress} relativeFrom - original address of the formula * @param {CellAddress} relativeTo - address where the formula is being repeated * @param {boolean} resolveToRow - whether to fill in a row number if this address * doesn't have one * @returns {CellAddress|null} - resolved address, or `null` if out of bounds */ transpose(relativeFrom, relativeTo, resolveToRow = true) { if (!relativeFrom.isResolved || !relativeTo.isResolved) { throw new CellEvaluationException("Can only transpose to and from resolved addresses"); } var newColumnIndex = this.columnIndex; if (!this.isColumnFixed) { const columnDelta = relativeTo.columnIndex - relativeFrom.columnIndex; newColumnIndex += columnDelta; } var newRowIndex = this.rowIndex; if (!this.isResolved && resolveToRow) { newRowIndex = relativeFrom.rowIndex; } if (newRowIndex != -1 && !this.isRowAbsolute) { const rowDelta = relativeTo.rowIndex - relativeFrom.rowIndex; newRowIndex += rowDelta; } if (newColumnIndex < 0 || newRowIndex < 0) return null; return new CellAddress(newColumnIndex, newRowIndex); } equals(other) { if (!(other instanceof CellAddress)) return false; return other.columnIndex == this.columnIndex && other.rowIndex == this.rowIndex; } exactlyEquals(other) { if (!(other instanceof CellAddress)) return false; return other.name == this.name; } toString() { return this.name; } /** * Converts column letters (e.g. `A`, `C`, `AA`) to a 0-based column index. * Assumes a validated well-formed column letter or else behavior is undefined. * * @param {string} letters * @returns {number} column index */ static #lettersToColumnIndex(letters) { const ACodepoint = 'A'.codePointAt(0); var columnIndex = 0; for (var i = letters.length - 1; i >= 0; i--) { const letterIndex = letters.codePointAt(i) - ACodepoint; columnIndex = columnIndex * 26 + letterIndex; } return columnIndex; } /** * Converts a column index to column letters (e.g. index 0 = `A`). * * @param {number} columnIndex * @returns {string} */ static #columnIndexToLetters(columnIndex) { var letters = ''; if (columnIndex >= 0) { const ACodepoint = 'A'.codePointAt(0); var remaining = columnIndex; do { letters = String.fromCodePoint(ACodepoint + (remaining % 26)) + letters; remaining = Math.floor(remaining / 26); } while (remaining > 0); } return letters; } static #formatAddress(columnIndex, rowIndex, isColumnFixed, isRowFixed) { var addr = ''; if (isColumnFixed && columnIndex >= 0) addr += '$'; if (columnIndex >= 0) addr += this.#columnIndexToLetters(columnIndex); if (isRowFixed && rowIndex >= 0) addr += '$'; if (rowIndex >= 0) addr += `${rowIndex + 1}`; return addr; } /** * Attempts to convert a cell address string to a `CellAddress`. * * @param {string} address - cell address string * @param {CellAddress|null} relativeTo - address to resolve relative addresses against * @param {boolean} throwIfInvalid - whether to throw an error if address is invalid * @returns {CellAddress|null} address, if parsable * @throws {CellEvaluationException} if the address is invalid and `throwIfInvalid` * is `true` */ static fromString(address, relativeTo=null, throwIfInvalid=false) { const groups = /^(\$?)([A-Z]{1,2}?)((?:\$(?=[0-9]))?)([0-9]*)$/i.exec(address); if (groups === null) { if (throwIfInvalid) throw new CellEvaluationException(`Bad address "${address}"`, '#REF'); return null; } const isColumnFixed = groups[1] == '$'; const letters = groups[2].toUpperCase(); const isRowFixed = groups[3] == '$'; const numbers = groups[4]; var columnIndex = this.#lettersToColumnIndex(letters); var rowIndex = (numbers.length == 0) ? -1 : parseInt(numbers) - 1; if (columnIndex < 0 && relativeTo != null) columnIndex = relativeTo.columnIndex; if (rowIndex < 0 && relativeTo != null) rowIndex = relativeTo.rowIndex; return new CellAddress(columnIndex, rowIndex, isColumnFixed, isRowFixed); } } /** * Range of cells addresses. Can be iterated via `cellsIn`. */ class CellAddressRange { /** @type {boolean} */ isResolved; /** @type {number} */ minColumnIndex; /** @type {number} */ maxColumnIndex; /** @type {number} */ minRowIndex; /** @type {number} */ maxRowIndex; /** @type {string} */ name; /** * Creates a rectangular range between two corner cells. They can be in * any order. The given cells must either both be resolved or both unresolved. * * @param {CellAddress} fromCell * @param {CellAddress} toCell */ constructor(fromCell, toCell) { if (fromCell.isResolved != toCell.isResolved) { throw new CellEvaluationException(`Cannot mix resolved and unresolved cell addresses in range: ${fromCell.name} and ${toCell.name}`); } this.minColumnIndex = Math.min(fromCell.columnIndex, toCell.columnIndex); this.maxColumnIndex = Math.max(fromCell.columnIndex, toCell.columnIndex); this.minRowIndex = Math.min(fromCell.rowIndex, toCell.rowIndex); this.maxRowIndex = Math.max(fromCell.rowIndex, toCell.rowIndex); this.isResolved = fromCell.isResolved; this.name = (new CellAddress(this.minColumnIndex, this.minRowIndex)).name + ':' + (new CellAddress(this.maxColumnIndex, this.maxRowIndex)).name; } /** * Creates an iterator for every `CellAddress` in this range within the * confines of the given grid's dimensions. Iterates each row in the first * column, then each row in the second, etc. Iteration range is inclusive * of the min and max extents. * * Example: * ``` * for (const address of range.cellsIn(grid)) { * ... * } * ``` * * @param {SpreadsheetGrid} grid * @returns {object} iterable object */ cellsIn(grid) { const minCol = this.minColumnIndex < 0 ? 0 : this.minColumnIndex; const maxCol = this.maxColumnIndex < 0 ? grid.columnCount - 1 : Math.min(this.maxColumnIndex, grid.columnCount - 1); const minRow = this.minRowIndex < 0 ? 0 : this.minRowIndex; const maxRow = this.maxRowIndex < 0 ? grid.rowCount - 1 : Math.min(this.maxRowIndex, grid.rowCount - 1); const iterable = {}; iterable[Symbol.iterator] = function() { var currentCol = minCol; var currentRow = minRow; return { next() { const col = currentCol; const row = currentRow++; if (currentRow > maxRow) { currentRow = minRow; currentCol++; } return { value: new CellAddress(col, row), done: col > maxCol || row > maxRow, }; } }; }; return iterable; } } /** * A value in a spreadsheet or calculation. */ class CellValue { /** * Blank cell. `value` is `null`. */ static TYPE_BLANK = 'blank'; /** * Currency value. `value` is `number`. */ static TYPE_CURRENCY = 'currency'; /** * Regular number value. `value` is `number`. */ static TYPE_NUMBER = 'number'; /** * Percentage. `value` is `number`, represented as a ratio (100% = 1.0). */ static TYPE_PERCENT = 'percent'; /** * Unaltered text value. `value` is `string`. */ static TYPE_STRING = 'string'; /** * Boolean. `value` is `boolean`. */ static TYPE_BOOLEAN = 'boolean'; /** * A formula that has resulted in an error during parsing or evaluation. * `value` is `string` error message. */ static TYPE_ERROR = 'error'; /** * A formula expression. `value` is `string` and includes the leading `=`. */ static TYPE_FORMULA = 'formula'; // -- Properties ----- /** * A blank value. * @type {CellValue} */ static BLANK = new CellValue('', null, CellValue.TYPE_BLANK); /** * Type of value. One of the `TYPE_` constants. * @type {string} */ type = CellValue.TYPE_STRING; /** * Number of decimal places shown in the formatted value. * @type {number} */ decimals = 0; /** * The string shown in the table cell to the user. * @type {string} */ formattedValue = ''; /** * The PHP data value. E.g. a `float` for currency values or an `Exception` * for errors. * @type {any} */ value = null; /** * Constructs a cell value explicitly. Values are not validated. Consider * using `.fromCellString()` or `.fromValue()` to populate values more * intelligently and consistently. * * @param {string} formattedValue * @param {any} value * @param {string} type * @param {number} decimals */ constructor( formattedValue, value = null, type = CellValue.TYPE_STRING, decimals = 0 ) { this.formattedValue = formattedValue; this.value = value; this.type = type; this.decimals = decimals; } /** * Returns whether this value is a numeric type. * @returns {boolean} */ isNumeric() { return CellValue.isTypeNumeric(this.type); } /** * Creates a CellValue from formatted table cell contents. Attempts to * detect formatted numbers including currency and percentages. * * @param {string} cellString * @returns {CellValue} */ static fromCellString(cellString) { const cv = new CellValue(cellString); cv.#populateFromCellString(cellString); return cv; } /** * Creates a CellValue from a value. Based off datatype, not string * formatting; use `fromCellString` to parse formatted numbers. * * @param {any} value * @param {string|null} type - optional forced type * @param {number|null} decimals - optional number of decimal places to format to * @returns {CellValue} * @throws {CellEvaluationException} if `value` is of an unsupported type */ static fromValue(value, type = null, decimals = null) { if (value === null) { return new CellValue('', null, CellValue.TYPE_BLANK); } if (value instanceof Error) { if (value instanceof CellException) { return new CellValue(value.errorSymbol, value.message, CellValue.TYPE_ERROR); } return new CellValue('#ERROR', value.message, CellValue.TYPE_ERROR); } if (typeof value == 'boolean') { const formatted = CellValue.formatType(value, CellValue.TYPE_BOOLEAN, 0); return new CellValue(formatted, value, CellValue.TYPE_BOOLEAN); } if (typeof value == 'number') { const resolvedType = type || CellValue.TYPE_NUMBER; const resolvedDecimals = (decimals !== null) ? decimals : (resolvedType == CellValue.TYPE_CURRENCY ? 2 : CellValue.#autodecimals(resolvedType == CellValue.TYPE_PERCENT ? value * 100.0 : value)); const formatted = CellValue.formatType(value, resolvedType, resolvedDecimals); return new CellValue(formatted, value, resolvedType, resolvedDecimals); } if (typeof value != 'string') { throw new CellEvaluationException(`Value of type ${typeof value} unsupported`); } const trimmed = value.trim(); if (trimmed.startsWith('=')) { return new CellValue(trimmed, trimmed, CellValue.TYPE_FORMULA); } return new CellValue(trimmed, trimmed, CellValue.TYPE_STRING); } /** * @param {string|null} cellString */ #populateFromCellString(cellString) { cellString = (cellString !== null) ? cellString.trim() : null; this.formattedValue = cellString; // blank if (cellString === null || cellString == '') { this.type = CellValue.TYPE_BLANK; this.value = null; return; } // 'literal if (cellString.startsWith("'")) { const stripped = cellString.substring(1).trim(); this.type = CellValue.TYPE_STRING; this.formattedValue = stripped; this.value = stripped; return; } // =TRUE const caps = cellString.toUpperCase(); if (caps == 'TRUE') { this.type = CellValue.TYPE_BOOLEAN; this.formattedValue = caps; this.value = true; this.formattedValue = 'TRUE'; return; } // =FALSE if (caps == 'FALSE') { this.type = CellValue.TYPE_BOOLEAN; this.formattedValue = caps; this.value = false; this.formattedValue = 'FALSE'; return; } // =A*B if (cellString.startsWith('=')) { this.type = CellValue.TYPE_FORMULA; this.value = cellString; return; } // -$1,234.56 var groups; if (groups = /^([-]?)\$(-?[0-9,]*\.)([0-9]+)$/.exec(cellString)) { const sign = groups[1]; const dollars = groups[2].replace(/,/g, ''); const cents = groups[3]; this.type = CellValue.TYPE_CURRENCY; this.decimals = 2; this.value = parseFloat(sign + dollars + cents); this.formattedValue = CellValue.#formatCurrency(this.value, this.decimals); return; } // -$1,234 if (groups = /^([-]?)\$(-?[0-9,]+)$/.exec(cellString)) { const sign = groups[1]; const dollars = groups[2].replace(/,/g, ''); this.type = CellValue.TYPE_CURRENCY; this.decimals = 0; this.value = parseFloat(sign + dollars); this.formattedValue = CellValue.#formatCurrency(this.value, this.decimals); return; } // -1,234.56% if (groups = /^([-]?[0-9,]*\.)([0-9,]+)%$/.exec(cellString)) { const wholes = groups[1].replace(/,/, ''); const decimals = groups[2]; this.type = CellValue.TYPE_PERCENT; this.decimals = decimals.length; this.value = parseFloat(wholes + decimals) / 100.0; this.formattedValue = CellValue.#formatPercent(this.value, this.decimals); return; } // -1,234% if (groups = /^([-]?[0-9,]+)%$/.exec(cellString)) { const wholes = groups[1].replace(/,/g, ''); this.type = CellValue.TYPE_PERCENT; this.decimals = 0; this.value = parseFloat(wholes) / 100.0; this.formattedValue = CellValue.#formatPercent(this.value, this.decimals); return; } // -1,234.56 if (groups = /^([-]?[0-9,]*\.)([0-9]+)$/.exec(cellString)) { const wholes = groups[1].replace(/,/g, ''); const decimals = groups[2]; this.type = CellValue.TYPE_NUMBER; this.decimals = decimals.length; this.value = parseFloat(wholes + decimals); this.formattedValue = CellValue.#formatNumber(this.value, this.decimals); return; } // -1,234 if (groups = /^([-]?[0-9,]+)$/.exec(cellString)) { const wholes = groups[1].replace(/,/g, ''); this.type = CellValue.TYPE_NUMBER; this.decimals = 0; this.value = parseFloat(wholes); this.formattedValue = CellValue.#formatNumber(this.value, this.decimals); return; } this.type = CellValue.TYPE_STRING; this.value = cellString; } /** * Returns the boolean equivalent of this value if possible. * @returns {boolean|null} */ booleanValue() { switch (this.type) { case CellValue.TYPE_BLANK: return false; case CellValue.TYPE_BOOLEAN: return this.value; case CellValue.TYPE_CURRENCY: case CellValue.TYPE_NUMBER: case CellValue.TYPE_PERCENT: return this.value != 0; case CellValue.TYPE_ERROR: case CellValue.TYPE_FORMULA: case CellValue.TYPE_STRING: return null; } } /** * Returns the numeric value of this value if possible. * @returns {number|null} */ numericValue() { switch (this.type) { case CellValue.TYPE_BLANK: return 0.0; case CellValue.TYPE_BOOLEAN: return this.value ? 1.0 : 0.0; case CellValue.TYPE_CURRENCY: case CellValue.TYPE_NUMBER: case CellValue.TYPE_PERCENT: return this.value; case CellValue.TYPE_ERROR: case CellValue.TYPE_FORMULA: case CellValue.TYPE_STRING: return null; } } /** * Returns the string value of this value if possible. * @param {boolean} formatted * @returns {string|null} */ stringValue(formatted = false) { switch (this.type) { case CellValue.TYPE_BLANK: return ''; case CellValue.TYPE_BOOLEAN: return this.value ? 'TRUE' : 'FALSE'; case CellValue.TYPE_CURRENCY: case CellValue.TYPE_NUMBER: case CellValue.TYPE_PERCENT: return formatted ? this.formattedValue : `${this.value}`; case CellValue.TYPE_STRING: return formatted ? this.formattedValue : this.value; case CellValue.TYPE_ERROR: case CellValue.TYPE_FORMULA: return null; } } // -- Operations -------------------------------------------------------- /** * Returns the result of this value plus `b`. * * @param {CellValue} b * @returns {CellValue} sum */ add(b) { return CellValue.#binaryNumericOperation(this, b, '+', (aVal, bVal) => aVal + bVal); } /** * Returns the result of this value minus `b`. * * @param {CellValue} b * @returns {CellValue} difference */ subtract(b) { return CellValue.#binaryNumericOperation(this, b, '-', (aVal, bVal) => aVal - bVal); } /** * Returns the result of this value multiplied by `b`. * * @param {CellValue} b * @returns {CellValue} product */ multiply(b) { return CellValue.#binaryNumericOperation(this, b, '*', (aVal, bVal) => aVal * bVal); } /** * Returns the result of this value divided by `b`. * * @param {CellValue} b * @returns {CellValue} quotient * @throws {CellEvaluationException} on divide by zero */ divide(b) { return CellValue.#binaryNumericOperation(this, b, '/', (aVal, bVal) => { if (bVal == 0) throw new CellEvaluationException("Division by zero", '#NAN'); return aVal / bVal }); } /** * Returns the result of this value modulo by `b`. * * @param {CellValue} b * @returns {CellValue} remainder * @throws {CellEvaluationException} on divide by zero */ modulo(b) { return CellValue.#binaryNumericOperation(this, b, '%', (aVal, bVal) => { if (bVal == 0) throw new CellEvaluationException("Division by zero", '#NAN'); return aVal % bVal }); } /** * Returns the result of whether this value is greater than `b`. * * @param {CellValue} b * @returns {CellValue} */ gt(b) { return CellValue.fromValue(CellValue.#compare(this, b) > 0); } /** * Returns the result of whether tihs value is greater than or equal to `b`. * * @param {CellValue} b * @returns {CellValue} */ gte(b) { return CellValue.fromValue(CellValue.#compare(this, b) >= 0); } /** * Returns the result of whether this value is less than `b`. * * @param {CellValue} b * @returns {CellValue} */ lt(b) { return CellValue.fromValue(CellValue.#compare(this, b) < 0); } /** * Returns the result of whether this value is less than or equal to `b`. * * @param {CellValue} b * @returns {CellValue} */ lte(b) { return CellValue.fromValue(CellValue.#compare(this, b) <= 0); } /** * Returns the result of whether this value is equal to `b`. * * @param {CellValue} b * @returns {CellValue} */ eq(b) { return CellValue.fromValue(CellValue.#compare(this, b) == 0); } /** * Returns the result of whether this value is unequal to `b`. * * @param {CellValue} b * @returns {CellValue} */ neq(b) { return CellValue.fromValue(CellValue.#compare(this, b) != 0); } /** * Returns the boolean not of this value. * @returns {CellValue} */ not() { switch (this.type) { case CellValue.TYPE_BLANK: return CellValue.fromValue(true); case CellValue.TYPE_CURRENCY: case CellValue.TYPE_NUMBER: case CellValue.TYPE_PERCENT: return CellValue.fromValue(this.value == 0); case CellValue.TYPE_STRING: throw new CellEvaluationException("Cannot perform NOT on string"); case CellValue.TYPE_BOOLEAN: return CellValue.fromValue(!this.value); case CellValue.TYPE_ERROR: throw this.value; case CellValue.TYPE_FORMULA: throw new CellEvaluationException("Cannot perform NOT on expression"); } } /** * @param {CellValue} b * @returns {CellValue} */ concatenate(b) { const s1 = this.stringValue(true); const s2 = b.stringValue(true); if (s1 === null || s2 === null) { throw new CellEvaluationException("Concatenation requires string arguments"); } return CellValue.fromValue(`${s1}${s2}`); } /** * @param {CellValue} a * @param {CellValue} b * @param {string} op * @param {function} fn - takes two `number` arguments and returns a `number` result * @returns {CellValue} * @throws {CellEvaluationException} */ static #binaryNumericOperation(a, b, op, fn) { const ops = this.#resolveNumericOperands(a, b, op); const aNum = ops[0]; const bNum = ops[1]; const type = ops[2]; const result = fn(aNum, bNum); return CellValue.fromValue(result, type); } /** * Determines what the result of a calculation with two operands should * look like. Returns a tuple array of the A float value, the B float value, * and the type of the result. * * @param {CellValue} a - operand A * @param {CellValue} b - operand B * @param {string} op - operator symbol * @returns {Array} 3-element tuple array with A number value, B number value, * and result type string * @throws {CellEvaluationException} if types are incompatible for numeric operations */ static #resolveNumericOperands(a, b, op) { if (a.type == this.TYPE_ERROR) throw a.value; if (b.type == this.TYPE_ERROR) throw b.value; if (a.type == this.TYPE_STRING || b.type == this.TYPE_STRING) { throw new CellEvaluationException("Cannot perform math on text values"); } if (a.type == this.TYPE_BLANK) { if (b.type == this.TYPE_BLANK) { return [ 0, 0, this.TYPE_NUMBER, 0 ]; } return [ 0, b.value, b.type ]; } else if (b.type == this.TYPE_BLANK) { return [ a.value, 0, a.type ]; } const isMultOrDiv = (op == '*' || op == '/' || op == '%'); if (a.type == b.type) { return [ a.value, b.value, a.type ]; } switch (a.type + b.type) { case this.TYPE_CURRENCY + this.TYPE_NUMBER: case this.TYPE_CURRENCY + this.TYPE_PERCENT: return [ a.value, b.value, this.TYPE_CURRENCY ]; case this.TYPE_PERCENT + this.TYPE_CURRENCY: return [ a.value, b.value, isMultOrDiv ? this.TYPE_CURRENCY : this.TYPE_PERCENT ]; case this.TYPE_PERCENT + this.TYPE_NUMBER: return [ a.value, b.value, isMultOrDiv ? this.TYPE_NUMBER : this.TYPE_PERCENT ]; case this.TYPE_NUMBER + this.TYPE_CURRENCY: return [ a.value, b.value, b.type ]; case this.TYPE_NUMBER + this.TYPE_PERCENT: return [ a.value, b.value, isMultOrDiv ? this.TYPE_NUMBER : b.type ]; case this.TYPE_BOOLEAN + this.TYPE_CURRENCY: case this.TYPE_BOOLEAN + this.TYPE_NUMBER: case this.TYPE_BOOLEAN + this.TYPE_PERCENT: return [ a.value ? 1 : 0, b.value, b.type ]; case this.TYPE_CURRENCY + this.TYPE_BOOLEAN: case this.TYPE_NUMBER + this.TYPE_BOOLEAN: case this.TYPE_PERCENT + this.TYPE_BOOLEAN: return [ a.value, b.value ? 1 : 0, a.type ]; } throw new CellEvaluationException(`Unhandled operand types "${a.type}" and "${b.type}"`); } /** * @param {CellValue} a * @param {CellValue} b * @returns {number} */ static #compare(a, b) { const args = CellValue.#resolveComparableArguments(a, b); const valueA = args[0]; const valueB = args[1]; if (typeof valueA == 'string') { return valueA.localeCompare(valueB, undefined, { sensitivity: 'accent' }); } else { if (valueA < valueB) return -1; if (valueA > valueB) return 1; return 0; } } /** * @param {CellValue} a * @param {CellValue} b * @returns {Array} */ static #resolveComparableArguments(a, b) { if (a.type == CellValue.TYPE_ERROR) throw a.value; if (b.type == CellValue.TYPE_ERROR) throw b.value; if (a.type == CellValue.TYPE_FORMULA) throw new CellEvaluationException("Can't compare formula values"); if (b.type == CellValue.TYPE_FORMULA) throw new CellEvaluationException("Can't compare formula values"); const aNumValue = a.value; const bNumValue = b.value; const aStrValue = `${a.value}`; const bStrValue = `${b.value}`; switch (a.type) { case CellValue.TYPE_BLANK: aNumValue = 0; aStrValue = ''; break; case CellValue.TYPE_BOOLEAN: aNumValue = (aNumValue) ? 1 : 0; break; } switch (b.type) { case CellValue.TYPE_BLANK: bNumValue = 0; bStrValue = ''; break; case CellValue.TYPE_BOOLEAN: bNumValue = (bNumValue) ? 1 : 0; break; } if (a.type == CellValue.TYPE_STRING || b.type == CellValue.TYPE_STRING) { return [ aStrValue, bStrValue ]; } return [ aNumValue, bNumValue ]; } /** * Returns a formatted string for the given raw value, value type, and * decimal places. * @param {any} value * @param {string} type * @param {number} decimals * @returns {string} */ static formatType(value, type, decimals) { switch (type) { case CellValue.TYPE_BLANK: return ''; case CellValue.TYPE_CURRENCY: return CellValue.#formatCurrency(value, decimals); case CellValue.TYPE_NUMBER: return CellValue.#formatNumber(value, decimals); case CellValue.TYPE_PERCENT: return CellValue.#formatPercent(value, decimals); case CellValue.TYPE_BOOLEAN: return value ? 'TRUE' : 'FALSE'; case CellValue.TYPE_STRING: return `${value}`; case CellValue.TYPE_FORMULA: return `${value}`; } } /** * @param {number} value * @param {number} decimals * @returns {string} */ static #formatNumber(value, decimals) { return (value).toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } /** * @param {number} dollars * @param {number} decimals * @returns {string} */ static #formatCurrency(dollars, decimals) { var s = (dollars).toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); if (s.startsWith('-')) { return '-$' + s.substring(1); } return '$' + s; } /** * @param {number} value * @param {number} decimals * @returns {string} */ static #formatPercent(value, decimals) { const dec = value * 100.0; return (dec).toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) + '%'; } /** * Determines a good number of decimal places to format a value to. * @param {any} value * @param {number} maxDigits * @returns {number} */ static #autodecimals(value, maxDigits = 6) { if (value instanceof CellValue) { return CellValue.#autodecimals(value.value); } if (typeof value == 'number') { var s = (value).toLocaleString(undefined, { maximumFractionDigits: maxDigits }); if (/\./.exec(s) === null) return 0; var fraction = s.split('.')[1]; return Math.min(maxDigits, fraction.length); } return 0; } /** * @param {string} type * @returns {boolean} */ static isTypeNumeric(type) { return type == CellValue.TYPE_NUMBER || type == CellValue.TYPE_PERCENT || type == CellValue.TYPE_CURRENCY || type == CellValue.TYPE_BOOLEAN; } toString() { return `[CellValue type=${this.type} value=${this.value} "${this.formattedValue}"]`; } } /** * A rectangular grid of `SpreadsheetCell`s representing a spreadsheet. * Agnostic of presentation. */ class SpreadsheetGrid { /** * Indexed by column then row. * @type {SpreadsheetCell[][]} */ cells; /** @type {number} */ columnCount; /** @type {number} */ rowCount; /** * @param {number} columnCount * @param {number} rowCount */ constructor(columnCount, rowCount) { this.columnCount = columnCount; this.rowCount = rowCount; this.cells = new Array(columnCount); for (var c = 0; c < columnCount; c++) { this.cells[c] = new Array(rowCount); for (var r = 0; r < rowCount; r++) { this.cells[c][r] = new SpreadsheetCell(); } } } /** * @param {CellAddress} address * @returns {SpreadsheetCell|null} cell, or `null` if no cell available * @throws {CellEvaluationException} if the address it out of bounds */ cellAt(address) { const c = address.columnIndex, r = address.rowIndex; if (c < 0 || c >= this.cells.length) throw new CellEvaluationException(`Unresolved cell address ${address.name}`, '#REF'); const row = this.cells[c]; if (r < 0 || r >= row.length) throw new CellEvaluationException(`Unresolved cell address ${address.name}`, '#REF'); return row[r]; } /** * Returns the original value at the given address. * @param {CellAddress} address */ valueAt(address) { return this.cellAt(address)?.originalValue; } /** * Returns the computed value at the given address. * @param {CellAddress} address * @returns {CellValue|null} computed value or `null` */ outputValueAt(address) { return this.cellAt(address)?.outputValue; } } /** * One cell in a spreadsheet grid. Has an `originalValue` to start and an * evaluated `outputValue` after population by a `CellExpressionSet` computation. */ class SpreadsheetCell { /** * @type {CellValue} */ originalValue = CellValue.BLANK; /** * @type {CellValue|null} */ outputValue = null; /** @type {boolean} */ isCalculated = false; /** @type {CellExpression|null} */ parsedExpression = null; /** * @type {CellValue|null} */ get resolvedValue() { return this.outputValue ?? this.originalValue; } } /** * Integration with Markdown. Adding this block reader to a parser will run a * post-process step on any tables in the document tree. Must be used with * `MDTableBlockReader`. Tables without at least one formula will not be altered. */ class MDSpreadsheetReader extends MDReader { postProcess(state, nodes) { for (const node of nodes) { if (node instanceof MDTableNode) { this.#processTable(node, state); } } } /** * @param {MDTableNode} tableNode * @param {MDState} state */ #processTable(tableNode, state) { // Measure table const rowCount = tableNode.bodyRows.length; var columnCount = 0; for (const row of tableNode.bodyRows) { columnCount = Math.max(columnCount, row.children.length); } // Create and populate grid const grid = new SpreadsheetGrid(columnCount, rowCount); for (var c = 0; c < columnCount; c++) { for (var r = 0; r < rowCount; r++) { const cellNode = tableNode.bodyRows[r].children[c]; if (cellNode === undefined) continue; const cellText = cellNode.toPlaintext(state); const gridCell = grid.cells[c][r]; gridCell.originalValue = CellValue.fromCellString(cellText); } } // Calculate const expressions = new CellExpressionSet(grid); expressions.calculateCells(); // See if anything was calculated. If not, don't mess with table. var isCalculated = false; for (var c = 0; c < columnCount && !isCalculated; c++) { for (var r = 0; r < rowCount; r++) { if (grid.cells[c][r].isCalculated) { isCalculated = true; break; } } } if (!isCalculated) return; // Copy results back to table for (var c = 0; c < columnCount; c++) { for (var r = 0; r < rowCount; r++) { const cellNode = tableNode.bodyRows[r].children[c]; if (cellNode === undefined) continue; const gridCell = grid.cells[c][r]; const gridValue = gridCell.outputValue; const cellText = gridValue.formattedValue; cellNode.children = [ new MDTextNode(cellText) ]; if (gridCell.isCalculated) { cellNode.cssClasses.push('calculated'); } cellNode.cssClasses.push(`spreadsheet-type-${gridValue.type}`); if (gridValue.type == CellValue.TYPE_ERROR) { cellNode.attributes['title'] = gridValue.value; } const gridNumber = gridValue.numericValue(); if (gridNumber !== null) { cellNode.attributes['data-numeric-value'] = `${gridNumber}`; } const gridString = gridValue.stringValue(false); if (gridString !== null) { cellNode.attributes['data-string-value'] = gridString; } } } } }