errorSymbol = $errorSymbol; } } /** * Exception thrown when a spreadsheet expression is invalid. */ class CellSyntaxException extends CellException { public function __construct(string $message, string $errorSymbol='#SYNTAX') { parent::__construct($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 { public function __construct(string $message, string $errorSymbol='#REF') { parent::__construct($message, $errorSymbol); } } /** * Expression parsing token types. */ enum CellExpressionTokenType { case Name; case Address; case NameOrAddress; case String; case Number; case OpenParen; case CloseParen; case Colon; case Plus; case Minus; case Multiply; case Divide; case Comma; case Semicolon; case Ampersand; case LessThan; case LessThanEqual; case GreaterThan; case GreaterThanEqual; case Equal; case Unequal; case Not; public function isPotentialName(): bool { return $this === self::Name || $this === self::NameOrAddress; } public function isPotentialAddress(): bool { return $this === self::Address || $this === self::NameOrAddress; } } /** * Type of operation for a `CellExpression`. Named functions all fall under * `Function`. */ enum CellExpressionOperation { /** Arg is `float` */ case Number; /** Arg is `string` without quotes */ case String; /** Arg is `bool` */ case Boolean; /** Arg is `CellAddress` */ case Reference; /** Args are start and end `CellAddress`es (e.g. "A5", "C7") */ case Range; /** Args are two operand `CellExpression`s. */ case Add; /** Args are two operand `CellExpression`s */ case Subtract; /** Args are two operand `CellExpression`s */ case Multiply; /** Args are two operand `CellExpression`s */ case Divide; /** Args are two operand `CellExpression`s. */ case Concatenate; /** Arg is operand `CellExpression` */ case UnaryMinus; /** Args are two operand `CellExpression`s. */ case GreaterThan; /** Args are two operand `CellExpression`s. */ case GreaterThanEqual; /** Args are two operand `CellExpression`s. */ case LessThan; /** Args are two operand `CellExpression`s. */ case LessThanEqual; /** Args are two operand `CellExpression`s. */ case Equal; /** Args are two operand `CellExpression`s. */ case Unequal; /** Arg is operand `CellExpression`. */ case UnaryNot; /** Args are 0+ `CellExpression`s */ case Function; } /** * Collection of all calculated cells in a table. Evaluates formulas. */ class CellExpressionSet { private SpreadsheetGrid $grid; public function __construct(SpreadsheetGrid $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. */ public function calculateCells() { $rowCount = $this->grid->rowCount; $colCount = $this->grid->columnCount; // Make queue of cell addresses with expressions in them /** @var CellAddress[] */ $expressionAddressQueue = []; $range = new CellAddressRange(new CellAddress(0, 0), new CellAddress($colCount - 1, $rowCount - 1)); foreach ($range->cellsIn($this->grid) as $addressStr => $cell) { $address = CellAddress::fromString($addressStr); if ($address === null) { error_log("Couldn't parse address string {$addressStr}!"); } $value = $cell->originalValue; if ($value->type !== CellValue::TYPE_FORMULA) { $cell->outputValue = $value; $cell->isCalculated = false; continue; } try { $expression = CellExpression::parse($value->formattedValue, $address); if (!$expression) { throw new CellSyntaxException("Invalid expression"); } $cell->parsedExpression = $expression; $cell->isCalculated = true; array_push($expressionAddressQueue, $address); $this->enqueueFilledBlanks($expression, $expressionAddressQueue); } catch (CellException $e) { $cell->outputValue = CellValue::fromValue($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 */ private function processExpressionQueue(array &$addresses) { $requeueCount = 0; while (count($addresses) > 0 && $requeueCount < count($addresses)) { $address = $addresses[0]; array_splice($addresses, 0, 1); $cell = $this->grid->cellAt($address); try { $result = $this->evaluate($cell->parsedExpression, $address); $cell->isCalculated = true; if ($result instanceof CellValue) { $cell->outputValue = $result; $requeueCount = 0; } elseif (is_array($result)) { if (count($result) === 1) { $cell->outputValue = $result[0]; $requeueCount = 0; } else { throw new CellEvaluationException("Expression resolved to " . count($result) . " values, single value expected"); } } else { $typename = gettype($result) === 'object' ? get_class($result) : gettype($result); throw new CellEvaluationException( "Expression resolved to {$typename}, expected CellValue"); } } catch (CellDependencyException $e) { // Depends on a value that hasn't been calculated yet array_push($addresses, $address); $requeueCount++; } catch (CellSyntaxException | CellEvaluationException $e) { $cell->outputValue = CellValue::fromValue($e); $requeueCount = 0; } } } /** * 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 */ private function enqueueFilledBlanks(CellExpression $expression, array &$addresses) { foreach ($expression->fillRanges ?? [] as $range) { foreach ($range->cellsIn($this->grid) as $filledAddressStr => $filledCell) { $filledAddress = CellAddress::fromString($filledAddressStr); 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; array_push($addresses, $filledAddress); } } } } /** * Marks all cells at the given addresses as circular references. * * @param CellAddress[] $addresses mutable address queue */ private function processCircularReferences(array $addresses) { foreach ($addresses as $address) { $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 * @return CellValue|CellValue[] results */ private function evaluate(CellExpression $expr, CellAddress $address): CellValue|array { $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 * @return CellValue|CellValue[] results * @throws CellException if evaluation fails for any reason */ private function preevaluate(CellExpression $expr, CellAddress $address): CellValue|array { switch ($expr->op) { case CellExpressionOperation::Number: case CellExpressionOperation::String: case CellExpressionOperation::Boolean: return $expr->arguments[0]; case CellExpressionOperation::Reference: { $refAddress = $expr->arguments[0]; $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: { $range = $expr->arguments[0]; $values = []; foreach ($range->cellsIn($this->grid) as $rAddressStr => $cell) { $rAddress = CellAddress::fromString($rAddressStr); if ($rAddress->equals($address)) continue; $val = $this->grid->outputValueAt($rAddress); if ($val === null) { throw new CellDependencyException("Need calculated value for {$rAddress->name} to evaluate"); } array_push($values, $val); } return $values; } case CellExpressionOperation::Add: { $op1 = $this->evaluate($expr->arguments[0], $address); $op2 = $this->evaluate($expr->arguments[1], $address); return $op1->add($op2); } case CellExpressionOperation::Subtract: { $op1 = $this->evaluate($expr->arguments[0], $address); $op2 = $this->evaluate($expr->arguments[1], $address); return $op1->subtract($op2); } case CellExpressionOperation::Multiply: { $op1 = $this->evaluate($expr->arguments[0], $address); $op2 = $this->evaluate($expr->arguments[1], $address); return $op1->multiply($op2); } case CellExpressionOperation::Divide: { $op1 = $this->evaluate($expr->arguments[0], $address); $op2 = $this->evaluate($expr->arguments[1], $address); return $op1->divide($op2); } case CellExpressionOperation::UnaryMinus: { $op = $this->evaluate($expr->arguments[0], $address); return CellValue::fromValue(0)->subtract($op); } case CellExpressionOperation::GreaterThan: { $op1 = $this->evaluate($expr->arguments[0], $address); $op2 = $this->evaluate($expr->arguments[1], $address); return $op1->gt($op2); } case CellExpressionOperation::GreaterThanEqual: { $op1 = $this->evaluate($expr->arguments[0], $address); $op2 = $this->evaluate($expr->arguments[1], $address); return $op1->gte($op2); } case CellExpressionOperation::LessThan: { $op1 = $this->evaluate($expr->arguments[0], $address); $op2 = $this->evaluate($expr->arguments[1], $address); return $op1->lt($op2); } case CellExpressionOperation::LessThanEqual: { $op1 = $this->evaluate($expr->arguments[0], $address); $op2 = $this->evaluate($expr->arguments[1], $address); return $op1->lte($op2); } case CellExpressionOperation::Equal: { $op1 = $this->evaluate($expr->arguments[0], $address); $op2 = $this->evaluate($expr->arguments[1], $address); return $op1->eq($op2); } case CellExpressionOperation::Unequal: { $op1 = $this->evaluate($expr->arguments[0], $address); $op2 = $this->evaluate($expr->arguments[1], $address); return $op1->neq($op2); } case CellExpressionOperation::UnaryNot: { $op = $this->evaluate($expr->arguments[0], $address); return $op->not(); } case CellExpressionOperation::Concatenate: { $op1 = $this->evaluate($expr->arguments[0], $address); $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 * @return CellValue result * @throws CellException if evaluation fails for any reason */ private function callFunction(string $functionName, array $args, CellAddress $address): CellValue { switch (mb_strtoupper($functionName)) { 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 'ISBLANK': return $this->funcIsBlank($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 int $minArgs minimum required arguments * @param int $maxArgs maximum required arguments * @param array $args raw arguments * @param CellAddress $address address of the formula * @return CellValue[] numeric arguments * @throws CellSyntaxException if wrong number of arguments is passed * @throws CellEvaluationException if an argument does not resolve to a numeric value */ private function assertNumericArguments(string $functionName, int $minArgs, int $maxArgs, array $args, CellAddress $address): array { $argCount = count($args); 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}"); } $out = []; foreach ($args as $argument) { $evaled = $this->evaluate($argument, $address); if (!($evaled instanceof CellValue) || !$evaled->isNumeric()) { throw new CellEvaluationException("{$functionName}() expects numeric arguments"); } array_push($out, $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 bool $errorOnNonnumeric whether to throw an exception if * non-numeric arguments are encountered, otherwise they're skipped silently * @return CellValue[] flattened array of numeric values */ private function flattenedNumericArguments(string $functionName, array $args, CellAddress $address, bool $errorOnNonnumeric=true): array { $flattened = []; foreach ($args as $argument) { $evaled = $this->evaluate($argument, $address); if ($evaled instanceof CellValue) { if (!$evaled->isNumeric()) { if ($errorOnNonnumeric) { throw new CellEvaluationException("{$functionName} requires numeric arguments"); } continue; } array_push($flattened, $evaled); } elseif (is_array($evaled)) { $arr = $evaled; foreach ($arr as $arrayArgument) { if ($arrayArgument instanceof CellValue) { if (!$arrayArgument->isNumeric()) { if ($errorOnNonnumeric) { throw new CellEvaluationException("{$functionName} requires numeric arguments"); } continue; } array_push($flattened, $arrayArgument); } } } } return $flattened; } /** * `ABS(value)` - absolute value of a numeric argument * * @param array $args raw arguments * @param CellAddress $address expression location * @return CellValue result */ private function funcAbs(array $args, CellAddress $address): CellValue { $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 * @return CellValue result */ private function funcAnd(array $args, CellAddress $address): CellValue { if (count($args) === 0) { throw new CellEvaluationException("AND requires one or more arguments"); } $values = $this->flattenedNumericArguments('AND', $args, $address, false); foreach ($values as $value) { $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 * @return CellValue result */ private function funcAverage(array $args, CellAddress $address): CellValue { $sum = CellValue::fromValue(0); $count = 0; foreach ($args as $arg) { $val = $this->evaluate($arg, $address); if (is_array($val)) { foreach ($val as $elem) { if (!$elem->isNumeric()) continue; $sum = $sum->add($elem); $count++; } } elseif ($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 * @return CellValue result */ private function funcCeiling(array $args, CellAddress $address): CellValue { $arg = $this->assertNumericArguments('CEILING', 1, 1, $args, $address)[0]; $newValue = 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 * @return CellValue result */ private function funcExp(array $args, CellAddress $address): CellValue { $arg = $this->assertNumericArguments('EXP', 1, 1, $args, $address)[0]; $newValue = 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 * @return CellValue result */ private function funcFloor(array $args, CellAddress $address): CellValue { $arg = $this->assertNumericArguments('FLOOR', 1, 1, $args, $address)[0]; $newValue =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 * @return CellValue result */ private function funcIf(array $args, CellAddress $address): CellValue { if (count($args) !== 3) { throw new CellEvaluationException("IF expects three arguments"); } $evaled = array_map(fn($arg) => $this->evaluate($arg, $address), $args); $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 * @return CellValue result */ private function funcIfs(array $args, CellAddress $address): CellValue { if (count($args) < 3) { throw new CellEvaluationException("IFS expects at least 3 arguments"); } if ((count($args) % 2) !== 1) { throw new CellEvaluationException("IFS expects an odd number of arguments"); } $evaled = array_map(fn($arg) => $this->evaluate($arg, $address), $args); for ($i = 0; $i < count($evaled) - 1; $i += 2) { $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[count($evaled) - 1]; } /** * `IFBLANK(value)` - Returns `TRUE` if the given value is blank, otherwise * `FALSE`. * * @param array $args raw arguments * @param CellAddress $address expression location * @return CellValue result */ private function funcIsBlank(array $args, CellAddress $address): CellValue { if (sizeof($args) !== 1) { throw new CellEvaluationException("IFBLANK expects 1 argument"); } $arg = $this->evaluate($args[0], $address); if (!($arg instanceof CellValue)) { throw new CellEvaluationException("IFBLANK expcts 1 argument"); } return CellValue::fromValue($arg->type === CellValue::TYPE_BLANK); } /** * `LN(value)` - Natural log. * * @param array $args raw arguments * @param CellAddress $address expression location * @return CellValue result */ private function funcLn(array $args, CellAddress $address): CellValue { $arg = $this->assertNumericArguments('LN', 1, 1, $args, $address)[0]; $newValue = 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 * @return CellValue result */ private function funcLog(array $args, CellAddress $address): CellValue { $evaled = $this->assertNumericArguments('LOG', 1, 2, $args, $address); $exponent = $evaled[0]; $base = (count($evaled) > 1) ? $evaled[1]->value : 10.0; $newValue = log($exponent->value) / 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 * @return CellValue result */ private function funcLower(array $args, CellAddress $address): CellValue { if (count($args) !== 1) { throw new CellEvaluationException("LOWER requires one argument"); } $evaled = array_map(fn($arg) => $this->evaluate($arg, $address), $args); $s = $evaled[0]->stringValue(true); if ($s === null) { throw new CellEvaluationException("LOWER requires one string argument"); } return CellValue::fromValue(mb_strtolower($s)); } /** * `MAX(arg, arg, ... arg)` - Returns the maximum numeric value. * * @param array $args raw arguments * @param CellAddress $address expression location * @return CellValue result */ private function funcMax(array $args, CellAddress $address): CellValue { $maxValue = null; $flattened = $this->flattenedNumericArguments('MAX', $args, $address); if (count($flattened) === 0) { throw new CellEvaluationException("MAX requires at least one numeric argument"); } foreach ($flattened as $argument) { 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 * @return CellValue result */ private function funcMin(array $args, CellAddress $address): CellValue { $minValue = null; $flattened = $this->flattenedNumericArguments('MIN', $args, $address); if (count($flattened) === 0) { throw new CellEvaluationException("MIN requires at least one numeric argument"); } foreach ($flattened as $argument) { 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 * @return CellValue result */ private function funcMod(array $args, CellAddress $address): CellValue { if (count($args) !== 2) { throw new CellEvaluationException("MOD requires two numeric arguments"); } $values = array_map(fn($arg) => $this->evaluate($arg, $address), $args); return $values[0]->modulo($values[1]); } /** * `NOT(value)` - Boolean NOT of a Boolean value. * * @param array $args raw arguments * @param CellAddress $address expression location * @return CellValue result */ private function funcNot(array $args, CellAddress $address): CellValue { if (count($args) !== 1) { throw new CellEvaluationException("NOT expects one argument"); } $evaled = array_map(fn($arg) => $this->evaluate($arg, $address), $args); $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 * @return CellValue result */ private function funcOr(array $args, CellAddress $address): CellValue { if (count($args) === 0) { throw new CellEvaluationException("OR requires one or more arguments"); } $values = $this->flattenedNumericArguments('OR', $args, $address, false); foreach ($values as $value) { $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 * @return CellValue result */ private function funcPower(array $args, CellAddress $address): CellValue { $evaled = $this->assertNumericArguments('POWER', 2, 2, $args, $address); $base = $evaled[0]; $exp = $evaled[1]; $val = 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 * @return CellValue result */ private function funcRound(array $args, CellAddress $address): CellValue { $evaled = $this->assertNumericArguments('ROUND', 1, 2, $args, $address); $val = $evaled[0]; $places = count($evaled) > 1 ? $evaled[1]->value : 0; $divider = pow(10.0, $places); $newValue = 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 * @return CellValue result */ private function funcSqrt(array $args, CellAddress $address): CellValue { $arg = $this->assertNumericArguments('SQRT', 1, 1, $args, $address)[0]; $val = $arg->numericValue(); return CellValue::fromValue(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 * @return CellValue result */ private function funcSubstitute(array $args, CellAddress $address): CellValue { if (count($args) !== 3) { throw new CellEvaluationException("SUBSTITUTE expects 3 string arguments"); } $values = array_map(fn($arg) => $this->evaluate($arg, $address), $args); $text = $values[0]->stringValue(); $search = $values[1]->stringValue(); $replace = $values[2]->stringValue(); if ($text === null || $search === null || $replace === null) { throw new CellEvaluationException("SUBSTITUTE expects 3 string arguments"); } $result = str_replace($search, $replace, $text); return CellValue::fromValue($result); } /** * `SUM(arg, arg, ... arg)` - Calculates the sum of numeric arguments. * * @param array $args raw arguments * @param CellAddress $address expression location * @return CellValue result */ private function funcSum(array $args, CellAddress $address): CellValue { $sum = CellValue::fromValue(0); foreach ($args as $arg) { $val = $this->evaluate($arg, $address); if (is_array($val)) { foreach ($val as $elem) { if ($elem->isNumeric()) $sum = $sum->add($elem); } } elseif ($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 * @return CellValue result */ private function funcUpper(array $args, CellAddress $address): CellValue { if (count($args) !== 1) { throw new CellEvaluationException("UPPER requires one argument"); } $evaled = array_map(fn($arg) => $this->evaluate($arg, $address), $args); $s = $evaled[0]->stringValue(true); if ($s === null) { throw new CellEvaluationException("UPPER requires one string argument"); } return CellValue::fromValue(mb_strtoupper($s)); } /** * `XOR(arg, arg, ... arg)` - Boolean XOR of one or more Boolean arguments. * * @param array $args raw arguments * @param CellAddress $address expression location * @return CellValue result */ private function funcXor(array $args, CellAddress $address): CellValue { if (count($args) === 0) { throw new CellEvaluationException("XOR requires one or more arguments"); } $values = $this->flattenedNumericArguments('XOR', $args, $address, false); $result = null; foreach ($values as $value) { $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. */ public CellExpressionOperation $op; /** * Mixed array of `CellValue`, `CellAddress`, `CellAddressRange`, * `CellExpression`, perhaps more. */ public array $arguments; /** * For `Function`, the function name. */ public ?string $qualifier; /** * Optional format override. One of `"number"`, `"currency"`, `"percent"`. */ public ?string $outputType = null; /** * Optional decimal place formatting override. */ public ?int $outputDecimals = null; /** * Address ranges to copy this expression into for any blank cells. Used * by formulas with the `FILL` modifier. * @var CellAddressRange[]|null $fillRanges */ public ?array $fillRanges = null; /** * Source address of the formula. */ public ?CellAddress $location = null; /** * @param CellExpressionOperation $op * @param array $args * @param ?string $qualifier */ public function __construct(CellExpressionOperation $op, array $args, ?string $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 * @return ?CellExpression parsed expression, or `null` if it failed */ public static function parse(string $expression, CellAddress $address): ?CellExpression { $tokens = self::expressionToTokens($expression); if (count($tokens) === 0) return null; $expr = self::expressionFromTokens($tokens, $address); $expr->location = $address; return $expr; } /** * Writes an expression tree to `error_log` for debugging purposes. * * @param CellExpression $expression * @param string $indent */ public static function dumpExpression(CellExpression $expression, string $indent = '') { if (count($expression->arguments) === 0) { error_log($indent . "expr " . $expression->op->name . "()"); } else { error_log($indent . $expression->op->name . '('); foreach ($expression->arguments as $argument) { if (is_numeric($argument)) { error_log($indent . "\t{$argument}"); } elseif (is_string($argument)) { error_log($indent . "\t\"{$argument}\""); } elseif (is_bool($argument)) { error_log($indent . "\t" . ($argument ? "true" : "false")); } elseif ($argument instanceof CellAddress) { error_log($indent . "\t" . $argument->name); } elseif ($argument instanceof CellAddressRange) { error_log($indent . "\t" . $argument->name); } elseif ($argument instanceof CellValue) { error_log($indent . "\t" . $argument->type . " " . $argument->formattedValue); } elseif ($argument instanceof CellExpression) { $this->dumpExpression($argument, $indent + "\t"); } else { error_log($indent . "\t" . gettype($argument)); } } error_log($indent . ')'); } } private function clone(): CellExpression { $cp = new CellExpression($this->op, array($this->arguments), $this->qualifier); $cp->outputType = $this->outputType; $cp->outputDecimals = $this->outputDecimals; $cp->fillRanges = $this->fillRanges !== null ? array($this->fillRanges) : 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. */ public function transpose(CellAddress $start, CellAddress $end): ?CellExpression { $transposed = $this->clone(); // structuredClone makes a mess of typing $transposed->arguments = []; foreach ($this->arguments as $argument) { if ($argument instanceof CellExpression) { array_push($transposed->arguments, $argument->transpose($start, $end)); } elseif ($argument instanceof CellAddress) { array_push($transposed->arguments, $argument->transpose($start, $end)); } elseif ($argument instanceof CellAddressRange) { array_push($transposed->arguments, $argument->transpose($start, $end)); } else { array_push($transposed->arguments, $argument); } } return $transposed; } // -- Tokenizing -------------------------------------------------------- /** * Converts an expression into an array of tokens. * @param string $text expression * @return CellExpressionToken[] tokens */ public static function expressionToTokens(string $text): array { $tokens = []; $pos = 0; self::skipWhitespace($text, $pos); if (mb_substr($text, $pos, 1) === '=') { // Ignore equals $pos++; } self::skipWhitespace($text, $pos); $l = mb_strlen($text); while ($pos < $l) { array_push($tokens, self::readNextToken($text, $pos)); self::skipWhitespace($text, $pos); } return $tokens; } /** * @param string $text * @param int $pos * @return CellExpressionToken */ private static function readNextToken(string $text, int &$pos): CellExpressionToken { // Single char tokens if ($token = self::readNextSimpleToken($text, $pos, '==', CellExpressionTokenType::Equal)) return $token; if ($token = self::readNextSimpleToken($text, $pos, '!=', CellExpressionTokenType::Unequal)) return $token; if ($token = self::readNextSimpleToken($text, $pos, '<=', CellExpressionTokenType::LessThanEqual)) return $token; if ($token = self::readNextSimpleToken($text, $pos, '>=', CellExpressionTokenType::GreaterThanEqual)) return $token; if ($token = self::readNextSimpleToken($text, $pos, '<', CellExpressionTokenType::LessThan)) return $token; if ($token = self::readNextSimpleToken($text, $pos, '>', CellExpressionTokenType::GreaterThan)) return $token; if ($token = self::readNextSimpleToken($text, $pos, '!', CellExpressionTokenType::Not)) return $token; if ($token = self::readNextSimpleToken($text, $pos, '+', CellExpressionTokenType::Plus)) return $token; if ($token = self::readNextSimpleToken($text, $pos, '-', CellExpressionTokenType::Minus)) return $token; if ($token = self::readNextSimpleToken($text, $pos, '*', CellExpressionTokenType::Multiply)) return $token; if ($token = self::readNextSimpleToken($text, $pos, '/', CellExpressionTokenType::Divide)) return $token; if ($token = self::readNextSimpleToken($text, $pos, ',', CellExpressionTokenType::Comma)) return $token; if ($token = self::readNextSimpleToken($text, $pos, '(', CellExpressionTokenType::OpenParen)) return $token; if ($token = self::readNextSimpleToken($text, $pos, ')', CellExpressionTokenType::CloseParen)) return $token; if ($token = self::readNextSimpleToken($text, $pos, ':', CellExpressionTokenType::Colon)) return $token; if ($token = self::readNextSimpleToken($text, $pos, ';', CellExpressionTokenType::Semicolon)) return $token; if ($token = self::readNextSimpleToken($text, $pos, '&', CellExpressionTokenType::Ampersand)) return $token; // Other tokens if ($token = self::readNextAddressToken($text, $pos)) return $token; if ($token = self::readNextNameToken($text, $pos)) return $token; if ($token = self::readNextNumberToken($text, $pos)) return $token; if ($token = self::readNextStringToken($text, $pos)) return $token; $ch = mb_substr($text, $pos, 1); throw new CellSyntaxException("Unexpected character \"{$ch}\" at {$pos}"); } private static function skipWhitespace(string $text, int &$pos) { $l = mb_strlen($text); while ($pos < $l) { $ch = mb_substr($text, $pos, 1); if ($ch === ' ' || $ch === "\t" || $ch === "\n" || $ch === "\r") { $pos++; } else { return; } } } private static function readNextSimpleToken(string $text, int &$pos, string $target, CellExpressionTokenType $type): ?CellExpressionToken { $remaining = mb_strlen($text) - $pos; $l = mb_strlen($target); if ($l > $remaining) return null; $test = mb_substr($text, $pos, $l); if (mb_strtoupper($test) !== mb_strtoupper($target)) return null; $pos += $l; return new CellExpressionToken($type, $test); } private static function readNextAddressToken(string $text, int &$pos): ?CellExpressionToken { $p = $pos; $ch = mb_substr($text, $p, 1); $address = ''; $isName = true; if ($ch === '$') { $address .= $ch; $isName = false; $p++; } $col = self::readChars($text, $p, fn($s) => self::isLetter($s), 1, 2); if ($col === null) return null; $address .= $col; $ch = mb_substr($text, $p, 1); if ($ch === '$') { $address .= $ch; $isName = false; $p++; $row = self::readChars($text, $p, fn($s) => self::isDigit($s), 1); if ($row === null) return null; $address .= $row; } else { $row = self::readChars($text, $p, fn($s) => self::isDigit($s), 0); if ($row === null) return null; $address .= $row; } $pos = $p; return new CellExpressionToken( $isName ? CellExpressionTokenType::NameOrAddress : CellExpressionTokenType::Address, $address); } private static function readNextNameToken(string $text, int &$pos): ?CellExpressionToken { $p = $pos; $name = self::readChars($text, $p, fn($s) => self::isLetter($s), 1); if ($name === null) return null; $pos = $p; if (CellAddress::isAddress($name)) { return new CellExpressionToken(CellExpressionTokenType::NameOrAddress, $name); } return new CellExpressionToken(CellExpressionTokenType::Name, $name); } private static function readNextNumberToken(string $text, int &$pos): ?CellExpressionToken { $ch = mb_substr($text, $pos, 1); if (!self::isDigit($ch)) return null; $l = mb_strlen($text); $numStr = $ch; $pos++; while ($pos < $l) { $ch = mb_substr($text, $pos, 1); if (self::isDigit($ch)) { $pos++; $numStr .= $ch; } else { break; } } if ($pos < $l) { $ch = mb_substr($text, $pos, 1); if ($ch === '.') { $numStr .= $ch; $pos++; while ($pos < $l) { $ch = mb_substr($text, $pos, 1); if (self::isDigit($ch)) { $pos++; $numStr .= $ch; } else { break; } } } } return new CellExpressionToken(CellExpressionTokenType::Number, $numStr); } private static function readNextStringToken(string $text, int &$pos): ?CellExpressionToken { $ch = mb_substr($text, $pos, 1); if ($ch !== '"') return null; $str = ''; $pos++; $l = mb_strlen($text); $inEscape = false; while ($pos < $l) { $ch = mb_substr($text, $pos, 1); $pos++; if ($inEscape) { $inEscape = false; if ($ch === '\\' || $ch === '"') { $str .= $ch; } else { throw new CellSyntaxException("Bad string escape sequence \"\\{$ch}\""); } } elseif ($ch === '\\') { $inEscape = true; } elseif ($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 int $pos * @param callable $charTest * @param ?int $minimumLength * @param ?int $maximumLength * @return ?string */ private static function readChars(string $text, int &$pos, callable $charTest, ?int $minimumLength = null, ?int $maximumLength = null): ?string { $p = $pos; $l = mb_strlen($text); $s = ''; $sl = 0; while ($p < $l && ($maximumLength === null || $sl < $maximumLength)) { $ch = mb_substr($text, $p, 1); if (!$charTest($ch)) break; $s .= $ch; $sl++; $p++; } if ($p < $l && $charTest(mb_substr($text, $p, 1))) { return null; } if ($minimumLength !== null && $sl < $minimumLength) { return null; } $pos = $p; return $s; } /** * @param string $ch * @return bool */ private static function isLetter(string $ch): bool { $ord = ord($ch); return ($ord >= 65 && $ord <= 90) || ($ord >= 97 && $ord <= 122); } /** * @param string $ch * @return bool */ private static function isDigit(string $ch): bool { $ord = ord($ch); return ($ord >= 48 && $ord <= 57); } // -- Parsing ----------------------------------------------------------- /** * @param array $tokens * @param CellAddress $address * @return ?CellExpression */ public static function expressionFromTokens(array $tokens, CellAddress $address): ?CellExpression { if ($expr = self::tryExpressionAndFormat($tokens, 0, count($tokens) - 1, $address)) return $expr; if ($expr = self::tryExpressionAndFill($tokens, 0, count($tokens) - 1, $address)) return $expr; if ($expr = self::tryExpression($tokens, 0, count($tokens) - 1, $address)) return $expr; return null; } /** * @param array $tokens * @param int $start * @param int $end * @param CellAddress $address * @return ?CellExpression */ private static function tryExpressionAndFormat(array $tokens, int $start, int $end, CellAddress $address): ?CellExpression { for ($t = $start + 1; $t < $end; $t++) { if ($tokens[$t]->type === CellExpressionTokenType::Semicolon) { $expr = self::tryExpressionAndFill($tokens, $start, $t - 1, $address) ?? self::tryExpression($tokens, $start, $t - 1, $address); if ($expr === null) return null; $format = self::tryFormat($tokens, $t + 1, $end, $address); if ($format === null) return null; [ $expr->outputType, $expr->outputDecimals ] = $format; return $expr; } } return null; } /** * @param CellExpressionToken[] $tokens * @param int $start * @param int $end * @param CellAddress $address * @return ?CellExpression */ private static function tryExpressionAndFill(array $tokens, int $start, int $end, CellAddress $address): ?CellExpression { $count = $end - $start + 1; if ($count < 2) return null; if (!$tokens[$end]->type->isPotentialName()) return null; $name = mb_strtoupper($tokens[$end]->content); if ($name !== 'FILL') return null; $exp = self::tryExpression($tokens, $start, $end - 1, $address); $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 int $start * @param int $end * @param CellAddress $address * @return ?array CellValue type and decimal places */ private static function tryFormat(array $tokens, int $start, int $end, CellAddress $address): ?array { $count = $end - $start + 1; if ($count < 0 || $count > 2) return null; if (!$tokens[$start]->type->isPotentialName()) return null; $type = mb_strtolower($tokens[$start]->content); if (!CellValue::isTypeNumeric($type)) return null; if ($count > 1) { if ($tokens[$start + 1]->type !== CellExpressionTokenType::Number) return null; $decimals = intval($tokens[$start + 1]->content); } else { $decimals = null; } return [ $type, $decimals ]; } /** * @param CellExpressionToken[] $tokens * @param int $start * @param int $end * @param CellAddress $address * @return CellExpression */ private static function tryExpression(array $tokens, int $start, int $end, CellAddress $address): CellExpression { if ($expr = self::tryParenExpression($tokens, $start, $end, $address)) return $expr; if ($expr = self::tryNumber($tokens, $start, $end, $address)) return $expr; if ($expr = self::tryString($tokens, $start, $end, $address)) return $expr; if ($expr = self::tryBoolean($tokens, $start, $end, $address)) return $expr; if ($expr = self::tryFunction($tokens, $start, $end, $address)) return $expr; if ($expr = self::tryRange($tokens, $start, $end, $address)) return $expr; if ($expr = self::tryReference($tokens, $start, $end, $address)) return $expr; if ($expr = self::tryInfix($tokens, $start, $end, $address)) return $expr; if ($expr = self::tryUnary($tokens, $start, $end, $address)) return $expr; throw new CellSyntaxException("Invalid expression"); } /** * @param CellExpressionToken[] $tokens * @param int $start * @param int $end * @param CellAddress $address * @return CellExpression|null */ private static function tryParenExpression(array $tokens, int $start, int $end, CellAddress $address): ?CellExpression { if ($tokens[$start]->type !== CellExpressionTokenType::OpenParen) return null; if ($tokens[$end]->type !== CellExpressionTokenType::CloseParen) return null; $parenLevel = 0; for ($t = $start + 1; $t < $end; $t++) { if ($tokens[$t]->type === CellExpressionTokenType::OpenParen) { $parenLevel++; } elseif ($tokens[$t]->type === CellExpressionTokenType::CloseParen) { $parenLevel--; } if ($parenLevel < 0) return null; } if ($parenLevel !== 0) return null; return self::tryExpression($tokens, $start + 1, $end - 1, $address); } /** * @param CellExpressionToken[] $tokens * @param int $start * @param int $end * @param CellAddress $address * @return CellExpression|null */ private static function tryNumber(array $tokens, int $start, int $end, CellAddress $address): ?CellExpression { if ($tokens[$end]->type !== CellExpressionTokenType::Number) return null; if ($end > $start + 1) return null; $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 int $start * @param int $end * @param CellAddress $address * @return CellExpression|null */ private static function tryString(array $tokens, int $start, int $end, CellAddress $address): ?CellExpression { if ($start !== $end) return null; if ($tokens[$start]->type !== CellExpressionTokenType::String) return null; $str = $tokens[$start]->content; return new CellExpression(CellExpressionOperation::String, [ new CellValue($str, $str, CellValue::TYPE_STRING, 0) ]); } /** * @param CellExpressionToken[] $tokens * @param int $start * @param int $end * @param CellAddress $address * @return CellExpression|null */ private static function tryBoolean(array $tokens, int $start, int $end, CellAddress $address): ?CellExpression { if ($start !== $end) return null; if (!$tokens[$start]->type->isPotentialName()) return null; $str = mb_strtoupper($tokens[$start]->content); if ($str !== 'TRUE' && $str !== 'FALSE') return null; return new CellExpression(CellExpressionOperation::Boolean, [ new CellValue($str, $str === 'TRUE', CellValue::TYPE_BOOLEAN) ]); } /** * @param CellExpressionToken[] $tokens * @param int $start * @param int $end * @param CellAddress $address * @return CellExpression|null */ private static function tryFunction(array $tokens, int $start, int $end, CellAddress $address): ?CellExpression { $count = $end - $start + 1; if ($count < 3) return null; if (!$tokens[$start]->type->isPotentialName()) return null; $qualifier = $tokens[$start]->content; if ($tokens[$start + 1]->type !== CellExpressionTokenType::OpenParen) return null; if ($tokens[$end]->type !== CellExpressionTokenType::CloseParen) return null; $argList = self::tryArgumentList($tokens, $start + 2, $end - 1, $address); if ($argList === null) return null; return new CellExpression(CellExpressionOperation::Function, $argList, $qualifier); } /** * @param CellExpressionToken[] $tokens * @param int $start * @param int $end * @param CellAddress $address * @return CellExpression[]|null */ private static function tryArgumentList(array $tokens, int $start, int $end, CellAddress $address): ?array { $count = $end - $start + 1; if ($count === 0) return []; $parenDepth = 0; $argCount = 1; // Populate argTokens with tuples of start and end token indices for each arg. /** @type {int[][]} */ $argTokens = []; // argindex -> [ start, end ] $exprStartToken = $start; for ($i = $start; $i <= $end; $i++) { if ($tokens[$i]->type === CellExpressionTokenType::OpenParen) { $parenDepth++; } elseif ($tokens[$i]->type === CellExpressionTokenType::CloseParen) { $parenDepth--; } elseif ($tokens[$i]->type === CellExpressionTokenType::Comma && $parenDepth === 0) { $exprEndToken = $i - 1; array_push($argTokens, [ $exprStartToken, $exprEndToken ]); $exprStartToken = $i + 1; } } array_push($argTokens, [ $exprStartToken, $end ]); // Convert token ranges to expressions $args = []; foreach ($argTokens as $argToken) { $arg = self::tryExpression($tokens, $argToken[0], $argToken[1], $address); if ($arg === null) return null; array_push($args, $arg); } return $args; } /** * @param CellExpressionToken[] $tokens * @param int $start * @param int $end * @param CellAddress $address * @return CellExpression|null */ private static function tryRange(array $tokens, int $start, int $end, CellAddress $address): ?CellExpression { $count = $end - $start + 1; if ($count !== 3) return null; if (!$tokens[$start]->type->isPotentialAddress()) return null; $first = mb_strtoupper($tokens[$start]->content); if ($tokens[$start + 1]->type !== CellExpressionTokenType::Colon) return null; if (!$tokens[$end]->type->isPotentialAddress()) return null; $last = mb_strtoupper($tokens[$end]->content); $firstAddress = CellAddress::fromString($first); $lastAddress = CellAddress::fromString($last); $range = new CellAddressRange($firstAddress, $lastAddress); return new CellExpression(CellExpressionOperation::Range, [ $range ]); } /** * @param CellExpressionToken[] $tokens * @param int $start * @param int $end * @param CellAddress $address * @return CellExpression|null */ private static function tryReference(array $tokens, int $start, int $end, CellAddress $address): ?CellExpression { if ($start !== $end) return null; if (!$tokens[$start]->type->isPotentialAddress()) return null; $ref = mb_strtoupper($tokens[$start]->content); $refAddress = CellAddress::fromString($ref, $address, true); return new CellExpression(CellExpressionOperation::Reference, [ $refAddress ]); } private const infixPriority = [ 'Minus' => 1, 'Plus' => 2, 'Divide' => 3, 'Multiply' => 4, 'Ampersand' => 10, 'GreaterThan' => 20, 'GreaterThanEqual' => 20, 'LessThan' => 20, 'LessThanEqual' => 20, 'Equal' => 20, 'Unequal' => 20, ]; /** * @param CellExpressionToken[] $tokens * @param int $start * @param int $end * @param CellAddress $address * @return CellExpression|null */ private static function tryInfix(array $tokens, int $start, int $end, CellAddress $address): ?CellExpression { $count = $end - $start + 1; if ($count < 3) return null; $candidates = []; $parenLevel = 0; for ($i = $start; $i <= $end; $i++) { if ($tokens[$i]->type === CellExpressionTokenType::OpenParen) { $parenLevel++; } elseif ($tokens[$i]->type === CellExpressionTokenType::CloseParen) { $parenLevel--; } elseif ($parenLevel === 0 && $i > $start && $i < $end) { $op = $tokens[$i]->type->name; $priority = self::infixPriority[$op] ?? false; if ($priority === false) continue; array_push($candidates, [ 'priority' => $priority, 'i' => $i ]); } } usort($candidates, fn($a, $b) => $a['priority'] - $b['priority']); $bestCandidate = null; foreach ($candidates as $candidate) { try { $i = $candidate['i']; $operand1 = self::tryExpression($tokens, $start, $i - 1, $address); if ($operand1 === null) continue; $operand2 = self::tryExpression($tokens, $i + 1, $end, $address); if ($operand2 === null) continue; $bestCandidate = $candidate; break; } catch (e) { if (!($e instanceof CellSyntaxException)) { throw $e; } } } if ($bestCandidate === null) { return null; } $i = $bestCandidate['i']; 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 int $start * @param int $end * @param CellAddress $address * @return CellExpression|null */ private static function tryUnary(array $tokens, int $start, int $end, CellAddress $address): ?CellExpression { $count = $end - $start + 1; if ($count < 2) return null; $ops = [ [ CellExpressionTokenType::Minus, CellExpressionOperation::UnaryMinus ], [ CellExpressionTokenType::Not, CellExpressionOperation::UnaryNot ], ]; foreach ($ops as $op) { if ($tokens[$start]->type !== $op[0]) continue; $operand = self::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 { public CellExpressionTokenType $type; public string $content; /** * @param CellExpressionTokenType $type * @param string $content */ public function __construct(CellExpressionTokenType $type, string $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 { public string $name; /** * Whether the column should remain unchanged when transposed. This is * symbolized by prefixing the column name with a `$` (e.g. `$C3`). */ public bool $isColumnFixed; /** * Zero-based column index. */ public int $columnIndex; /** * Letter code for the column. */ public function columnLetter(): string { return CellAddress::columnIndexToLetters($this->columnIndex); } /** * Whether the row should remain unchanged when transposed. This is * symbolized by prefixing the row number with a `$` (e.g. `C$3`). */ public bool $isRowFixed; /** * Zero-based row index. */ public int $rowIndex; /** * One-based row number. This is the human-facing row number. */ public function rowNumber(): ?int { return $this->rowIndex >= 0 ? $this->rowIndex + 1 : null; } /** * Whether this address has both a definite column and row. */ public bool $isResolved; /** * @param int $columnIndex 0-based column index * @param int $rowIndex 0-based row index * @param bool $isColumnFixed whether the column name is fixed in * place during transpositions. Denoted with a `$` in front of the column letters. * @param bool $isRowFixed whether the row number is fixed in place * during transpositions. Denoted with a `$` in front of the row digits. */ public function __construct(int $columnIndex, int $rowIndex, bool $isColumnFixed=false, bool $isRowFixed=false) { if (!is_numeric($columnIndex)) { throw new Error("columnIndex must be number, got " . gettype($columnIndex)); } if (!is_numeric($rowIndex)) { throw new Error("rowIndex must be number, got " . gettype($rowIndex)); } $this->columnIndex = $columnIndex; $this->rowIndex = $rowIndex; $this->isColumnFixed = $isColumnFixed; $this->isRowFixed = $isRowFixed; $this->isResolved = ($columnIndex >= 0 && $rowIndex >= 0); $this->name = self::formatAddress($columnIndex, $rowIndex, $isColumnFixed, $isRowFixed); } /** * Tests if a string is formatted like an address. */ public static function isAddress(string $text): bool { return self::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 bool $resolveToRow whether to fill in a row number if this * address doesn't have one * @return CellAddress|null resolved address, or `null` if out of bounds */ public function transpose(CellAddress $relativeFrom, CellAddress $relativeTo, bool $resolveToRow = true): ?CellAddress { 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->isRowFixed) { $rowDelta = $relativeTo->rowIndex - $relativeFrom->rowIndex; $newRowIndex += $rowDelta; } if ($newColumnIndex < 0 || $newRowIndex < 0) return null; return new CellAddress($newColumnIndex, $newRowIndex); } public function equals($other): bool { if (!($other instanceof CellAddress)) return false; return $other->columnIndex === $this->columnIndex && $other->rowIndex === $this->rowIndex; } public function exactlyEquals($other): bool { if (!($other instanceof CellAddress)) return false; return $other->name === $this->name; } public function __toString(): string { 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. */ public static function lettersToColumnIndex(string $letters): int { $ACodepoint = ord('A'); $columnIndex = 0; for ($i = mb_strlen($letters) - 1; $i >= 0; $i--) { $letterIndex = ord(mb_substr($letters, $i, 1)) - $ACodepoint; $columnIndex = $columnIndex * 26 + $letterIndex; } return $columnIndex; } /** * Converts a column index to column letters (e.g. index 0 = `A`). */ private static function columnIndexToLetters(int $columnIndex): string { $letters = ''; if ($columnIndex >= 0) { $ACodepoint = ord('A'); $remaining = $columnIndex; do { $letters = chr($ACodepoint + ($remaining % 26)) . $letters; $remaining = floor($remaining / 26); } while ($remaining > 0); } return $letters; } private static function formatAddress(int $columnIndex, int $rowIndex, bool $isColumnFixed, bool $isRowFixed): string { $addr = ''; if ($isColumnFixed && $columnIndex >= 0) $addr .= '$'; if ($columnIndex >= 0) $addr .= self::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 $relativeTo address to resolve relative * addresses against * @param bool $throwIfInvalid whether to throw an error if address * is invalid * @return ?CellAddress address, if parsable * @throws CellEvaluationException if the address is invalid and * `$throwIfInvalid` is `true` */ public static function fromString(string $address, ?CellAddress $relativeTo=null, bool $throwIfInvalid=false): ?CellAddress { if (!mb_eregi('^(\\$?)([A-Z]{1,2}?)((?:\\$(?=[0-9]))?)([0-9]*)$', $address, $groups)) { if ($throwIfInvalid) throw new CellEvaluationException("Bad address \"{$address}\"", '#REF'); return null; } $isColumnFixed = ($groups[1] === '$'); $letters = mb_strtoupper($groups[2]); $isRowFixed = ($groups[3] === '$'); $numbers = $groups[4]; $columnIndex = self::lettersToColumnIndex($letters); $rowIndex = (mb_strlen($numbers) === 0) ? -1 : intval($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 { public bool $isResolved; public int $minColumnIndex; public int $maxColumnIndex; public int $minRowIndex; public int $maxRowIndex; public 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 */ public function __construct(CellAddress $fromCell, CellAddress $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 = min($fromCell->columnIndex, $toCell->columnIndex); $this->maxColumnIndex = max($fromCell->columnIndex, $toCell->columnIndex); $this->minRowIndex = min($fromCell->rowIndex, $toCell->rowIndex); $this->maxRowIndex = 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` string 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: * ``` * foreach (range.cellsIn(grid) as $addressString => $cell) { * ... * } * ``` * * @param SpreadsheetGrid $grid * @return object iterable object */ public function cellsIn(SpreadsheetGrid $grid): Iterator { $minCol = $this->minColumnIndex; $maxCol = $this->maxColumnIndex; $minRow = $this->minRowIndex; $maxRow = $this->maxRowIndex; if ($minRow === -1) { $minRow = 0; } if ($maxRow === -1) { $maxRow = $grid->rowCount - 1; } $maxCol = min($maxCol, $grid->columnCount); return new class($grid, $minCol, $maxCol, $minRow, $maxRow) implements Iterator { private SpreadsheetGrid $grid; private int $minCol; private int $maxCol; private int $minRow; private int $maxRow; private int $col; private int $row; private ?CellAddress $address = null; private ?SpreadsheetCell $cell = null; function __construct($grid, $minCol, $maxCol, $minRow, $maxRow) { $this->grid = $grid; $this->minCol = $minCol; $this->maxCol = $maxCol; $this->minRow = $minRow; $this->maxRow = $maxRow; $this->col = $minCol; $this->row = $minRow; $this->setValues(); } private function setValues() { if ($this->col >= $this->minCol && $this->col <= $this->maxCol && $this->row >= $this->minRow && $this->row <= $this->maxRow) { $this->address = new CellAddress($this->col, $this->row); $this->cell = $this->grid->cellAt($this->address); if (!$this->cell) { error_log("WARNING: Iterator found no cell at {$this->address->name}"); } } else { $this->address = null; $this->cell = null; } } private function increment(): void { $this->row++; if ($this->row > $this->maxRow) { $this->row = $this->minRow; $this->col++; } $this->setValues(); } function current(): ?SpreadsheetCell { return $this->cell; } function key(): string { return $this->address->name; } function next(): void { $this->increment(); } function rewind(): void { $this->row = $this->minRow; $this->col = $this->minCol; $this->setValues(); } function valid(): bool { return $this->address !== null; } }; } } /** * A value in a spreadsheet or calculation. */ class CellValue { /** * Blank cell. `$value` is `null`. */ public const TYPE_BLANK = 'blank'; /** * Currency value. `$value` is `float`. */ public const TYPE_CURRENCY = 'currency'; /** * Regular number value. `$value` is `float`. */ public const TYPE_NUMBER = 'number'; /** * Percentage. `$value` is `float`, represented as a ratio (100% = 1.0). */ public const TYPE_PERCENT = 'percent'; /** * Unaltered text value. `$value` is `string`. */ public const TYPE_STRING = 'string'; /** * Boolean. `$value` is `bool`. */ public const TYPE_BOOLEAN = 'boolean'; /** * A formula that has resulted in an error during parsing or evaluation. * `$value` is `string` error message. */ public const TYPE_ERROR = 'error'; /** * A formula expression. `$value` is `string` and includes the leading `=`. */ public const TYPE_FORMULA = 'formula'; // -- Properties ----- /** * Type of value. One of the `TYPE_` constants. */ public string $type = CellValue::TYPE_STRING; /** * Number of decimal places shown in the formatted value. */ public int $decimals = 0; /** * The string shown in the table cell to the user. */ public string $formattedValue = ''; /** * The PHP data value. E.g. a `float` for currency values or an `Exception` * for errors. */ public $value = null; /** * Constructs a cell value explicitly. Values are not validated. Consider * using `.fromCellString()` or `.fromValue()` to populate values more * intelligently and consistently. */ public function __construct( string $formattedValue, mixed $value = null, string $type = CellValue::TYPE_STRING, int $decimals = 0 ) { $this->formattedValue = $formattedValue; $this->value = $value; $this->type = $type; $this->decimals = $decimals; } /** * Returns whether this value is a numeric type. */ public function isNumeric(): bool { return CellValue::isTypeNumeric($this->type); } /** * Creates a CellValue from formatted table cell contents. Attempts to * detect formatted numbers including currency and percentages. */ public static function fromCellString(string $cellString): CellValue { $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 mixed $value * @param ?string $type optional forced type * @param ?int $decimals optional number of decimal places to format to * @return CellValue * @throws CellEvaluationException if `$value` is of an unsupported type */ public static function fromValue(mixed $value, ?string $type = null, ?int $decimals = null): CellValue { if ($value === null) { return new CellValue('', null, CellValue::TYPE_BLANK); } if ($value instanceof Error) { if ($value instanceof CellException) { return new CellValue($value->errorSymbol, $value->getMessage(), CellValue::TYPE_ERROR); } return new CellValue('#ERROR', $value->getMessage(), CellValue::TYPE_ERROR); } if (is_bool($value)) { $formatted = CellValue::formatType($value, CellValue::TYPE_BOOLEAN, 0); return new CellValue($formatted, $value, CellValue::TYPE_BOOLEAN); } if (is_numeric($value)) { $resolvedType = $type ?? CellValue::TYPE_NUMBER; $resolvedDecimals = ($decimals !== null) ? $decimals : ($resolvedType === CellValue::TYPE_CURRENCY ? 2 : CellValue::autodecimals($resolvedType === CellValue::TYPE_PERCENT ? $value * 100.0 : $value)); $formatted = CellValue::formatType($value, $resolvedType, $resolvedDecimals); return new CellValue($formatted, $value, $resolvedType, $resolvedDecimals); } if (!is_string($value)) { throw new CellEvaluationException("Value of type " . gettype($value) . "unsupported"); } $trimmed = trim($value); if (str_starts_with($trimmed, '=')) { return new CellValue($trimmed, $trimmed, CellValue::TYPE_FORMULA); } return new CellValue($trimmed, $trimmed, CellValue::TYPE_STRING); } private function populateFromCellString(?string $cellString) { $cellString = ($cellString !== null) ? trim($cellString) : null; $this->formattedValue = $cellString; // blank if ($cellString === null || $cellString === '') { $this->type = CellValue::TYPE_BLANK; $this->value = null; return; } // 'literal if (str_starts_with($cellString, "'")) { $stripped = trim(mb_substr($cellString, 1)); $this->type = CellValue::TYPE_STRING; $this->formattedValue = $stripped; $this->value = $stripped; return; } // =TRUE $caps = mb_strtoupper($cellString); 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 (str_starts_with($cellString, '=')) { $this->type = CellValue::TYPE_FORMULA; $this->value = $cellString; return; } // -$1,234.56 if (mb_eregi('^([-]?)\\$(-?[0-9,]*\\.)([0-9]+)$', $cellString, $groups)) { $sign = $groups[1]; $dollars = mb_eregi_replace(',', '', $groups[2]); $cents = $groups[3]; $this->type = CellValue::TYPE_CURRENCY; $this->decimals = 2; $this->value = floatval($sign . $dollars . $cents); $this->formattedValue = CellValue::formatCurrency($this->value, $this->decimals); return; } // -$1,234 if (mb_eregi('^([-]?)\\$(-?[0-9,]+)$', $cellString, $groups)) { $sign = $groups[1]; $dollars = mb_eregi_replace(',', '', $groups[2]); $this->type = CellValue::TYPE_CURRENCY; $this->decimals = 0; $this->value = floatval($sign . $dollars); $this->formattedValue = CellValue::formatCurrency($this->value, $this->decimals); return; } // -1,234.56% if (mb_eregi('^([-]?[0-9,]*\\.)([0-9,]+)%$', $cellString, $groups)) { $wholes = mb_eregi_replace(',', '', $groups[1]); $decimals = $groups[2]; $this->type = CellValue::TYPE_PERCENT; $this->decimals = mb_strlen($decimals); $this->value = floatval($wholes . $decimals) / 100.0; $this->formattedValue = CellValue::formatPercent($this->value, $this->decimals); return; } // -1,234% if (mb_eregi('^([-]?[0-9,]+)%$', $cellString, $groups)) { $wholes = mb_eregi_replace(',', '', $groups[1]); $this->type = CellValue::TYPE_PERCENT; $this->decimals = 0; $this->value = floatval($wholes) / 100.0; $this->formattedValue = CellValue::formatPercent($this->value, $this->decimals); return; } // -1,234.56 if (mb_eregi('^([-]?[0-9,]*\\.)([0-9]+)$', $cellString, $groups)) { $wholes = mb_eregi_replace(',', '', $groups[1]); $decimals = $groups[2]; $this->type = CellValue::TYPE_NUMBER; $this->decimals = mb_strlen($decimals); $this->value = floatval($wholes . $decimals); $this->formattedValue = CellValue::formatNumber($this->value, $this->decimals); return; } // -1,234 if (mb_eregi('^([-]?[0-9,]+)$', $cellString, $groups)) { $wholes = mb_eregi_replace(',', '', $groups[1]); $this->type = CellValue::TYPE_NUMBER; $this->decimals = 0; $this->value = floatval($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. */ public function booleanValue(): ?bool { 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. */ public function numericValue(): ?float { 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. */ public function stringValue(bool $formatted = false): ?string { 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`. */ public function add(CellValue $b): CellValue { return self::binaryNumericOperation($this, $b, '+', fn($aVal, $bVal) => $aVal + $bVal); } /** * Returns the result of this value minus `$b`. */ public function subtract(CellValue $b) { return self::binaryNumericOperation($this, $b, '-', fn($aVal, $bVal) => $aVal - $bVal); } /** * Returns the result of this value multiplied by `$b`. */ public function multiply(CellValue $b): CellValue { return self::binaryNumericOperation($this, $b, '*', fn($aVal, $bVal) => $aVal * $bVal); } /** * Returns the result of this value divided by `$b`. * * @throws CellEvaluationException on divide by zero */ public function divide(CellValue $b): CellValue { return self::binaryNumericOperation($this, $b, '/', function($aVal, $bVal) { if ($bVal === 0) throw new CellEvaluationException("Division by zero", '#NAN'); return $aVal / $bVal; }); } /** * Returns the result of this value modulo by `$b`. * * @throws CellEvaluationException on divide by zero */ public function modulo($b) { return self::binaryNumericOperation($this, $b, '%', function($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`. */ public function gt(CellValue $b): CellValue { return self::fromValue(CellValue::compare($this, $b) > 0); } /** * Returns the result of whether tihs value is greater than or equal to `$b`. */ public function gte(CellValue $b): CellValue { return self::fromValue(CellValue::compare($this, $b) >= 0); } /** * Returns the result of whether this value is less than `$b`. */ public function lt(CellValue $b): CellValue { return self::fromValue(CellValue::compare($this, $b) < 0); } /** * Returns the result of whether this value is less than or equal to `$b`. */ public function lte(CellValue $b): CellValue { return self::fromValue(CellValue::compare($this, $b) <= 0); } /** * Returns the result of whether this value is equal to `$b`. */ public function eq(CellValue $b): CellValue { return self::fromValue(CellValue::compare($this, $b) === 0); } /** * Returns the result of whether this value is unequal to `$b`. */ public function neq(CellValue $b): CellValue { return self::fromValue(CellValue::compare($this, $b) !== 0); } /** * Returns the boolean not of this value. */ public function not(): CellValue { switch ($this->type) { case CellValue::TYPE_BLANK: return self::fromValue(true); case CellValue::TYPE_CURRENCY: case CellValue::TYPE_NUMBER: case CellValue::TYPE_PERCENT: return self::fromValue($this->value === 0); case CellValue::TYPE_STRING: throw new CellEvaluationException("Cannot perform NOT on string"); case CellValue::TYPE_BOOLEAN: return self::fromValue(!$this->value); case CellValue::TYPE_ERROR: throw $this->value; case CellValue::TYPE_FORMULA: throw new CellEvaluationException("Cannot perform NOT on expression"); } } /** * Returns the string representation of a value concatenated to the string * representation of this value. */ public function concatenate(CellValue $b): CellValue { $s1 = $this->stringValue(true); $s2 = $b->stringValue(true); if ($s1 === null || $s2 === null) { throw new CellEvaluationException("Concatenation requires string arguments"); } return self::fromValue("{$s1}{$s2}"); } /** * Helper to resolve two numeric arguments and perform an operation on them. * * @param CellValue $a operand A * @param CellValue $b operand B * @param string $op operator * @param function $fn takes two `float` arguments and returns a `float` result * @return CellValue result * @throws CellEvaluationException */ public static function binaryNumericOperation(CellValue $a, CellValue $b, string $op, callable $fn): CellValue { $ops = self::resolveNumericOperands($a, $b, $op); $aNum = $ops[0]; $bNum = $ops[1]; $type = $ops[2]; $result = $fn($aNum, $bNum); return self::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 * @return array 3-element tuple array with A float value, B float value, * and result type string * @throws CellEvaluationException if types are incompatible for numeric operations */ private static function resolveNumericOperands(CellValue $a, CellValue $b, string $op): array { if ($a->type === self::TYPE_ERROR) throw $a->value; if ($b->type === self::TYPE_ERROR) throw $b->value; if ($a->type === self::TYPE_STRING || $b->type === self::TYPE_STRING) { throw new CellEvaluationException("Cannot perform math on text values"); } if ($a->type === self::TYPE_BLANK) { if ($b->type === self::TYPE_BLANK) { return [ 0, 0, self::TYPE_NUMBER, 0 ]; } return [ 0, $b->value, $b->type ]; } elseif ($b->type === self::TYPE_BLANK) { return [ $a->value, 0, $a->type ]; } $isMultOrDiv = ($op === '*' || $op === '/' || $op === '%'); if ($a->type === $b->type) { return [ $a->value, $b->value, $a->type ]; } switch ($a->type . $b->type) { case self::TYPE_CURRENCY . self::TYPE_NUMBER: case self::TYPE_CURRENCY . self::TYPE_PERCENT: return [ $a->value, $b->value, self::TYPE_CURRENCY ]; case self::TYPE_PERCENT . self::TYPE_CURRENCY: return [ $a->value, $b->value, $isMultOrDiv ? self::TYPE_CURRENCY : self::TYPE_PERCENT ]; case self::TYPE_PERCENT . self::TYPE_NUMBER: return [ $a->value, $b->value, $isMultOrDiv ? self::TYPE_NUMBER : self::TYPE_PERCENT ]; case self::TYPE_NUMBER . self::TYPE_CURRENCY: return [ $a->value, $b->value, $b->type ]; case self::TYPE_NUMBER . self::TYPE_PERCENT: return [ $a->value, $b->value, $isMultOrDiv ? self::TYPE_NUMBER : $b->type ]; case self::TYPE_BOOLEAN . self::TYPE_CURRENCY: case self::TYPE_BOOLEAN . self::TYPE_NUMBER: case self::TYPE_BOOLEAN . self::TYPE_PERCENT: return [ $a->value ? 1 : 0, $b->value, $b->type ]; case self::TYPE_CURRENCY . self::TYPE_BOOLEAN: case self::TYPE_NUMBER . self::TYPE_BOOLEAN: case self::TYPE_PERCENT . self::TYPE_BOOLEAN: return [ $a->value, $b->value ? 1 : 0, $a->type ]; } throw new CellEvaluationException("Unhandled operand types \"{$a->type}\" and \"{$b->type}\""); } /** * Performs a comparison of two values. * * @param CellValue $a * @param CellValue $b * @return int `-1`, `0`, or `1` if a < b, a == b, or a > b, respectively */ private static function compare(CellValue $a, CellValue $b): int { $args = self::resolveComparableArguments($a, $b); $valueA = $args[0]; $valueB = $args[1]; if (is_string($valueA)) { return strcasecmp($valueA, $valueB); } else { if ($valueA < $valueB) return -1; if ($valueA > $valueB) return 1; return 0; } } /** * @param CellValue $a * @param CellValue $b * @return array two comparable values (strings or floats) * @throws CellEvaluationException if values are formulas * @throws CellException if `$a` or `$b` is of type `TYPE_ERROR` */ private static function resolveComparableArguments(CellValue $a, CellValue $b): array { 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"); $aNumValue = $a->value; $bNumValue = $b->value; $aStrValue = "{$a->value}"; $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. */ public static function formatType(mixed $value, string $type, int $decimals): string { 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}"; default: throw new CellException("Cannot format value of type {$type}"); } } private static function formatNumber(float|int $value, int $decimals): string { return number_format($value, $decimals); } private static function formatCurrency(float|int $dollars, int $decimals): string { $s = number_format($dollars, $decimals); if (str_starts_with($s, '-')) { return '-$' . mb_substr($s, 1); } return '$' . $s; } private static function formatPercent(float|int $value, int $decimals): string { $dec = $value * 100.0; return number_format($dec, $decimals) . '%'; } /** * Determines a good number of decimal places to format a value to. */ private static function autodecimals(mixed $value, int $maxDigits = 6): int { if ($value instanceof CellValue) { return CellValue::autodecimals($value->value); } if (is_numeric($value)) { $s = number_format($value, $maxDigits); if (strpos($s, '.') === false) return 0; $fraction = explode('.', $s)[1]; $fraction = rtrim($fraction, '0'); return min($maxDigits, mb_strlen($fraction)); } return 0; } /** * Tests if a type is numeric. */ public static function isTypeNumeric(string $type): bool { return $type === CellValue::TYPE_NUMBER || $type === CellValue::TYPE_PERCENT || $type === CellValue::TYPE_CURRENCY || $type === CellValue::TYPE_BOOLEAN; } public function __toString(): string { return "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[][]} */ public array $cells; public int $columnCount; public int $rowCount; public function __construct(int $columnCount, int $rowCount) { $this->columnCount = $columnCount; $this->rowCount = $rowCount; $this->cells = []; for ($c = 0; $c < $columnCount; $c++) { $column = []; for ($r = 0; $r < $rowCount; $r++) { array_push($column, new SpreadsheetCell()); } array_push($this->cells, $column); } } /** * @param CellAddress $address * @return SpreadsheetCell $cell * @throws CellEvaluationException if the address it out of bounds */ public function cellAt(CellAddress $address): SpreadsheetCell { $c = $address->columnIndex; $r = $address->rowIndex; if ($c < 0 || $c >= count($this->cells)) throw new CellEvaluationException("Unresolved cell address {$address->name}", '#REF'); $col = $this->cells[$c]; if ($r < 0 || $r >= count($col)) throw new CellEvaluationException("Unresolved cell address {$address->name}", '#REF'); return $col[$r]; } /** * Returns the original value at the given address. */ public function valueAt(CellAddress $address): ?CellValue { return $this->cellAt($address)->originalValue; } /** * Returns the computed value at the given address. */ public function outputValueAt(CellAddress $address): ?CellValue { 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 { public CellValue $originalValue; public ?CellValue $outputValue = null; /** * Whether `outputValue` is the result of a formula evaluation. */ public bool $isCalculated = false; public ?CellExpression $parsedExpression = null; public function __construct() { $this->originalValue = new CellValue('', null, CellValue::TYPE_BLANK, 0); } public function resolvedValue(): CellValue { 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 * `MDTableReader`. Tables without at least one formula will not be altered. */ class MDSpreadsheetReader extends MDReader { public function preProcess(MDState $state) { foreach ($state->readersByBlockPriority as $reader) { if ($reader instanceof MDTableReader) { $reader->preferFormulas = true; } } } public function postProcess(MDState $state, array &$nodes) { foreach ($nodes as $node) { if ($node instanceof MDTableNode) { $this->processTable($node, $state); } } } private function processTable(MDTableNode $tableNode, MDState $state) { // Measure table $rowCount = count($tableNode->bodyRows()); $columnCount = 0; foreach ($tableNode->bodyRows() as $row) { $columnCount = max($columnCount, count($row->children)); } // Create and populate grid $grid = new SpreadsheetGrid($columnCount, $rowCount); for ($c = 0; $c < $columnCount; $c++) { for ($r = 0; $r < $rowCount; $r++) { $cellNode = $tableNode->bodyCellAt($c, $r); if ($cellNode === null) continue; $cellText = $cellNode->toPlaintext($state); $gridCell = $grid->cells[$c][$r]; $gridCell->originalValue = CellValue::fromCellString($cellText); } } // Calculate $expressions = new CellExpressionSet($grid); $expressions->calculateCells(); // See if anything was calculated. If not, don't mess with table. $isCalculated = false; for ($c = 0; $c < $columnCount && !$isCalculated; $c++) { for ($r = 0; $r < $rowCount; $r++) { if ($grid->cellAt(new CellAddress($c, $r))->isCalculated) { $isCalculated = true; break; } } } if (!$isCalculated) return; // Copy results back to table for ($c = 0; $c < $columnCount; $c++) { for ($r = 0; $r < $rowCount; $r++) { $cellNode = $tableNode->bodyCellAt($c, $r); $gridCell = $grid->cellAt(new CellAddress($c, $r)); if ($cellNode === null || $gridCell === null) continue; $this->populateCell($cellNode, $gridCell, $state, $c, $r); } } } /** * @param MDTableCellNode $cellNode * @param SpreadsheetCell $gridCell * @param MDState $state * @param int $c column index * @param int $r row index */ private function populateCell(MDTableCellNode $cellNode, SpreadsheetCell $gridCell, MDState $state, int $c, int $r) { $gridValue = $gridCell->outputValue; if ($gridValue === null) return; $oldCellText = trim($cellNode->toPlaintext($state)); $cellText = $gridValue->formattedValue; if ($cellText != $oldCellText) { // Try to insert the text into any nested whole-value formatting nodes // if possible if (!$this->findTextNode($cellNode, $oldCellText, $cellText)) { // Contents contain mixed formatting. We'll have to just replace // the whole thing. $cellNode->children = [ new MDTextNode($cellText) ]; } } if ($gridCell->isCalculated) { $cellNode->addClass('calculated'); } $cellNode->addClass("spreadsheet-type-{$gridValue->type}"); if ($gridValue->type == CellValue::TYPE_ERROR) { $cellNode->attributes['title'] = $gridValue->value; } $gridNumber = $gridValue->numericValue(); if ($gridNumber !== null) { $cellNode->attributes['data-numeric-value'] = "{$gridNumber}"; } $gridString = $gridValue->stringValue(false); if ($gridString !== null) { $cellNode->attributes['data-string-value'] = $gridString; } } private function findTextNode(MDNode $startNode, string $expectedText, string $newText): bool { if ($startNode instanceof MDTextNode) { if (trim($startNode->text) === trim($expectedText)) { $startNode->text = $newText; return true; } } foreach ($startNode->children as $child) { if ($this->findTextNode($child, $expectedText, $newText)) return true; } return false; } } ?>