class CellException extends Error { errorSymbol; constructor(message, errorSymbol='#ERROR') { super(message); this.errorSymbol = errorSymbol; } } class CellSyntaxException extends CellException { constructor(message, errorSymbol='#SYNTAX') { super(message, errorSymbol); } } class CellEvaluationException extends CellException {} class CellDependencyException extends CellException { constructor(message, errorSymbol='#REF') { super(message, errorSymbol); } } class CellExpressionTokenType { static Name = new CellExpressionTokenType('Name'); static Address = new CellExpressionTokenType('Address'); static NameOrAddress = new CellExpressionTokenType('NameOrAddress'); static String = new CellExpressionTokenType('String'); static Number = new CellExpressionTokenType('Number'); static OpenParen = new CellExpressionTokenType('OpenParen'); static CloseParen = new CellExpressionTokenType('CloseParen'); static Colon = new CellExpressionTokenType('Colon'); static Plus = new CellExpressionTokenType('Plus'); static Minus = new CellExpressionTokenType('Minus'); static Multiply = new CellExpressionTokenType('Multiply'); static Divide = new CellExpressionTokenType('Divide'); static Comma = new CellExpressionTokenType('Comma'); static Semicolon = new CellExpressionTokenType('Semicolon'); static Ampersand = new CellExpressionTokenType('Ampersand'); static LessThan = new CellExpressionTokenType('LessThan'); static LessThanEqual = new CellExpressionTokenType('LessThanEqual'); static GreaterThan = new CellExpressionTokenType('GreaterThan'); static GreaterThanEqual = new CellExpressionTokenType('GreaterThanEqual'); static Equal = new CellExpressionTokenType('Equal'); static Unequal = new CellExpressionTokenType('Unequal'); static Not = new CellExpressionTokenType('Not'); /** @type {string} */ #name; /** @type {string} */ get name() { return this.#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; } } class CellExpressionOperation { /** Arg is int or float */ static Number = new this('Number'); /** Arg is string without quotes */ static String = new this('String'); /** Arg is bool */ static Boolean = new this('Boolean'); /** Arg is reference address (e.g. "A5") */ static Reference = new this('Reference'); /** Args are start and end addresses (e.g. "A5", "C7") */ static Range = new this('Range'); /** Args are two operand CellExpressions. */ static Add = new this('Add'); /** Args are two operand CellExpressions */ static Subtract = new this('Subtract'); /** Args are two operand CellExpressions */ static Multiply = new this('Multiply'); /** Args are two operand CellExpressions */ static Divide = new this('Divide'); /** Args are two operand CellExpressions. */ static Concatenate = new this('Concatenate'); /** Arg is operand expression */ static UnaryMinus = new this('UnaryMinus'); /** Args are two operand CellExpressions. */ static GreaterThan = new this('GreaterThan'); /** Args are two operand CellExpressions. */ static GreaterThanEqual = new this('GreaterThanEqual'); /** Args are two operand CellExpressions. */ static LessThan = new this('LessThan'); /** Args are two operand CellExpressions. */ static LessThanEqual = new this('LessThanEqual'); /** Args are two operand CellExpressions. */ static Equal = new this('Equal'); /** Args are two operand CellExpressions. */ static Unequal = new this('Unequal'); /** Arg is operand expression. */ static UnaryNot = new this('UnaryNot'); /** Args are 0+ CellExpressions */ static Function = new this('Function'); name; constructor(name) { this.name = name; } toString() { return this.name; } equals(other) { if (!(other instanceof CellExpressionOperation)) return false; return other.name == this.name; } } /** * Collection of all calculated cells in a table. */ 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); this.#processCircularReferences(expressionAddressQueue); } /** * @param {CellAddress[]} 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); const value = cell.originalValue; try { const result = this.#evaluate(cell.parsedExpression, address); cell.isCalculated = true; if (result instanceof CellValue) { cell.outputValue = result; } else if (Array.isArray(result) && result.length == 1) { cell.outputValue = result[0]; } else { throw new CellEvaluationException("Expression did not resolve to a single value"); } } 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; } } } } /** * @param {CellExpression} expression * @param {CellAddress[]} addresses */ #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); } } } } /** * @param {CellAddress[]} addresses */ #processCircularReferences(addresses) { while (addresses.length > 0) { const address = addresses[0]; addresses.splice(0, 1); // We defered these cells repeatedly and couldn't resolve them even // after everything else was calculated. const cell = this.#grid.cellAt(address); cell.outputValue = CellValue.fromValue(new CellDependencyException(`Circular reference at ${address.name}`)); } } /** * @param {CellExpression} expr * @param {CellAddress} address * @returns {CellValue|CellValue[]} */ #evaluate(expr, address) { const result = this.#preevaluate(expr, address); if (result instanceof CellValue) { if (expr.outputType !== null) { return CellValue.fromValue(result.value, expr.outputType ?? result.type, expr.outputDecimals); } } return result; } /** * @param {CellExpression} expr * @param {CellAddress} address * @returns {CellValue|CellValue[]} */ #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.name} 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); } console.warn(`Unhandled operation ${expr.op.name}`); } /** * @param {string} functionName * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #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}"`); } } /** * @param {string} functionName * @param {number} minArgs * @param {number} maxArgs * @param {Array} args * @param {CellAddress} address * @returns {Array} */ #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; } /** * @param {string} functionName * @param {Array} args * @param {CellAddress} address * @param {boolean} errorOnNonnumeric * @param {Array} */ #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; } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #funcAbs(args, address) { const evaled = this.#assertNumericArguments('ABS', 1, 1, args, address); const arg = evaled[0]; if (arg.value < 0.0) { return CellValue.fromValue(0).subtract(arg); } return arg; } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #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); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #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); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #funcCeiling(args, address) { const evaled = this.#assertNumericArguments('CEILING', 1, 1, args, address); const arg = evaled[0]; const newValue = Math.ceil(arg.value); return CellValue.fromValue(newValue, arg.type); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #funcExp(args, address) { const evaled = this.#assertNumericArguments('EXP', 1, 1, args, address); const arg = evaled[0]; const newValue = Math.exp(arg.value); return CellValue.fromValue(newValue, arg.type); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #funcFloor(args, address) { const evaled = this.#assertNumericArguments('FLOOR', 1, 1, args, address); const arg = evaled[0]; const newValue = Math.floor(arg.value); return CellValue.fromValue(newValue, arg.type); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #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]; } } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #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; 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]; } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #funcLn(args, address) { const evaled = this.#assertNumericArguments('LN', 1, 1, args, address); const arg = evaled[0]; const newValue = Math.log(arg.value); return CellValue.fromValue(newValue, arg.type); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #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); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #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()); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #funcMax(args, address) { const 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); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #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); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #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]); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #funcNot(args, address) { if (args.length == 0) { 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); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #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); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #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); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #funcRound(args, address) { const evaled = this.#assertNumericArguments('ROUND', 1, 2, args, address); const val = evaled[0]; const places = sizeof(evaled) > 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); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #funcSqrt(args, address) { if (args.length != 1) { throw new CellEvaluationException("SQRT expects 1 numeric argument"); } const values = args.map((arg) => this.#evaluate(arg, address)); const val = values[0].numericValue(); if (val === null) { throw new CellEvaluationException("SQRT expects 1 numeric argument"); } return CellValue.fromValue(Math.sqrt(val)); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #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(RegExp.escape(search), 'gi'), replace); return CellValue.fromValue(result); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #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; } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #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()); } /** * @param {Array} args * @param {CellAddress} address * @returns {CellValue} */ #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); } } /** * A spreadsheet formula expression. */ class CellExpression { /** @type {CellExpressionOperation} */ op; /** @type {Array} */ arguments; /** @type {string|null} */ qualifier; /** @type {string|null} */ outputType = null; /** @type {number|null} */ outputDecimals = null; /** * Address ranges to copy this expression into for any blank cells. * @type {CellAddressRange[]|null} fillRanges */ fillRanges = null; /** @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; } /** * @param {string} expression * @param {CellAddress} address * @returns {CellExpression|null} */ 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 + ')'); } } /** * @param {CellAddress} start * @param {CellAddress} end * @returns {CellExpression|null} */ transpose(start, end) { var transposed = structuredClone(this); 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, pos + 1) == '=') { // Ignore equals pos++; } this.#skipWhitespace(text, pos); var l = text.length; while (pos < 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[0] < l && (maximumLength === null || sl < maximumLength)) { ch = text.substring(p[0], p[0] + 1); if (!charTest(ch)) break; s += ch; sl++; p[0]++; } if (minimumLength !== null && sl < minimumLength) { return null; } pos[0] = p[0]; 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 {Array|null} */ static #tryArgumentList(tokens, start, end, address) { const count = end - start + 1; if (count == 0) return []; var parenDepth = 0; const argCount = 1; /** @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 ]); 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 = new CellAddress(ref, address); 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.error(`Found infix candidate at ${i} for ${op} priority ${priority}`); candidates.push({ priority: priority, i: i }); } } candidates.sort((a, b) => { if (a.priority < b.priority) return 1; if (a.priority > b.priority) return -1; return 0; }); var bestCandidate = null; for (const candidate of candidates) { try { i = candidate.i; const operand1 = this.#tryExpression(tokens, start, i - 1, address); if (operand1 === null) continue; const 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.error("No best candidate found"); return null; } i = bestCandidate.i; console.error(`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; } /** * @param {string} str * @returns {boolean} */ static #isReferenceName(str) { return /^[a-z]+[0-9]*$/i.exec(str) !== null; } } 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() { 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; }; #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; } /** * 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) { this.#columnIndex = columnIndex; this.#rowIndex = rowIndex; this.#isColumnFixed = isColumnFixed; this.#isRowFixed = isRowFixed; this.#name = (isColumnFixed && columnIndex >= 0 ? '$' : '') + CellAddress.#columnIndexToLetters(columnIndex) + (isRowFixed && rowIndex >= 0 ? '$' : '') + (rowIndex >= 0) ? `${rowIndex + 1}` : ''; } /** * Tests if a string is formatted like an address. * @param {string} text * @returns {boolean} */ static isAddress(text) { return this.fromAddress(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"); } newColumnIndex = this.columnIndex; if (!this.isColumnFixed) { columnDelta = relativeTo.columnIndex - relativeFrom.columnIndex; newColumnIndex += columnDelta; } newRowIndex = this.rowIndex; if (!this.isResolved && resolveToRow) { newRowIndex = relativeFrom.rowIndex; } if (newRowIndex != -1 && !this.isRowAbsolute) { 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} letter * @returns {number} column index */ static #lettersToColumnIndex(letter) { 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; } /** * @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 */ 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); } } class CellAddressRange { /** @type {boolean} */ isResolved; /** @type {number} */ minColumnIndex; /** @type {number} */ maxColumnIndex; /** @type {number} */ minRowIndex; /** @type {number} */ maxRowIndex; /** @type {string} */ name; /** * @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 table's dimensions. * * @param {SpreadsheetGrid} table * @returns {object} iterable object */ cellsIn(table) { const minCol = Math.max(1, this.minColumnIndex); const maxCol = Math.min(this.maxColumnIndex, table.columnCount - 1); const minRow = Math.max(1, this.minRowIndex); const maxRow = Math.min(this.maxRowIndex, table.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; } } 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 ----- /** * 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 PHP value. Based off PHP datatype, not * string formatting; use `fromCellString` to parse formatted numbers. * * @param {any} value * @param {string|null} type * @param {number|null} decimals * @returns {CellValue} */ 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.getErrorSymbol(), value.getMessage(), CellValue.TYPE_ERROR); } return new CellValue('#ERROR', value.getMessage(), 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) { var matches = []; 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; return; } // =FALSE if (caps == 'FALSE') { this.type = CellValue.TYPE_BOOLEAN; this.formattedValue = caps; this.value = 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 = cents.length; this.value = parseFloat(sign + dollars + cents); 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); 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; 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; 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); 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); 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; } } /** * 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 }); } /** * @param {number} dollars * @param {number} decimals * @returns {string} */ static #formatCurrency(dollars, decimals) { var s = (dollars).toLocaleString(undefined, { minimumFractionDigits: 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 }) + '%'; } /** * 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 = `${Math.abs(value)}`; if (/\./.exec(s) === null) return 0; if (s.endsWith('.0')) return 0; const parts = s.split('.'); const whole = parts[0]; var fraction = parts[1]; if (fraction.endsWith('99')) { // More than one 9 at the end points to floating point rounding. Lop em off. fraction = fraction.replace(/[9]+$/, ''); } 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}"]`; } } 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]; } valueAt(address) { return this.cellAt(address)?.originalValue; } outputValueAt(address) { return this.cellAt(address)?.outputValue; } applyToTable() { for (const column of this.cells) { for (const cell of column) { cell.applyToBlock(); } } } /** * @param {MDTableBlock} tableBlock * @param {MDState} state * @returns {SpreadsheetGrid} */ static fromTableBlock(tableBlock, state) { var columnCount = tableBlock.headerRow.cells.length; for (const row of tableBlock.bodyRows) { columnCount = Math.max(columnCount, row.cells.length); } const rowCount = tableBlock.bodyRows.length; const grid = new SpreadsheetGrid(columnCount, rowCount); for (var c = 0; c < columnCount; c++) { for (var r = 0; r < rowCount; r++) { const cellBlock = tableBlock.bodyRows[r].cells[c]; if (cellBlock === undefined) continue; const cell = grid.cells[c][r]; cell.block = cellBlock; cell.originalValue = CellValue.fromCellString(cellBlock.content.toPlaintext(state)); } } return grid; } } class SpreadsheetCell { /** * @type {MDTableCellBlock|null} */ block; /** * @type {CellValue|null} */ originalValue; /** * @type {CellValue|null} */ outputValue = null; /** @type {boolean} */ isCalculated = false; /** @type {CellExpression|null} */ parsedExpression; /** * @type {CellValue|null} */ get resolvedValue() { return this.outputValue ?? this.originalValue; } applyToBlock() { if (this.block === null) return; const resolvedValue = this.outputValue ?? this.originalValue; const num = resolvedValue.numericValue; if (num !== null) { this.block.attributes['data-numeric-value'] = `${num}`; } if (this.outputValue === null) return; this.block.content = new MDInlineBlock(new MDTextSpan(this.outputValue.formattedValue)); this.block.cssClasses.push('calculated', `type-${this.outputValue.type}`); } } class SpreadsheetBlockReader extends MDBlockReader { readBlock(state) { return null; } postProcess(state, blocks) { for (const block of blocks) { if (block instanceof MDTableBlock) { this.#processTable(tableBlock, state); } } } /** * @param {MDTableBlock} tableBlock * @param {MDState} state */ #processTable(tableBlock, state) { const grid = SpreadsheetGrid.fromTableBlock(tableBlock, state); // TODO } }