PluginProbe
TablePress – Tables in WordPress made easy / 3.4
TablePress – Tables in WordPress made easy v3.4
3.4 3.3.4 3.3.3 3.3.2 3.3.1 trunk 1.12 1.14 1.9.2 2.0.4 2.1.7 2.1.8 2.2 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.3 2.3.1 2.3.2 2.4 2.4.1 2.4.2 2.4.3 All 45 releases
tablepress / libraries / vendor / PhpSpreadsheet / Calculation / Calculation.php

Calculation.php in TablePress – Tables in WordPress made easy 3.4, at libraries/vendor/PhpSpreadsheet/Calculation/Calculation.php

3,127 lines 115.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace TablePress\PhpOffice\PhpSpreadsheet\Calculation;
4
5 use TablePress\Composer\Pcre\Preg; // many pregs in this program use u modifier, which has side-effects which make it unsuitable for this
6 use TablePress\PhpOffice\PhpSpreadsheet\Calculation\Engine\BranchPruner;
7 use TablePress\PhpOffice\PhpSpreadsheet\Calculation\Engine\CyclicReferenceStack;
8 use TablePress\PhpOffice\PhpSpreadsheet\Calculation\Engine\Logger;
9 use TablePress\PhpOffice\PhpSpreadsheet\Calculation\Engine\Operands;
10 use TablePress\PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
11 use TablePress\PhpOffice\PhpSpreadsheet\Calculation\Token\Stack;
12 use TablePress\PhpOffice\PhpSpreadsheet\Cell\AddressRange;
13 use TablePress\PhpOffice\PhpSpreadsheet\Cell\Cell;
14 use TablePress\PhpOffice\PhpSpreadsheet\Cell\Coordinate;
15 use TablePress\PhpOffice\PhpSpreadsheet\Cell\DataType;
16 use TablePress\PhpOffice\PhpSpreadsheet\DefinedName;
17 use TablePress\PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException;
18 use TablePress\PhpOffice\PhpSpreadsheet\NamedRange;
19 use TablePress\PhpOffice\PhpSpreadsheet\ReferenceHelper;
20 use TablePress\PhpOffice\PhpSpreadsheet\Shared\StringHelper;
21 use TablePress\PhpOffice\PhpSpreadsheet\Spreadsheet;
22 use TablePress\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
23 use ReflectionClassConstant;
24 use ReflectionMethod;
25 use ReflectionParameter;
26 use Throwable;
27 use TypeError;
28
29 class Calculation extends CalculationLocale
30 {
31 /** Constants */
32 /** Regular Expressions */
33 // Numeric operand
34 const CALCULATION_REGEXP_NUMBER = '[-+]?\d*\.?\d+(e[-+]?\d+)?';
35 // String operand
36 const CALCULATION_REGEXP_STRING = '"(?:[^"]|"")*"';
37 // Opening bracket
38 const CALCULATION_REGEXP_OPENBRACE = '\(';
39 // Function (allow for the old @ symbol that could be used to prefix a function, but we'll ignore it)
40 const CALCULATION_REGEXP_FUNCTION = '@?(?:_xlfn\.)?(?:_xlws\.)?((?:__xludf\.)?[\p{L}][\p{L}\p{N}\._]*)[\s]*\('; // TablePress: Add _ to allow the deprecated RAND_INT, RAND_FLOAT, NUMBER_FORMAT, and NUMBER_FORMAT_EU functions.
41 // Cell reference, with or without a sheet reference)
42 const CALCULATION_REGEXP_CELLREF = '((([^\s,!&%^\/\*\+<>=:`-]*)|(\'(?:[^\']|\'[^!])+?\')|(\"(?:[^\"]|\"[^!])+?\"))!)?\$?\b([a-z]{1,3})\$?(\d{1,7})(?![\w.])';
43 // Used only to detect spill operator #
44 const CALCULATION_REGEXP_CELLREF_SPILL = '/' . self::CALCULATION_REGEXP_CELLREF . '#/i';
45 // Cell reference (with or without a sheet reference) ensuring absolute/relative
46 const CALCULATION_REGEXP_CELLREF_RELATIVE = '((([^\s\(,!&%^\/\*\+<>=:`-]*)|(\'(?:[^\']|\'[^!])+?\')|(\"(?:[^\"]|\"[^!])+?\"))!)?(\$?\b[a-z]{1,3})(\$?\d{1,7})(?![\w.])';
47 const CALCULATION_REGEXP_COLUMN_RANGE = '(((([^\s\(,!&%^\/\*\+<>=:`-]*)|(\'(?:[^\']|\'[^!])+?\')|(\".(?:[^\"]|\"[^!])?\"))!)?(\$?[a-z]{1,3})):(?![.*])';
48 const CALCULATION_REGEXP_ROW_RANGE = '(((([^\s\(,!&%^\/\*\+<>=:`-]*)|(\'(?:[^\']|\'[^!])+?\')|(\"(?:[^\"]|\"[^!])+?\"))!)?(\$?[1-9][0-9]{0,6})):(?![.*])';
49 // Cell reference (with or without a sheet reference) ensuring absolute/relative
50 // Cell ranges ensuring absolute/relative
51 const CALCULATION_REGEXP_COLUMNRANGE_RELATIVE = '(\$?[a-z]{1,3}):(\$?[a-z]{1,3})';
52 const CALCULATION_REGEXP_ROWRANGE_RELATIVE = '(\$?\d{1,7}):(\$?\d{1,7})';
53 // Defined Names: Named Range of cells, or Named Formulae
54 const CALCULATION_REGEXP_DEFINEDNAME = '((([^\s,!&%^\/\*\+<>=-]*)|(\'(?:[^\']|\'[^!])+?\')|(\"(?:[^\"]|\"[^!])+?\"))!)?([_\p{L}][_\p{L}\p{N}\.]*)';
55 // Structured Reference (Fully Qualified and Unqualified)
56 const CALCULATION_REGEXP_STRUCTURED_REFERENCE = '([\p{L}_\\\][\p{L}\p{N}\._]+)?(\[(?:[^\d\]+-])?)';
57 // Error
58 const CALCULATION_REGEXP_ERROR = '\#[A-Z][A-Z0_\/]*[!\?]?';
59
60 /** constants */
61 const RETURN_ARRAY_AS_ERROR = 'error';
62 const RETURN_ARRAY_AS_VALUE = 'value';
63 const RETURN_ARRAY_AS_ARRAY = 'array';
64
65 /** Preferable to use instance variable instanceArrayReturnType rather than this static property. */
66 private static string $returnArrayAsType = self::RETURN_ARRAY_AS_VALUE;
67
68 /** Preferable to use this instance variable rather than static returnArrayAsType */
69 private ?string $instanceArrayReturnType = null;
70
71 /**
72 * Instance of this class.
73 */
74 private static ?Calculation $instance = null;
75
76 /**
77 * Instance of the spreadsheet this Calculation Engine is using.
78 */
79 private ?Spreadsheet $spreadsheet;
80
81 /**
82 * Calculation cache.
83 *
84 * @var mixed[]
85 */
86 private array $calculationCache = [];
87
88 /**
89 * Calculation cache enabled.
90 */
91 private bool $calculationCacheEnabled = true;
92
93 /**
94 * Maximum number of entries in the formula token cache.
95 * Default 0 (disabled). Set via setFormulaTokenCacheMaxSize() to enable.
96 */
97 private int $formulaTokenCacheMaxSize = 0;
98
99 /**
100 * Cache of parsed formula tokens, keyed by the raw formula string.
101 *
102 * @var array<string, array<mixed>|bool>
103 */
104 private array $formulaTokenCache = [];
105
106 private BranchPruner $branchPruner;
107
108 protected bool $branchPruningEnabled = true;
109
110 /**
111 * List of operators that can be used within formulae
112 * The true/false value indicates whether it is a binary operator or a unary operator.
113 */
114 private const CALCULATION_OPERATORS = [
115 '+' => true, '-' => true, '*' => true, '/' => true,
116 '^' => true, '&' => true, '%' => false, '~' => false,
117 '>' => true, '<' => true, '=' => true, '>=' => true,
118 '<=' => true, '<>' => true, '∩' => true, '∪' => true,
119 ':' => true,
120 ];
121
122 /**
123 * List of binary operators (those that expect two operands).
124 */
125 private const BINARY_OPERATORS = [
126 '+' => true, '-' => true, '*' => true, '/' => true,
127 '^' => true, '&' => true, '>' => true, '<' => true,
128 '=' => true, '>=' => true, '<=' => true, '<>' => true,
129 '∩' => true, '∪' => true, ':' => true,
130 ];
131
132 /**
133 * The debug log generated by the calculation engine.
134 */
135 private Logger $debugLog;
136
137 private bool $suppressFormulaErrors = false;
138
139 private bool $processingAnchorArray = false;
140
141 /**
142 * Error message for any error that was raised/thrown by the calculation engine.
143 */
144 public ?string $formulaError = null;
145
146 /**
147 * An array of the nested cell references accessed by the calculation engine, used for the debug log.
148 */
149 private CyclicReferenceStack $cyclicReferenceStack;
150
151 /** @var mixed[] */
152 private array $cellStack = [];
153
154 /**
155 * Current iteration counter for cyclic formulae
156 * If the value is 0 (or less) then cyclic formulae will throw an exception,
157 * otherwise they will iterate to the limit defined here before returning a result.
158 */
159 private int $cyclicFormulaCounter = 1;
160
161 private string $cyclicFormulaCell = '';
162
163 /**
164 * Number of iterations for cyclic formulae.
165 */
166 public int $cyclicFormulaCount = 1;
167
168 /**
169 * Excel constant string translations to their PHP equivalents
170 * Constant conversion from text name/value to actual (datatyped) value.
171 */
172 private const EXCEL_CONSTANTS = [
173 'TRUE' => true,
174 'FALSE' => false,
175 'NULL' => null,
176 ];
177
178 public static function keyInExcelConstants(string $key): bool
179 {
180 return array_key_exists($key, self::EXCEL_CONSTANTS);
181 }
182
183 public static function getExcelConstants(string $key): ?bool
184 {
185 return self::EXCEL_CONSTANTS[$key];
186 }
187
188 /**
189 * Internal functions used for special control purposes.
190 *
191 * @var array<string, array<string, array<string>|string>>
192 */
193 private static array $controlFunctions = [
194 'MKMATRIX' => [
195 'argumentCount' => '*',
196 'functionCall' => [Internal\MakeMatrix::class, 'make'],
197 ],
198 'NAME.ERROR' => [
199 'argumentCount' => '*',
200 'functionCall' => [ExcelError::class, 'NAME'],
201 ],
202 'WILDCARDMATCH' => [
203 'argumentCount' => '2',
204 'functionCall' => [Internal\WildcardMatch::class, 'compare'],
205 ],
206 ];
207
208 public function __construct(?Spreadsheet $spreadsheet = null)
209 {
210 // TablePress: Load custom modications to the calculation engine.
211 $this->register_tablepress_aliases_and_custom_functions();
212
213 $this->spreadsheet = $spreadsheet;
214 $this->cyclicReferenceStack = new CyclicReferenceStack();
215 $this->debugLog = new Logger($this->cyclicReferenceStack);
216 $this->branchPruner = new BranchPruner($this->branchPruningEnabled);
217 }
218
219 /**
220 * Get an instance of this class.
221 *
222 * @param ?Spreadsheet $spreadsheet Injected spreadsheet for working with a PhpSpreadsheet Spreadsheet object,
223 * or NULL to create a standalone calculation engine
224 */
225 public static function getInstance(?Spreadsheet $spreadsheet = null): self
226 {
227 if ($spreadsheet !== null) {
228 return $spreadsheet->getCalculationEngine();
229 }
230
231 if (!self::$instance) {
232 self::$instance = new self();
233 }
234
235 return self::$instance;
236 }
237
238 /**
239 * Intended for use only via a destructor.
240 *
241 * @internal
242 */
243 public static function getInstanceOrNull(?Spreadsheet $spreadsheet = null): ?self
244 {
245 if ($spreadsheet !== null) {
246 return $spreadsheet->getCalculationEngineOrNull();
247 }
248
249 return null;
250 }
251
252 /**
253 * Flush the calculation cache for any existing instance of this class
254 * but only if a Calculation instance exists.
255 */
256 public function flushInstance(): void
257 {
258 $this->clearCalculationCache();
259 $this->branchPruner->clearBranchStore();
260 $this->formulaTokenCache = [];
261 }
262
263 /**
264 * Get the Logger for this calculation engine instance.
265 */
266 public function getDebugLog(): Logger
267 {
268 return $this->debugLog;
269 }
270
271 /**
272 * __clone implementation. Cloning should not be allowed in a Singleton!
273 */
274 final public function __clone()
275 {
276 throw new Exception('Cloning the calculation engine is not allowed!');
277 }
278
279 /**
280 * Set the Array Return Type (Array or Value of first element in the array).
281 *
282 * @param string $returnType Array return type
283 *
284 * @return bool Success or failure
285 */
286 public static function setArrayReturnType(string $returnType): bool
287 {
288 if (
289 ($returnType == self::RETURN_ARRAY_AS_VALUE)
290 || ($returnType == self::RETURN_ARRAY_AS_ERROR)
291 || ($returnType == self::RETURN_ARRAY_AS_ARRAY)
292 ) {
293 self::$returnArrayAsType = $returnType;
294
295 return true;
296 }
297
298 return false;
299 }
300
301 /**
302 * Return the Array Return Type (Array or Value of first element in the array).
303 *
304 * @return string $returnType Array return type
305 */
306 public static function getArrayReturnType(): string
307 {
308 return self::$returnArrayAsType;
309 }
310
311 /**
312 * Set the Instance Array Return Type (Array or Value of first element in the array).
313 *
314 * @param string $returnType Array return type
315 *
316 * @return bool Success or failure
317 */
318 public function setInstanceArrayReturnType(string $returnType): bool
319 {
320 if (
321 ($returnType == self::RETURN_ARRAY_AS_VALUE)
322 || ($returnType == self::RETURN_ARRAY_AS_ERROR)
323 || ($returnType == self::RETURN_ARRAY_AS_ARRAY)
324 ) {
325 $this->instanceArrayReturnType = $returnType;
326
327 return true;
328 }
329
330 return false;
331 }
332
333 /**
334 * Return the Array Return Type (Array or Value of first element in the array).
335 *
336 * @return string $returnType Array return type for instance if non-null, otherwise static property
337 */
338 public function getInstanceArrayReturnType(): string
339 {
340 return $this->instanceArrayReturnType ?? self::$returnArrayAsType;
341 }
342
343 /**
344 * Is calculation caching enabled?
345 */
346 public function getCalculationCacheEnabled(): bool
347 {
348 return $this->calculationCacheEnabled;
349 }
350
351 /**
352 * Enable/disable calculation cache.
353 */
354 public function setCalculationCacheEnabled(bool $calculationCacheEnabled): self
355 {
356 $this->calculationCacheEnabled = $calculationCacheEnabled;
357 $this->clearCalculationCache();
358
359 return $this;
360 }
361
362 /**
363 * Enable calculation cache.
364 */
365 public function enableCalculationCache(): void
366 {
367 $this->setCalculationCacheEnabled(true);
368 }
369
370 /**
371 * Disable calculation cache.
372 */
373 public function disableCalculationCache(): void
374 {
375 $this->setCalculationCacheEnabled(false);
376 }
377
378 /**
379 * Clear calculation cache.
380 */
381 public function clearCalculationCache(): void
382 {
383 $this->calculationCache = [];
384 }
385
386 /**
387 * Clear the formula token cache.
388 */
389 public function clearFormulaTokenCache(): void
390 {
391 $this->formulaTokenCache = [];
392 }
393
394 /**
395 * Get the current number of entries in the formula token cache.
396 */
397 public function getFormulaTokenCacheSize(): int
398 {
399 return count($this->formulaTokenCache);
400 }
401
402 /**
403 * Set the maximum number of entries in the formula token cache.
404 * Set to 0 to disable caching (default), or a positive integer to enable.
405 */
406 public function setFormulaTokenCacheMaxSize(int $size): self
407 {
408 $this->formulaTokenCacheMaxSize = max(0, $size);
409 if ($this->formulaTokenCacheMaxSize === 0) {
410 $this->formulaTokenCache = [];
411 }
412
413 return $this;
414 }
415
416 /**
417 * Get the maximum number of entries allowed in the formula token cache.
418 */
419 public function getFormulaTokenCacheMaxSize(): int
420 {
421 return $this->formulaTokenCacheMaxSize;
422 }
423
424 /**
425 * Clear calculation cache for a specified worksheet.
426 */
427 public function clearCalculationCacheForWorksheet(string $worksheetName): void
428 {
429 if (isset($this->calculationCache[$worksheetName])) {
430 unset($this->calculationCache[$worksheetName]);
431 }
432 }
433
434 /**
435 * Rename calculation cache for a specified worksheet.
436 */
437 public function renameCalculationCacheForWorksheet(string $fromWorksheetName, string $toWorksheetName): void
438 {
439 if (isset($this->calculationCache[$fromWorksheetName])) {
440 $this->calculationCache[$toWorksheetName] = &$this->calculationCache[$fromWorksheetName];
441 unset($this->calculationCache[$fromWorksheetName]);
442 }
443 }
444
445 public function getBranchPruningEnabled(): bool
446 {
447 return $this->branchPruningEnabled;
448 }
449
450 /**
451 * @param mixed $enabled
452 */
453 public function setBranchPruningEnabled($enabled): self
454 {
455 $this->branchPruningEnabled = (bool) $enabled;
456 $this->branchPruner = new BranchPruner($this->branchPruningEnabled);
457
458 return $this;
459 }
460
461 public function enableBranchPruning(): void
462 {
463 $this->setBranchPruningEnabled(true);
464 }
465
466 public function disableBranchPruning(): void
467 {
468 $this->setBranchPruningEnabled(false);
469 }
470
471 /**
472 * Wrap string values in quotes.
473 * @param mixed $value
474 * @return mixed
475 */
476 public static function wrapResult($value)
477 {
478 if (is_string($value)) {
479 // Error values cannot be "wrapped"
480 if (Preg::isMatch('/^' . self::CALCULATION_REGEXP_ERROR . '$/i', $value, $match)) {
481 // Return Excel errors "as is"
482 return $value;
483 }
484
485 // Return strings wrapped in quotes
486 return self::FORMULA_STRING_QUOTE . $value . self::FORMULA_STRING_QUOTE;
487 } elseif ((is_float($value)) && ((is_nan($value)) || (is_infinite($value)))) {
488 // Convert numeric errors to NaN error
489 return ExcelError::NAN();
490 }
491
492 return $value;
493 }
494
495 /**
496 * Remove quotes used as a wrapper to identify string values.
497 * @param mixed $value
498 * @return mixed
499 */
500 public static function unwrapResult($value)
501 {
502 if (is_string($value)) {
503 if ((isset($value[0])) && ($value[0] == self::FORMULA_STRING_QUOTE) && (substr($value, -1) == self::FORMULA_STRING_QUOTE)) {
504 return (string) substr($value, 1, -1);
505 }
506 // Convert numeric errors to NAN error
507 } elseif ((is_float($value)) && ((is_nan($value)) || (is_infinite($value)))) {
508 return ExcelError::NAN();
509 }
510
511 return $value;
512 }
513
514 /**
515 * Calculate cell value (using formula from a cell ID)
516 * Retained for backward compatibility.
517 *
518 * @param ?Cell $cell Cell to calculate
519 * @return mixed
520 */
521 public function calculate(?Cell $cell = null)
522 {
523 try {
524 return $this->calculateCellValue($cell);
525 } catch (\Exception $e) {
526 throw new Exception($e->getMessage());
527 }
528 }
529
530 /**
531 * Calculate the value of a cell formula.
532 *
533 * @param ?Cell $cell Cell to calculate
534 * @param bool $resetLog Flag indicating whether the debug log should be reset or not
535 * @return mixed
536 */
537 public function calculateCellValue(?Cell $cell = null, bool $resetLog = true)
538 {
539 if ($cell === null) {
540 return null;
541 }
542
543 if ($resetLog) {
544 // Initialise the logging settings if requested
545 $this->formulaError = null;
546 $this->debugLog->clearLog();
547 $this->cyclicReferenceStack->clear();
548 $this->cyclicFormulaCounter = 1;
549 }
550
551 // Execute the calculation for the cell formula
552 $this->cellStack[] = [
553 'sheet' => $cell->getWorksheet()->getTitle(),
554 'cell' => $cell->getCoordinate(),
555 ];
556
557 $cellAddressAttempted = false;
558 $cellAddress = null;
559
560 try {
561 $value = $cell->getValue();
562 if (is_string($value) && $cell->getDataType() === DataType::TYPE_FORMULA) {
563 $value = Preg::replaceCallback(
564 self::CALCULATION_REGEXP_CELLREF_SPILL,
565 fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
566 $value
567 );
568 }
569 $result = self::unwrapResult($this->_calculateFormulaValue($value, $cell->getCoordinate(), $cell)); //* @phpstan-ignore argument.type ($value can be mixed not string)
570 if ($this->spreadsheet === null) {
571 throw new Exception('null spreadsheet in calculateCellValue');
572 }
573 $cellAddressAttempted = true;
574 $cellAddress = array_pop($this->cellStack);
575 if ($cellAddress === null) {
576 throw new Exception('null cellAddress in calculateCellValue');
577 }
578 /** @var array{sheet: string, cell: string} $cellAddress */
579 $testSheet = $this->spreadsheet->getSheetByName($cellAddress['sheet']);
580 if ($testSheet === null) {
581 throw new Exception('worksheet not found in calculateCellValue');
582 }
583 $testSheet->getCell($cellAddress['cell']);
584 } catch (\Exception $e) {
585 if (!$cellAddressAttempted) {
586 $cellAddress = array_pop($this->cellStack);
587 }
588 if ($this->spreadsheet !== null && is_array($cellAddress) && array_key_exists('sheet', $cellAddress)) {
589 $sheetName = $cellAddress['sheet'];
590 $testSheet = is_string($sheetName) ? $this->spreadsheet->getSheetByName($sheetName) : null;
591 if ($testSheet !== null && array_key_exists('cell', $cellAddress)) {
592 /** @var array{cell: string} $cellAddress */
593 $testSheet->getCell($cellAddress['cell']);
594 }
595 }
596
597 throw new Exception($e->getMessage(), $e->getCode(), $e);
598 }
599
600 if (is_array($result) && $this->getInstanceArrayReturnType() !== self::RETURN_ARRAY_AS_ARRAY) {
601 $testResult = Functions::flattenArray($result);
602 if ($this->getInstanceArrayReturnType() == self::RETURN_ARRAY_AS_ERROR) {
603 return ExcelError::VALUE();
604 }
605 $result = array_shift($testResult);
606 }
607
608 if ($result === null && $cell->getWorksheet()->getSheetView()->getShowZeros()) {
609 return 0;
610 } elseif ((is_float($result)) && ((is_nan($result)) || (is_infinite($result)))) {
611 return ExcelError::NAN();
612 }
613
614 return $result;
615 }
616
617 /**
618 * Validate and parse a formula string.
619 *
620 * @param string $formula Formula to parse
621 *
622 * @return array<mixed>|bool
623 */
624 public function parseFormula(string $formula)
625 {
626 // Check the formula token cache first (only when caching is enabled)
627 if ($this->formulaTokenCacheMaxSize > 0 && isset($this->formulaTokenCache[$formula])) {
628 return $this->formulaTokenCache[$formula];
629 }
630
631 $originalFormula = $formula;
632 $formula = Preg::replaceCallback(
633 self::CALCULATION_REGEXP_CELLREF_SPILL,
634 fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
635 $formula
636 );
637 // Basic validation that this is indeed a formula
638 // We return an empty array if not
639 $formula = trim($formula);
640 if ((!isset($formula[0])) || ($formula[0] != '=')) {
641 return [];
642 }
643 $formula = ltrim((string) substr($formula, 1));
644 if (!isset($formula[0])) {
645 return [];
646 }
647
648 // Parse the formula and return the token stack
649 $result = $this->internalParseFormula($formula);
650
651 // Cache the result when caching is enabled (clear cache if it exceeds the maximum size)
652 if ($this->formulaTokenCacheMaxSize > 0) {
653 // Phpstan says if condition is always false,
654 // but coverage report says next statement is covered.
655 if (count($this->formulaTokenCache) >= $this->formulaTokenCacheMaxSize) {
656 $this->formulaTokenCache = [];
657 }
658 // Cache key is the original formula string (before ANCHORARRAY transformation)
659 // to ensure consistent lookup regardless of internal transformations.
660 $this->formulaTokenCache[$originalFormula] = $result;
661 }
662
663 return $result;
664 }
665
666 /**
667 * Calculate the value of a formula.
668 *
669 * @param string $formula Formula to parse
670 * @param ?string $cellID Address of the cell to calculate
671 * @param ?Cell $cell Cell to calculate
672 * @return mixed
673 */
674 public function calculateFormula(string $formula, ?string $cellID = null, ?Cell $cell = null)
675 {
676 // Initialise the logging settings
677 $this->formulaError = null;
678 $this->debugLog->clearLog();
679 $this->cyclicReferenceStack->clear();
680
681 $resetCache = $this->getCalculationCacheEnabled();
682 if ($this->spreadsheet !== null && $cellID === null && $cell === null) {
683 $cellID = 'A1';
684 $cell = $this->spreadsheet->getActiveSheet()->getCell($cellID);
685 } else {
686 // Disable calculation cacheing because it only applies to cell calculations, not straight formulae
687 // But don't actually flush any cache
688 $this->calculationCacheEnabled = false;
689 }
690
691 // Execute the calculation
692 try {
693 $result = self::unwrapResult($this->_calculateFormulaValue($formula, $cellID, $cell));
694 } catch (\Exception $e) {
695 throw new Exception($e->getMessage());
696 }
697
698 if ($this->spreadsheet === null) {
699 // Reset calculation cacheing to its previous state
700 $this->calculationCacheEnabled = $resetCache;
701 }
702
703 return $result;
704 }
705
706 /**
707 * @param mixed $cellValue
708 */
709 public function getValueFromCache(string $cellReference, &$cellValue): bool
710 {
711 $this->debugLog->writeDebugLog('Testing cache value for cell %s', $cellReference);
712 // Is calculation cacheing enabled?
713 // If so, is the required value present in calculation cache?
714 if (($this->calculationCacheEnabled) && (isset($this->calculationCache[$cellReference]))) {
715 $this->debugLog->writeDebugLog('Retrieving value for cell %s from cache', $cellReference);
716 // Return the cached result
717
718 $cellValue = $this->calculationCache[$cellReference];
719
720 return true;
721 }
722
723 return false;
724 }
725
726 /**
727 * @param mixed $cellValue
728 */
729 public function saveValueToCache(string $cellReference, $cellValue): void
730 {
731 if ($this->calculationCacheEnabled) {
732 $this->calculationCache[$cellReference] = $cellValue;
733 }
734 }
735
736 /**
737 * Parse a cell formula and calculate its value.
738 *
739 * @param string $formula The formula to parse and calculate
740 * @param ?string $cellID The ID (e.g. A3) of the cell that we are calculating
741 * @param ?Cell $cell Cell to calculate
742 * @param bool $ignoreQuotePrefix If set to true, evaluate the formyla even if the referenced cell is quote prefixed
743 * @return mixed
744 */
745 public function _calculateFormulaValue(string $formula, ?string $cellID = null, ?Cell $cell = null, bool $ignoreQuotePrefix = false)
746 {
747 $cellValue = null;
748
749 // Quote-Prefixed cell values cannot be formulae, but are treated as strings
750 if ($cell !== null && $ignoreQuotePrefix === false && $cell->getStyle()->getQuotePrefix() === true) {
751 return self::wrapResult((string) $formula);
752 }
753
754 // https://www.reddit.com/r/excel/comments/chr41y/cmd_formula_stopped_working_since_last_update/
755 if (preg_match('/^=\s*cmd\s*\|/miu', $formula) !== 0) {
756 return ExcelError::REF(); // returns #BLOCKED in newer versions
757 }
758
759 // Basic validation that this is indeed a formula
760 // We simply return the cell value if not
761 $formula = trim($formula);
762 if ($formula === '' || $formula[0] !== '=') {
763 return self::wrapResult($formula);
764 }
765 $formula = ltrim((string) substr($formula, 1));
766 if (!isset($formula[0])) {
767 return self::wrapResult($formula);
768 }
769
770 $pCellParent = ($cell !== null) ? $cell->getWorksheet() : null;
771 $wsTitle = ($pCellParent !== null) ? $pCellParent->getTitle() : "\x00Wrk";
772 $wsCellReference = $wsTitle . '!' . $cellID;
773
774 if (($cellID !== null) && ($this->getValueFromCache($wsCellReference, $cellValue))) {
775 return $cellValue;
776 }
777 $this->debugLog->writeDebugLog('Evaluating formula for cell %s', $wsCellReference);
778
779 if (($wsTitle[0] !== "\x00") && ($this->cyclicReferenceStack->onStack($wsCellReference))) {
780 if ($this->cyclicFormulaCount <= 0) {
781 $this->cyclicFormulaCell = '';
782
783 return $this->raiseFormulaError('Cyclic Reference in Formula');
784 } elseif ($this->cyclicFormulaCell === $wsCellReference) {
785 ++$this->cyclicFormulaCounter;
786 if ($this->cyclicFormulaCounter >= $this->cyclicFormulaCount) {
787 $this->cyclicFormulaCell = '';
788
789 return $cellValue;
790 }
791 } elseif ($this->cyclicFormulaCell == '') {
792 if ($this->cyclicFormulaCounter >= $this->cyclicFormulaCount) {
793 return $cellValue;
794 }
795 $this->cyclicFormulaCell = $wsCellReference;
796 }
797 }
798
799 $this->debugLog->writeDebugLog('Formula for cell %s is %s', $wsCellReference, $formula);
800 // Parse the formula onto the token stack and calculate the value
801 $this->cyclicReferenceStack->push($wsCellReference);
802
803 $cellValue = $this->processTokenStack($this->internalParseFormula($formula, $cell), $cellID, $cell);
804 $this->cyclicReferenceStack->pop();
805
806 // Save to calculation cache
807 if ($cellID !== null) {
808 $this->saveValueToCache($wsCellReference, $cellValue);
809 }
810
811 // Return the calculated value
812 return $cellValue;
813 }
814
815 /**
816 * Ensure that paired matrix operands are both matrices and of the same size.
817 *
818 * @param mixed $operand1 First matrix operand
819 *
820 * @param-out mixed[] $operand1
821 *
822 * @param mixed $operand2 Second matrix operand
823 *
824 * @param-out mixed[] $operand2
825 *
826 * @param int $resize Flag indicating whether the matrices should be resized to match
827 * and (if so), whether the smaller dimension should grow or the
828 * larger should shrink.
829 * 0 = no resize
830 * 1 = shrink to fit
831 * 2 = extend to fit
832 *
833 * @return mixed[]
834 */
835 public static function checkMatrixOperands(&$operand1, &$operand2, int $resize = 1): array
836 {
837 // Examine each of the two operands, and turn them into an array if they aren't one already
838 // Note that this function should only be called if one or both of the operand is already an array
839 if (!is_array($operand1)) {
840 if (is_array($operand2)) {
841 [$matrixRows, $matrixColumns] = self::getMatrixDimensions($operand2);
842 $operand1 = array_fill(0, $matrixRows, array_fill(0, $matrixColumns, $operand1));
843 $resize = 0;
844 } else {
845 $operand1 = [$operand1];
846 $operand2 = [$operand2];
847 }
848 } elseif (!is_array($operand2)) {
849 [$matrixRows, $matrixColumns] = self::getMatrixDimensions($operand1);
850 $operand2 = array_fill(0, $matrixRows, array_fill(0, $matrixColumns, $operand2));
851 $resize = 0;
852 }
853
854 [$matrix1Rows, $matrix1Columns] = self::getMatrixDimensions($operand1);
855 [$matrix2Rows, $matrix2Columns] = self::getMatrixDimensions($operand2);
856 if ($resize === 3) {
857 $resize = 2;
858 } elseif (($matrix1Rows == $matrix2Columns) && ($matrix2Rows == $matrix1Columns)) {
859 $resize = 1;
860 }
861
862 if ($resize == 2) {
863 // Given two matrices of (potentially) unequal size, convert the smaller in each dimension to match the larger
864 self::resizeMatricesExtend($operand1, $operand2, $matrix1Rows, $matrix1Columns, $matrix2Rows, $matrix2Columns);
865 } elseif ($resize == 1) {
866 // Given two matrices of (potentially) unequal size, convert the larger in each dimension to match the smaller
867 /** @var mixed[][] $operand1 */
868 /** @var mixed[][] $operand2 */
869 self::resizeMatricesShrink($operand1, $operand2, $matrix1Rows, $matrix1Columns, $matrix2Rows, $matrix2Columns);
870 }
871 [$matrix1Rows, $matrix1Columns] = self::getMatrixDimensions($operand1);
872 [$matrix2Rows, $matrix2Columns] = self::getMatrixDimensions($operand2);
873
874 return [$matrix1Rows, $matrix1Columns, $matrix2Rows, $matrix2Columns];
875 }
876
877 /**
878 * Read the dimensions of a matrix, and re-index it with straight numeric keys starting from row 0, column 0.
879 *
880 * @param mixed[] $matrix matrix operand
881 *
882 * @return int[] An array comprising the number of rows, and number of columns
883 */
884 public static function getMatrixDimensions(array &$matrix): array
885 {
886 $matrixRows = count($matrix);
887 $matrixColumns = 0;
888 foreach ($matrix as $rowKey => $rowValue) {
889 if (!is_array($rowValue)) {
890 $matrix[$rowKey] = [$rowValue];
891 $matrixColumns = max(1, $matrixColumns);
892 } else {
893 $matrix[$rowKey] = array_values($rowValue);
894 $matrixColumns = max(count($rowValue), $matrixColumns);
895 }
896 }
897 $matrix = array_values($matrix);
898
899 return [$matrixRows, $matrixColumns];
900 }
901
902 /**
903 * Ensure that paired matrix operands are both matrices of the same size.
904 *
905 * @param mixed[][] $matrix1 First matrix operand
906 * @param mixed[][] $matrix2 Second matrix operand
907 * @param int $matrix1Rows Row size of first matrix operand
908 * @param int $matrix1Columns Column size of first matrix operand
909 * @param int $matrix2Rows Row size of second matrix operand
910 * @param int $matrix2Columns Column size of second matrix operand
911 */
912 private static function resizeMatricesShrink(array &$matrix1, array &$matrix2, int $matrix1Rows, int $matrix1Columns, int $matrix2Rows, int $matrix2Columns): void
913 {
914 if (($matrix2Columns < $matrix1Columns) || ($matrix2Rows < $matrix1Rows)) {
915 if ($matrix2Rows < $matrix1Rows) {
916 for ($i = $matrix2Rows; $i < $matrix1Rows; ++$i) {
917 unset($matrix1[$i]);
918 }
919 }
920 if ($matrix2Columns < $matrix1Columns) {
921 for ($i = 0; $i < $matrix1Rows; ++$i) {
922 for ($j = $matrix2Columns; $j < $matrix1Columns; ++$j) {
923 unset($matrix1[$i][$j]);
924 }
925 }
926 }
927 }
928
929 if (($matrix1Columns < $matrix2Columns) || ($matrix1Rows < $matrix2Rows)) {
930 if ($matrix1Rows < $matrix2Rows) {
931 for ($i = $matrix1Rows; $i < $matrix2Rows; ++$i) {
932 unset($matrix2[$i]);
933 }
934 }
935 if ($matrix1Columns < $matrix2Columns) {
936 for ($i = 0; $i < $matrix2Rows; ++$i) {
937 for ($j = $matrix1Columns; $j < $matrix2Columns; ++$j) {
938 unset($matrix2[$i][$j]);
939 }
940 }
941 }
942 }
943 }
944
945 /**
946 * Ensure that paired matrix operands are both matrices of the same size.
947 *
948 * @param mixed[] $matrix1 First matrix operand
949 * @param mixed[] $matrix2 Second matrix operand
950 * @param int $matrix1Rows Row size of first matrix operand
951 * @param int $matrix1Columns Column size of first matrix operand
952 * @param int $matrix2Rows Row size of second matrix operand
953 * @param int $matrix2Columns Column size of second matrix operand
954 */
955 private static function resizeMatricesExtend(array &$matrix1, array &$matrix2, int $matrix1Rows, int $matrix1Columns, int $matrix2Rows, int $matrix2Columns): void
956 {
957 if (($matrix2Columns < $matrix1Columns) || ($matrix2Rows < $matrix1Rows)) {
958 if ($matrix2Columns < $matrix1Columns) {
959 for ($i = 0; $i < $matrix2Rows; ++$i) {
960 /** @var mixed[][] $matrix2 */
961 $x = ($matrix2Columns === 1) ? $matrix2[$i][0] : null;
962 for ($j = $matrix2Columns; $j < $matrix1Columns; ++$j) {
963 $matrix2[$i][$j] = $x;
964 }
965 }
966 }
967 if ($matrix2Rows < $matrix1Rows) {
968 $x = ($matrix2Rows === 1) ? $matrix2[0] : array_fill(0, $matrix2Columns, null);
969 for ($i = $matrix2Rows; $i < $matrix1Rows; ++$i) {
970 $matrix2[$i] = $x;
971 }
972 }
973 }
974
975 if (($matrix1Columns < $matrix2Columns) || ($matrix1Rows < $matrix2Rows)) {
976 if ($matrix1Columns < $matrix2Columns) {
977 for ($i = 0; $i < $matrix1Rows; ++$i) {
978 /** @var mixed[][] $matrix1 */
979 $x = ($matrix1Columns === 1) ? $matrix1[$i][0] : null;
980 for ($j = $matrix1Columns; $j < $matrix2Columns; ++$j) {
981 $matrix1[$i][$j] = $x;
982 }
983 }
984 }
985 if ($matrix1Rows < $matrix2Rows) {
986 $x = ($matrix1Rows === 1) ? $matrix1[0] : array_fill(0, $matrix2Columns, null);
987 for ($i = $matrix1Rows; $i < $matrix2Rows; ++$i) {
988 $matrix1[$i] = $x;
989 }
990 }
991 }
992 }
993
994 /**
995 * Format details of an operand for display in the log (based on operand type).
996 *
997 * @param mixed $value First matrix operand
998 * @return mixed
999 */
1000 private function showValue($value)
1001 {
1002 if ($this->debugLog->getWriteDebugLog()) {
1003 $testArray = Functions::flattenArray($value);
1004 if (count($testArray) == 1) {
1005 $value = array_pop($testArray);
1006 }
1007
1008 if (is_array($value)) {
1009 $returnMatrix = [];
1010 $pad = $rpad = ', ';
1011 foreach ($value as $row) {
1012 if (is_array($row)) {
1013 $returnMatrix[] = implode($pad, array_map([$this, 'showValue'], $row)); // @phpstan-ignore argument.type (array_map can theoretically return array<mixed> not array<string>)
1014 $rpad = '; ';
1015 } else {
1016 $returnMatrix[] = $this->showValue($row);
1017 }
1018 }
1019
1020 /** @var string[] $returnMatrix */
1021 return '{ ' . implode($rpad, $returnMatrix) . ' }';
1022 } elseif (is_string($value) && (trim($value, self::FORMULA_STRING_QUOTE) == $value)) {
1023 return self::FORMULA_STRING_QUOTE . $value . self::FORMULA_STRING_QUOTE;
1024 } elseif (is_bool($value)) {
1025 return ($value) ? self::$localeBoolean['TRUE'] : self::$localeBoolean['FALSE'];
1026 } elseif ($value === null) {
1027 return self::$localeBoolean['NULL'];
1028 }
1029 }
1030
1031 return Functions::flattenSingleValue($value);
1032 }
1033
1034 /**
1035 * Format type and details of an operand for display in the log (based on operand type).
1036 *
1037 * @param mixed $value First matrix operand
1038 */
1039 private function showTypeDetails($value): ?string
1040 {
1041 if ($this->debugLog->getWriteDebugLog()) {
1042 $testArray = Functions::flattenArray($value);
1043 if (count($testArray) == 1) {
1044 $value = array_pop($testArray);
1045 }
1046
1047 if ($value === null) {
1048 return 'a NULL value';
1049 } elseif (is_float($value)) {
1050 $typeString = 'a floating point number';
1051 } elseif (is_int($value)) {
1052 $typeString = 'an integer number';
1053 } elseif (is_bool($value)) {
1054 $typeString = 'a boolean';
1055 } elseif (is_array($value)) {
1056 $typeString = 'a matrix';
1057 } else {
1058 /** @var string $value */
1059 if ($value == '') {
1060 return 'an empty string';
1061 } elseif ($value[0] == '#') {
1062 return 'a ' . $value . ' error';
1063 }
1064 $typeString = 'a string';
1065 }
1066
1067 return $typeString . ' with a value of ' . StringHelper::convertToString($this->showValue($value));
1068 }
1069
1070 return null;
1071 }
1072
1073 private const MATRIX_REPLACE_FROM = [self::FORMULA_OPEN_MATRIX_BRACE, ';', self::FORMULA_CLOSE_MATRIX_BRACE];
1074 private const MATRIX_REPLACE_TO = ['MKMATRIX(MKMATRIX(', '),MKMATRIX(', '))'];
1075
1076 /**
1077 * @return false|string False indicates an error
1078 */
1079 private function convertMatrixReferences(string $formula)
1080 {
1081 // Convert any Excel matrix references to the MKMATRIX() function
1082 if (str_contains($formula, self::FORMULA_OPEN_MATRIX_BRACE)) {
1083 // If there is the possibility of braces within a quoted string, then we don't treat those as matrix indicators
1084 if (str_contains($formula, self::FORMULA_STRING_QUOTE)) {
1085 // So instead we skip replacing in any quoted strings by only replacing in every other array element after we've exploded
1086 // the formula
1087 $temp = explode(self::FORMULA_STRING_QUOTE, $formula);
1088 // Open and Closed counts used for trapping mismatched braces in the formula
1089 $openCount = $closeCount = 0;
1090 $notWithinQuotes = false;
1091 foreach ($temp as &$value) {
1092 // Only count/replace in alternating array entries
1093 $notWithinQuotes = $notWithinQuotes === false;
1094 if ($notWithinQuotes === true) {
1095 $openCount += substr_count($value, self::FORMULA_OPEN_MATRIX_BRACE);
1096 $closeCount += substr_count($value, self::FORMULA_CLOSE_MATRIX_BRACE);
1097 $value = str_replace(self::MATRIX_REPLACE_FROM, self::MATRIX_REPLACE_TO, $value);
1098 }
1099 }
1100 unset($value);
1101 // Then rebuild the formula string
1102 $formula = implode(self::FORMULA_STRING_QUOTE, $temp);
1103 } else {
1104 // If there's no quoted strings, then we do a simple count/replace
1105 $openCount = substr_count($formula, self::FORMULA_OPEN_MATRIX_BRACE);
1106 $closeCount = substr_count($formula, self::FORMULA_CLOSE_MATRIX_BRACE);
1107 $formula = str_replace(self::MATRIX_REPLACE_FROM, self::MATRIX_REPLACE_TO, $formula);
1108 }
1109 // Trap for mismatched braces and trigger an appropriate error
1110 if ($openCount < $closeCount) {
1111 if ($openCount > 0) {
1112 return $this->raiseFormulaError("Formula Error: Mismatched matrix braces '}'");
1113 }
1114
1115 return $this->raiseFormulaError("Formula Error: Unexpected '}' encountered");
1116 } elseif ($openCount > $closeCount) {
1117 if ($closeCount > 0) {
1118 return $this->raiseFormulaError("Formula Error: Mismatched matrix braces '{'");
1119 }
1120
1121 return $this->raiseFormulaError("Formula Error: Unexpected '{' encountered");
1122 }
1123 }
1124
1125 return $formula;
1126 }
1127
1128 /**
1129 * Comparison (Boolean) Operators.
1130 * These operators work on two values, but always return a boolean result.
1131 */
1132 private const COMPARISON_OPERATORS = ['>' => true, '<' => true, '=' => true, '>=' => true, '<=' => true, '<>' => true];
1133
1134 /**
1135 * Operator Precedence.
1136 * This list includes all valid operators, whether binary (including boolean) or unary (such as %).
1137 * Array key is the operator, the value is its precedence.
1138 */
1139 private const OPERATOR_PRECEDENCE = [
1140 ':' => 9, // Range
1141 '∩' => 8, // Intersect
1142 '∪' => 7, // Union
1143 '~' => 6, // Negation
1144 '%' => 5, // Percentage
1145 '^' => 4, // Exponentiation
1146 '*' => 3, '/' => 3, // Multiplication and Division
1147 '+' => 2, '-' => 2, // Addition and Subtraction
1148 '&' => 1, // Concatenation
1149 '>' => 0, '<' => 0, '=' => 0, '>=' => 0, '<=' => 0, '<>' => 0, // Comparison
1150 ];
1151
1152 /** @param array<?string> $matches */
1153 private function unionForComma(array $matches): string
1154 {
1155 $matches5 = (string) $matches[5];
1156 // Weirdly, the regexp which get us here for issue 4832
1157 // only gets us here for Php8.4+. 8.4 introduced
1158 // major changes for PCRE, but I cannot identify
1159 // the exact change which caused this discrepancy.
1160 // I do plan to update coverage to 8.4 at some point,
1161 // and I can remove the coverage annotations after that.
1162 // @codeCoverageIgnoreStart
1163 if (str_contains($matches5, '(') && !str_contains($matches5, ')')) {
1164 if ($this->spreadsheet !== null) {
1165 if ($this->spreadsheet->getSheetByName($matches5) === null) {
1166 $matches0 = (string) $matches[0];
1167 $this->debugLog->writeDebugLog('Not Reformulating %s', $matches0);
1168
1169 return $matches0;
1170 }
1171 }
1172 }
1173 // @codeCoverageIgnoreEnd
1174 $matches1 = (string) $matches[1];
1175 $matches2 = (string) $matches[2];
1176
1177 return $matches1 . str_replace(',', '∪', $matches2);
1178 }
1179
1180 private const CELL_OR_CELLRANGE_OR_DEFINED_NAME
1181 = '(?:'
1182 . self::CALCULATION_REGEXP_CELLREF // cell address
1183 . '(?::' . self::CALCULATION_REGEXP_CELLREF . ')?' // optional range address, non-capturing
1184 . '|' . self::CALCULATION_REGEXP_DEFINEDNAME
1185 . ')'
1186 ;
1187
1188 public const UNIONABLE_COMMAS = '/((?:[,(]|^)\s*)' // comma or open paren or start of string, followed by optional whitespace
1189 . '([(]' // open paren
1190 . self::CELL_OR_CELLRANGE_OR_DEFINED_NAME // cell address
1191 . '(?:\s*,\s*' // optioonal whitespace, comma, optional whitespace, non-capturing
1192 . self::CELL_OR_CELLRANGE_OR_DEFINED_NAME // cell address
1193 . ')+' // one or more occurrences
1194 . '\s*[)])/i'; // optional whitespace, end paren
1195
1196 /**
1197 * @return array<int, mixed>|false
1198 */
1199 private function internalParseFormula(string $formula, ?Cell $cell = null)
1200 {
1201 if (($formula = $this->convertMatrixReferences(trim($formula))) === false) {
1202 return false;
1203 }
1204
1205 $oldFormula = $formula;
1206 $formula = Preg::replaceCallback(self::UNIONABLE_COMMAS, \Closure::fromCallable([$this, 'unionForComma']), $formula);
1207 if ($oldFormula !== $formula) {
1208 $this->debugLog->writeDebugLog('Reformulated as %s', $formula);
1209 }
1210 $phpSpreadsheetFunctions = &self::getFunctionsAddress();
1211
1212 // If we're using cell caching, then $pCell may well be flushed back to the cache (which detaches the parent worksheet),
1213 // so we store the parent worksheet so that we can re-attach it when necessary
1214 $pCellParent = ($cell !== null) ? $cell->getWorksheet() : null;
1215
1216 $regexpMatchString = '/^((?<string>' . self::CALCULATION_REGEXP_STRING
1217 . ')|(?<function>' . self::CALCULATION_REGEXP_FUNCTION
1218 . ')|(?<cellRef>' . self::CALCULATION_REGEXP_CELLREF
1219 . ')|(?<colRange>' . self::CALCULATION_REGEXP_COLUMN_RANGE
1220 . ')|(?<rowRange>' . self::CALCULATION_REGEXP_ROW_RANGE
1221 . ')|(?<number>' . self::CALCULATION_REGEXP_NUMBER
1222 . ')|(?<openBrace>' . self::CALCULATION_REGEXP_OPENBRACE
1223 . ')|(?<structuredReference>' . self::CALCULATION_REGEXP_STRUCTURED_REFERENCE
1224 . ')|(?<definedName>' . self::CALCULATION_REGEXP_DEFINEDNAME
1225 . ')|(?<error>' . self::CALCULATION_REGEXP_ERROR
1226 . '))/sui';
1227
1228 // Start with initialisation
1229 $index = 0;
1230 $stack = new Stack($this->branchPruner);
1231 $output = [];
1232 $expectingOperator = false; // We use this test in syntax-checking the expression to determine when a
1233 // - is a negation or + is a positive operator rather than an operation
1234 $expectingOperand = false; // We use this test in syntax-checking the expression to determine whether an operand
1235 // should be null in a function call
1236
1237 // The guts of the lexical parser
1238 // Loop through the formula extracting each operator and operand in turn
1239 while (true) {
1240 // Branch pruning: we adapt the output item to the context (it will
1241 // be used to limit its computation)
1242 $this->branchPruner->initialiseForLoop();
1243
1244 $opCharacter = $formula[$index]; // Get the first character of the value at the current index position
1245 if ($opCharacter === "\xe2") { // intersection or union
1246 $opCharacter .= $formula[++$index];
1247 $opCharacter .= $formula[++$index];
1248 }
1249
1250 // Check for two-character operators (e.g. >=, <=, <>)
1251 if ((isset(self::COMPARISON_OPERATORS[$opCharacter])) && (strlen($formula) > $index) && isset($formula[$index + 1], self::COMPARISON_OPERATORS[$formula[$index + 1]])) {
1252 $opCharacter .= $formula[++$index];
1253 }
1254 // Find out if we're currently at the beginning of a number, variable, cell/row/column reference,
1255 // function, defined name, structured reference, parenthesis, error or operand
1256 $isOperandOrFunction = (bool) preg_match($regexpMatchString, (string) substr($formula, $index), $match);
1257
1258 $expectingOperatorCopy = $expectingOperator;
1259 if ($opCharacter === '-' && !$expectingOperator) { // Is it a negation instead of a minus?
1260 // Put a negation on the stack
1261 $stack->push('Unary Operator', '~');
1262 ++$index; // and drop the negation symbol
1263 } elseif ($opCharacter === '%' && $expectingOperator) {
1264 // Put a percentage on the stack
1265 $stack->push('Unary Operator', '%');
1266 ++$index;
1267 } elseif ($opCharacter === '+' && !$expectingOperator) { // Positive (unary plus rather than binary operator plus) can be discarded?
1268 ++$index; // Drop the redundant plus symbol
1269 } elseif ((($opCharacter === '~') /*|| ($opCharacter === '∩') || ($opCharacter === '∪')*/) && (!$isOperandOrFunction)) {
1270 // We have to explicitly deny a tilde, union or intersect because they are legal
1271 return $this->raiseFormulaError("Formula Error: Illegal character '~'"); // on the stack but not in the input expression
1272 } elseif ((isset(self::CALCULATION_OPERATORS[$opCharacter]) || $isOperandOrFunction) && $expectingOperator) { // Are we putting an operator on the stack?
1273 while (self::swapOperands($stack, $opCharacter)) {
1274 $output[] = $stack->pop(); // Swap operands and higher precedence operators from the stack to the output
1275 }
1276
1277 // Finally put our current operator onto the stack
1278 $stack->push('Binary Operator', $opCharacter);
1279
1280 ++$index;
1281 $expectingOperator = false;
1282 } elseif ($opCharacter === ')' && $expectingOperator) { // Are we expecting to close a parenthesis?
1283 $expectingOperand = false;
1284 while (($o2 = $stack->pop()) && $o2['value'] !== '(') { // Pop off the stack back to the last (
1285 $output[] = $o2;
1286 }
1287 $d = $stack->last(2);
1288
1289 // Branch pruning we decrease the depth whether is it a function
1290 // call or a parenthesis
1291 $this->branchPruner->decrementDepth();
1292
1293 if (is_array($d) && preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', StringHelper::convertToString($d['value']), $matches)) {
1294 // Did this parenthesis just close a function?
1295 try {
1296 $this->branchPruner->closingBrace($d['value']);
1297 } catch (Exception $e) {
1298 return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
1299 }
1300
1301 $functionName = $matches[1]; // Get the function name
1302 $d = $stack->pop();
1303 $argumentCount = $d['value'] ?? 0; // See how many arguments there were (argument count is the next value stored on the stack)
1304 $output[] = $d; // Dump the argument count on the output
1305 $output[] = $stack->pop(); // Pop the function and push onto the output
1306 if (isset(self::$controlFunctions[$functionName])) {
1307 $expectedArgumentCount = self::$controlFunctions[$functionName]['argumentCount'];
1308 } elseif (isset($phpSpreadsheetFunctions[$functionName])) {
1309 $expectedArgumentCount = $phpSpreadsheetFunctions[$functionName]['argumentCount'];
1310 } else { // did we somehow push a non-function on the stack? this should never happen
1311 return $this->raiseFormulaError('Formula Error: Internal error, non-function on stack');
1312 }
1313 // Check the argument count
1314 $argumentCountError = false;
1315 $expectedArgumentCountString = null;
1316 if (is_numeric($expectedArgumentCount)) {
1317 if ($expectedArgumentCount < 0) {
1318 if ($argumentCount > abs($expectedArgumentCount + 0)) {
1319 $argumentCountError = true;
1320 $expectedArgumentCountString = 'no more than ' . abs($expectedArgumentCount + 0);
1321 }
1322 } else {
1323 if ($argumentCount != $expectedArgumentCount) {
1324 $argumentCountError = true;
1325 $expectedArgumentCountString = $expectedArgumentCount;
1326 }
1327 }
1328 } elseif (is_string($expectedArgumentCount) && $expectedArgumentCount !== '*') {
1329 if (!Preg::isMatch('/(\d*)([-+,])(\d*)/', $expectedArgumentCount, $argMatch)) {
1330 $argMatch = ['', '', '', ''];
1331 }
1332 switch ($argMatch[2]) {
1333 case '+':
1334 if ($argumentCount < $argMatch[1]) {
1335 $argumentCountError = true;
1336 $expectedArgumentCountString = $argMatch[1] . ' or more ';
1337 }
1338
1339 break;
1340 case '-':
1341 if (($argumentCount < $argMatch[1]) || ($argumentCount > $argMatch[3])) {
1342 $argumentCountError = true;
1343 $expectedArgumentCountString = 'between ' . $argMatch[1] . ' and ' . $argMatch[3];
1344 }
1345
1346 break;
1347 case ',':
1348 if (($argumentCount != $argMatch[1]) && ($argumentCount != $argMatch[3])) {
1349 $argumentCountError = true;
1350 $expectedArgumentCountString = 'either ' . $argMatch[1] . ' or ' . $argMatch[3];
1351 }
1352
1353 break;
1354 }
1355 }
1356 if ($argumentCountError) {
1357 /** @var int $argumentCount */
1358 return $this->raiseFormulaError("Formula Error: Wrong number of arguments for $functionName() function: $argumentCount given, " . $expectedArgumentCountString . ' expected');
1359 }
1360 }
1361 ++$index;
1362 } elseif ($opCharacter === ',') { // Is this the separator for function arguments?
1363 try {
1364 $this->branchPruner->argumentSeparator();
1365 } catch (Exception $e) {
1366 return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
1367 }
1368
1369 while (($o2 = $stack->pop()) && $o2['value'] !== '(') { // Pop off the stack back to the last (
1370 $output[] = $o2; // pop the argument expression stuff and push onto the output
1371 }
1372 // If we've a comma when we're expecting an operand, then what we actually have is a null operand;
1373 // so push a null onto the stack
1374 if (($expectingOperand) || (!$expectingOperator)) {
1375 $output[] = $stack->getStackItem('Empty Argument', null, 'NULL');
1376 }
1377 // make sure there was a function
1378 $d = $stack->last(2);
1379 /** @var string */
1380 $temp = $d['value'] ?? '';
1381 if (!preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', $temp, $matches)) {
1382 // Can we inject a dummy function at this point so that the braces at least have some context
1383 // because at least the braces are paired up (at this stage in the formula)
1384 // MS Excel allows this if the content is cell references; but doesn't allow actual values,
1385 // but at this point, we can't differentiate (so allow both)
1386 //return $this->raiseFormulaError('Formula Error: Unexpected ,');
1387
1388 $stack->push('Binary Operator', '∪');
1389
1390 ++$index;
1391 $expectingOperator = false;
1392
1393 continue;
1394 }
1395
1396 /** @var array<string, int> $d */
1397 $d = $stack->pop();
1398 ++$d['value']; // increment the argument count
1399
1400 $stack->pushStackItem($d);
1401 $stack->push('Brace', '('); // put the ( back on, we'll need to pop back to it again
1402
1403 $expectingOperator = false;
1404 $expectingOperand = true;
1405 ++$index;
1406 } elseif ($opCharacter === '(' && !$expectingOperator) {
1407 // Branch pruning: we go deeper
1408 $this->branchPruner->incrementDepth();
1409 $stack->push('Brace', '(', null);
1410 ++$index;
1411 } elseif ($isOperandOrFunction && !$expectingOperatorCopy) {
1412 // do we now have a function/variable/number?
1413 $expectingOperator = true;
1414 $expectingOperand = false;
1415 $val = $match[1] ?? '';
1416 $length = strlen($val);
1417
1418 if (preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', $val, $matches)) {
1419 // $val is known to be valid unicode from statement above, so Preg::replace is okay even with u modifier
1420 $val = Preg::replace('/\s/u', '', $val);
1421 if (isset($phpSpreadsheetFunctions[strtoupper($matches[1])]) || isset(self::$controlFunctions[strtoupper($matches[1])])) { // it's a function
1422 $valToUpper = strtoupper($val);
1423 } else {
1424 $valToUpper = 'NAME.ERROR(';
1425 }
1426 // here $matches[1] will contain values like "IF"
1427 // and $val "IF("
1428
1429 $this->branchPruner->functionCall($valToUpper);
1430
1431 $stack->push('Function', $valToUpper);
1432 // tests if the function is closed right after opening
1433 $ax = preg_match('/^\s*\)/u', (string) substr($formula, $index + $length));
1434 if ($ax) {
1435 $stack->push('Operand Count for Function ' . $valToUpper . ')', 0);
1436 $expectingOperator = true;
1437 } else {
1438 $stack->push('Operand Count for Function ' . $valToUpper . ')', 1);
1439 $expectingOperator = false;
1440 }
1441 $stack->push('Brace', '(');
1442 } elseif (preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/miu', $val, $matches)) {
1443 // Watch for this case-change when modifying to allow cell references in different worksheets...
1444 // Should only be applied to the actual cell column, not the worksheet name
1445 // If the last entry on the stack was a : operator, then we have a cell range reference
1446 $testPrevOp = $stack->last(1);
1447 if ($testPrevOp !== null && $testPrevOp['value'] === ':') {
1448 // If we have a worksheet reference, then we're playing with a 3D reference
1449 if ($matches[2] === '') {
1450 // Otherwise, we 'inherit' the worksheet reference from the start cell reference
1451 // The start of the cell range reference should be the last entry in $output
1452 $rangeStartCellRef = $output[count($output) - 1]['value'] ?? '';
1453 if ($rangeStartCellRef === ':') {
1454 // Do we have chained range operators?
1455 $rangeStartCellRef = $output[count($output) - 2]['value'] ?? '';
1456 }
1457 /** @var string $rangeStartCellRef */
1458 preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/miu', $rangeStartCellRef, $rangeStartMatches);
1459 if (array_key_exists(2, $rangeStartMatches)) {
1460 if ($rangeStartMatches[2] > '') {
1461 $val = $rangeStartMatches[2] . '!' . $val;
1462 }
1463 } else {
1464 $val = ExcelError::REF();
1465 }
1466 } else {
1467 $rangeStartCellRef = $output[count($output) - 1]['value'] ?? '';
1468 if ($rangeStartCellRef === ':') {
1469 // Do we have chained range operators?
1470 $rangeStartCellRef = $output[count($output) - 2]['value'] ?? '';
1471 }
1472 /** @var string $rangeStartCellRef */
1473 preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/miu', $rangeStartCellRef, $rangeStartMatches);
1474 if (isset($rangeStartMatches[2]) && $rangeStartMatches[2] !== $matches[2]) {
1475 return $this->raiseFormulaError('3D Range references are not yet supported');
1476 }
1477 }
1478 } elseif (!str_contains($val, '!') && $pCellParent !== null) {
1479 $worksheet = $pCellParent->getTitle();
1480 $val = "'{$worksheet}'!{$val}";
1481 }
1482 // unescape any apostrophes or double quotes in worksheet name
1483 $val = str_replace(["''", '""'], ["'", '"'], $val);
1484 $outputItem = $stack->getStackItem('Cell Reference', $val, $val);
1485
1486 $output[] = $outputItem;
1487 } elseif (preg_match('/^' . self::CALCULATION_REGEXP_STRUCTURED_REFERENCE . '$/miu', $val, $matches)) {
1488 try {
1489 $structuredReference = Operands\StructuredReference::fromParser($formula, $index, $matches);
1490 } catch (Exception $e) {
1491 return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
1492 }
1493
1494 $val = $structuredReference->value();
1495 $length = strlen($val);
1496 $outputItem = $stack->getStackItem(Operands\StructuredReference::NAME, $structuredReference, null);
1497
1498 $output[] = $outputItem;
1499 $expectingOperator = true;
1500 } else {
1501 // it's a variable, constant, string, number or boolean
1502 $localeConstant = false;
1503 $stackItemType = 'Value';
1504 $stackItemReference = null;
1505
1506 // If the last entry on the stack was a : operator, then we may have a row or column range reference
1507 $testPrevOp = $stack->last(1);
1508 if ($testPrevOp !== null && $testPrevOp['value'] === ':') {
1509 $stackItemType = 'Cell Reference';
1510
1511 if (
1512 !is_numeric($val)
1513 && ((ctype_alpha($val) === false || strlen($val) > 3))
1514 && (preg_match('/^' . self::CALCULATION_REGEXP_DEFINEDNAME . '$/mui', $val) !== false)
1515 && ($this->spreadsheet === null || $this->spreadsheet->getNamedRange($val) !== null)
1516 ) {
1517 $namedRange = ($this->spreadsheet === null) ? null : $this->spreadsheet->getNamedRange($val);
1518 if ($namedRange !== null) {
1519 $stackItemType = 'Defined Name';
1520 $address = str_replace('$', '', $namedRange->getValue());
1521 $stackItemReference = $val;
1522 if (str_contains($address, ':')) {
1523 // We'll need to manipulate the stack for an actual named range rather than a named cell
1524 $fromTo = explode(':', $address);
1525 $to = array_pop($fromTo);
1526 foreach ($fromTo as $from) {
1527 $output[] = $stack->getStackItem($stackItemType, $from, $stackItemReference);
1528 $output[] = $stack->getStackItem('Binary Operator', ':');
1529 }
1530 $address = $to;
1531 }
1532 $val = $address;
1533 }
1534 } elseif ($val === ExcelError::REF()) {
1535 $stackItemReference = $val;
1536 } else {
1537 /** @var non-empty-string $startRowColRef */
1538 $startRowColRef = $output[count($output) - 1]['value'] ?? '';
1539 [$rangeWS1, $startRowColRef] = Worksheet::extractSheetTitle($startRowColRef, true);
1540 $rangeSheetRef = $rangeWS1;
1541 if ($rangeWS1 !== '') {
1542 $rangeWS1 .= '!';
1543 }
1544 if (str_starts_with($rangeSheetRef, "'")) {
1545 $rangeSheetRef = Worksheet::unApostrophizeTitle($rangeSheetRef);
1546 }
1547 [$rangeWS2, $val] = Worksheet::extractSheetTitle($val, true);
1548 if ($rangeWS2 !== '') {
1549 $rangeWS2 .= '!';
1550 } else {
1551 $rangeWS2 = $rangeWS1;
1552 }
1553
1554 $refSheet = $pCellParent;
1555 if ($pCellParent !== null && $rangeSheetRef !== '' && $rangeSheetRef !== $pCellParent->getTitle()) {
1556 $refSheet = $pCellParent->getParentOrThrow()->getSheetByName($rangeSheetRef);
1557 }
1558
1559 if (ctype_digit($val) && $val <= AddressRange::MAX_ROW) {
1560 // Row range
1561 $stackItemType = 'Row Reference';
1562 $valx = $val;
1563 $endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataColumn($valx) : AddressRange::MAX_COLUMN; // Max 16,384 columns for Excel2007
1564 $val = "{$rangeWS2}{$endRowColRef}{$val}";
1565 } elseif (ctype_alpha($val) && strlen($val) <= 3) {
1566 // Column range
1567 $stackItemType = 'Column Reference';
1568 $endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataRow() : AddressRange::MAX_ROW; // Max 1,048,576 rows for Excel2007
1569 $val = "{$rangeWS2}{$val}{$endRowColRef}";
1570 }
1571 $stackItemReference = $val;
1572 }
1573 } elseif ($opCharacter === self::FORMULA_STRING_QUOTE) {
1574 // UnEscape any quotes within the string
1575 $val = self::wrapResult(str_replace('""', self::FORMULA_STRING_QUOTE, StringHelper::convertToString(self::unwrapResult($val))));
1576 } elseif (isset(self::EXCEL_CONSTANTS[trim(strtoupper($val))])) {
1577 $stackItemType = 'Constant';
1578 $excelConstant = trim(strtoupper($val));
1579 $val = self::EXCEL_CONSTANTS[$excelConstant];
1580 $stackItemReference = $excelConstant;
1581 } elseif (($localeConstant = array_search(trim(strtoupper($val)), self::$localeBoolean)) !== false) {
1582 $stackItemType = 'Constant';
1583 $val = self::EXCEL_CONSTANTS[$localeConstant];
1584 $stackItemReference = $localeConstant;
1585 } elseif (
1586 preg_match('/^' . self::CALCULATION_REGEXP_ROW_RANGE . '/miu', (string) substr($formula, $index), $rowRangeReference)
1587 ) {
1588 $val = $rowRangeReference[1];
1589 $length = strlen($rowRangeReference[1]);
1590 $stackItemType = 'Row Reference';
1591 // unescape any apostrophes or double quotes in worksheet name
1592 $val = str_replace(["''", '""'], ["'", '"'], $val);
1593 $column = 'A';
1594 if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) { // @phpstan-ignore booleanAnd.alwaysFalse (testPrevop must be non-null), booleanAnd.alwaysFalse (ditto), identical.alwaysFalse (ditto)
1595 $column = $pCellParent->getHighestDataColumn($val);
1596 }
1597 $val = "{$rowRangeReference[2]}{$column}{$rowRangeReference[7]}";
1598 $stackItemReference = $val;
1599 } elseif (
1600 preg_match('/^' . self::CALCULATION_REGEXP_COLUMN_RANGE . '/miu', (string) substr($formula, $index), $columnRangeReference)
1601 ) {
1602 $val = $columnRangeReference[1];
1603 $length = strlen($val);
1604 $stackItemType = 'Column Reference';
1605 // unescape any apostrophes or double quotes in worksheet name
1606 $val = str_replace(["''", '""'], ["'", '"'], $val);
1607 $row = '1';
1608 if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) { // @phpstan-ignore booleanAnd.alwaysFalse (testPrevOp must be non-null?), booleanAnd.alwaysFalse (ditto), identical.alwaysFalse (ditto)
1609 $row = $pCellParent->getHighestDataRow($val);
1610 }
1611 $val = "{$val}{$row}";
1612 $stackItemReference = $val;
1613 } elseif (preg_match('/^' . self::CALCULATION_REGEXP_DEFINEDNAME . '.*/miu', $val, $match)) {
1614 $stackItemType = 'Defined Name';
1615 $stackItemReference = $val;
1616 } elseif (is_numeric($val)) {
1617 if ((str_contains((string) $val, '.')) || (stripos((string) $val, 'e') !== false) || ($val > PHP_INT_MAX) || ($val < -PHP_INT_MAX)) {
1618 $val = (float) $val;
1619 } else {
1620 $val = (int) $val;
1621 }
1622 }
1623
1624 $details = $stack->getStackItem($stackItemType, $val, $stackItemReference);
1625 if ($localeConstant) {
1626 $details['localeValue'] = $localeConstant;
1627 }
1628 $output[] = $details;
1629 }
1630 $index += $length;
1631 } elseif ($opCharacter === '$') { // absolute row or column range
1632 ++$index;
1633 } elseif ($opCharacter === ')') { // miscellaneous error checking
1634 if ($expectingOperand) {
1635 $output[] = $stack->getStackItem('Empty Argument', null, 'NULL');
1636 $expectingOperand = false;
1637 $expectingOperator = true;
1638 } else {
1639 return $this->raiseFormulaError("Formula Error: Unexpected ')'");
1640 }
1641 } elseif (isset(self::CALCULATION_OPERATORS[$opCharacter]) && !$expectingOperator) {
1642 return $this->raiseFormulaError("Formula Error: Unexpected operator '$opCharacter'");
1643 } else { // I don't even want to know what you did to get here
1644 return $this->raiseFormulaError('Formula Error: An unexpected error occurred');
1645 }
1646 // Test for end of formula string
1647 if ($index == strlen($formula)) {
1648 // Did we end with an operator?.
1649 // Only valid for the % unary operator
1650 if ((isset(self::CALCULATION_OPERATORS[$opCharacter])) && ($opCharacter != '%')) {
1651 return $this->raiseFormulaError("Formula Error: Operator '$opCharacter' has no operands");
1652 }
1653
1654 break;
1655 }
1656 // Ignore white space
1657 while (($formula[$index] === "\n") || ($formula[$index] === "\r")) {
1658 ++$index;
1659 }
1660
1661 if ($formula[$index] === ' ') {
1662 while ($formula[$index] === ' ') {
1663 ++$index;
1664 }
1665
1666 // If we're expecting an operator, but only have a space between the previous and next operands (and both are
1667 // Cell References, Defined Names or Structured References) then we have an INTERSECTION operator
1668 $countOutputMinus1 = count($output) - 1;
1669 if (
1670 ($expectingOperator)
1671 && array_key_exists($countOutputMinus1, $output)
1672 && is_array($output[$countOutputMinus1])
1673 && array_key_exists('type', $output[$countOutputMinus1])
1674 && (
1675 (preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '.*/miu', (string) substr($formula, $index), $match))
1676 && ($output[$countOutputMinus1]['type'] === 'Cell Reference')
1677 || (preg_match('/^' . self::CALCULATION_REGEXP_DEFINEDNAME . '.*/miu', (string) substr($formula, $index), $match))
1678 && ($output[$countOutputMinus1]['type'] === 'Defined Name' || $output[$countOutputMinus1]['type'] === 'Value')
1679 || (preg_match('/^' . self::CALCULATION_REGEXP_STRUCTURED_REFERENCE . '.*/miu', (string) substr($formula, $index), $match))
1680 && ($output[$countOutputMinus1]['type'] === Operands\StructuredReference::NAME || $output[$countOutputMinus1]['type'] === 'Value')
1681 )
1682 ) {
1683 while (self::swapOperands($stack, $opCharacter)) {
1684 $output[] = $stack->pop(); // Swap operands and higher precedence operators from the stack to the output
1685 }
1686 $stack->push('Binary Operator', '∩'); // Put an Intersect Operator on the stack
1687 $expectingOperator = false;
1688 }
1689 }
1690 }
1691
1692 while (($op = $stack->pop()) !== null) {
1693 // pop everything off the stack and push onto output
1694 if ($op['value'] == '(') {
1695 return $this->raiseFormulaError("Formula Error: Expecting ')'"); // if there are any opening braces on the stack, then braces were unbalanced
1696 }
1697 $output[] = $op;
1698 }
1699
1700 return $output;
1701 }
1702
1703 /** @param mixed[] $operandData
1704 * @return mixed */
1705 private static function dataTestReference(array &$operandData)
1706 {
1707 $operand = $operandData['value'];
1708 if (($operandData['reference'] === null) && (is_array($operand))) {
1709 $rKeys = array_keys($operand);
1710 $rowKey = array_shift($rKeys);
1711 if (is_array($operand[$rowKey]) === false) {
1712 $operandData['value'] = $operand[$rowKey];
1713
1714 return $operand[$rowKey];
1715 }
1716
1717 $cKeys = array_keys(array_keys($operand[$rowKey]));
1718 $colKey = array_shift($cKeys);
1719 if (ctype_upper("$colKey")) {
1720 $operandData['reference'] = $colKey . $rowKey;
1721 }
1722 }
1723
1724 return $operand;
1725 }
1726
1727 private static int $matchIndex8 = 8;
1728
1729 private static int $matchIndex9 = 9;
1730
1731 private static int $matchIndex10 = 10;
1732
1733 /**
1734 * @param array<mixed>|false $tokens
1735 *
1736 * @return array<int, mixed>|false|string
1737 */
1738 private function processTokenStack($tokens, ?string $cellID = null, ?Cell $cell = null)
1739 {
1740 if ($tokens === false) {
1741 return false;
1742 }
1743 $phpSpreadsheetFunctions = &self::getFunctionsAddress();
1744
1745 // If we're using cell caching, then $pCell may well be flushed back to the cache (which detaches the parent cell collection),
1746 // so we store the parent cell collection so that we can re-attach it when necessary
1747 $pCellWorksheet = ($cell !== null) ? $cell->getWorksheet() : null;
1748 $originalCoordinate = ($nullsafeVariable1 = $cell) ? $nullsafeVariable1->getCoordinate() : null;
1749 $pCellParent = ($cell !== null) ? $cell->getParent() : null;
1750 $stack = new Stack($this->branchPruner);
1751
1752 // Stores branches that have been pruned
1753 $fakedForBranchPruning = [];
1754 // help us to know when pruning ['branchTestId' => true/false]
1755 $branchStore = [];
1756 // Loop through each token in turn
1757 foreach ($tokens as $tokenIdx => $tokenData) {
1758 /** @var mixed[] $tokenData */
1759 $this->processingAnchorArray = false;
1760 if ($tokenData['type'] === 'Cell Reference' && isset($tokens[$tokenIdx + 1]) && $tokens[$tokenIdx + 1]['type'] === 'Operand Count for Function ANCHORARRAY()') { //* @phpstan-ignore offsetAccess.nonOffsetAccessible ($tokens might be mixed not array)
1761 $this->processingAnchorArray = true;
1762 }
1763 $token = $tokenData['value'];
1764 // Branch pruning: skip useless resolutions
1765 /** @var ?string */
1766 $storeKey = $tokenData['storeKey'] ?? null;
1767 if ($this->branchPruningEnabled && isset($tokenData['onlyIf'])) {
1768 /** @var string */
1769 $onlyIfStoreKey = $tokenData['onlyIf'];
1770 $storeValue = $branchStore[$onlyIfStoreKey] ?? null;
1771 $storeValueAsBool = ($storeValue === null)
1772 ? true : (bool) Functions::flattenSingleValue($storeValue);
1773 if (is_array($storeValue)) {
1774 $wrappedItem = end($storeValue);
1775 $storeValue = is_array($wrappedItem) ? end($wrappedItem) : $wrappedItem;
1776 }
1777
1778 if (
1779 (isset($storeValue) || $tokenData['reference'] === 'NULL')
1780 && (!$storeValueAsBool || Information\ErrorValue::isError($storeValue) || ($storeValue === 'Pruned branch'))
1781 ) {
1782 // If branching value is not true, we don't need to compute
1783 /** @var string $onlyIfStoreKey */
1784 if (!isset($fakedForBranchPruning['onlyIf-' . $onlyIfStoreKey])) {
1785 /** @var string $token */
1786 $stack->push('Value', 'Pruned branch (only if ' . $onlyIfStoreKey . ') ' . $token);
1787 $fakedForBranchPruning['onlyIf-' . $onlyIfStoreKey] = true;
1788 }
1789
1790 if (isset($storeKey)) {
1791 // We are processing an if condition
1792 // We cascade the pruning to the depending branches
1793 $branchStore[$storeKey] = 'Pruned branch';
1794 $fakedForBranchPruning['onlyIfNot-' . $storeKey] = true;
1795 $fakedForBranchPruning['onlyIf-' . $storeKey] = true;
1796 }
1797
1798 continue;
1799 }
1800 }
1801
1802 if ($this->branchPruningEnabled && isset($tokenData['onlyIfNot'])) {
1803 /** @var string */
1804 $onlyIfNotStoreKey = $tokenData['onlyIfNot'];
1805 $storeValue = $branchStore[$onlyIfNotStoreKey] ?? null;
1806 $storeValueAsBool = ($storeValue === null)
1807 ? true : (bool) Functions::flattenSingleValue($storeValue);
1808 if (is_array($storeValue)) {
1809 $wrappedItem = end($storeValue);
1810 $storeValue = is_array($wrappedItem) ? end($wrappedItem) : $wrappedItem;
1811 }
1812
1813 if (
1814 (isset($storeValue) || $tokenData['reference'] === 'NULL')
1815 && ($storeValueAsBool || Information\ErrorValue::isError($storeValue) || ($storeValue === 'Pruned branch'))
1816 ) {
1817 // If branching value is true, we don't need to compute
1818 if (!isset($fakedForBranchPruning['onlyIfNot-' . $onlyIfNotStoreKey])) {
1819 /** @var string $token */
1820 $stack->push('Value', 'Pruned branch (only if not ' . $onlyIfNotStoreKey . ') ' . $token);
1821 $fakedForBranchPruning['onlyIfNot-' . $onlyIfNotStoreKey] = true;
1822 }
1823
1824 if (isset($storeKey)) {
1825 // We are processing an if condition
1826 // We cascade the pruning to the depending branches
1827 $branchStore[$storeKey] = 'Pruned branch';
1828 $fakedForBranchPruning['onlyIfNot-' . $storeKey] = true;
1829 $fakedForBranchPruning['onlyIf-' . $storeKey] = true;
1830 }
1831
1832 continue;
1833 }
1834 }
1835
1836 if ($token instanceof Operands\StructuredReference) {
1837 if ($cell === null) {
1838 return $this->raiseFormulaError('Structured References must exist in a Cell context');
1839 }
1840
1841 try {
1842 $cellRange = $token->parse($cell);
1843 if (str_contains($cellRange, ':')) {
1844 $this->debugLog->writeDebugLog('Evaluating Structured Reference %s as Cell Range %s', $token->value(), $cellRange);
1845 $rangeValue = self::getInstance($cell->getWorksheet()->getParent())->_calculateFormulaValue("={$cellRange}", $cellRange, $cell);
1846 $stack->push('Value', $rangeValue);
1847 $this->debugLog->writeDebugLog('Evaluated Structured Reference %s as value %s', $token->value(), $this->showValue($rangeValue));
1848 } else {
1849 $this->debugLog->writeDebugLog('Evaluating Structured Reference %s as Cell %s', $token->value(), $cellRange);
1850 $cellValue = $cell->getWorksheet()->getCell($cellRange)->getCalculatedValue(false);
1851 $stack->push('Cell Reference', $cellValue, $cellRange);
1852 $this->debugLog->writeDebugLog('Evaluated Structured Reference %s as value %s', $token->value(), $this->showValue($cellValue));
1853 }
1854 } catch (Exception $e) {
1855 if ($e->getCode() === Exception::CALCULATION_ENGINE_PUSH_TO_STACK) {
1856 $stack->push('Error', ExcelError::REF(), null);
1857 $this->debugLog->writeDebugLog('Evaluated Structured Reference %s as error value %s', $token->value(), ExcelError::REF());
1858 } else {
1859 return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
1860 }
1861 }
1862 } elseif (!is_numeric($token) && !is_object($token) && isset($token, self::BINARY_OPERATORS[$token])) { //* @phpstan-ignore offsetAccess.invalidOffset ($token is mixed)
1863 // if the token is a binary operator, pop the top two values off the stack, do the operation, and push the result back on the stack
1864 // We must have two operands, error if we don't
1865 $operand2Data = $stack->pop();
1866 if ($operand2Data === null) {
1867 return $this->raiseFormulaError('Internal error - Operand value missing from stack');
1868 }
1869 $operand1Data = $stack->pop();
1870 if ($operand1Data === null) {
1871 return $this->raiseFormulaError('Internal error - Operand value missing from stack');
1872 }
1873
1874 $operand1 = self::dataTestReference($operand1Data);
1875 $operand2 = self::dataTestReference($operand2Data);
1876
1877 // Log what we're doing
1878 if ($token == ':') {
1879 $this->debugLog->writeDebugLog('Evaluating Range %s %s %s', $this->showValue($operand1Data['reference']), $token, $this->showValue($operand2Data['reference']));
1880 } else {
1881 $this->debugLog->writeDebugLog('Evaluating %s %s %s', $this->showValue($operand1), $token, $this->showValue($operand2));
1882 }
1883
1884 // Process the operation in the appropriate manner
1885 switch ($token) {
1886 // Comparison (Boolean) Operators
1887 case '>': // Greater than
1888 case '<': // Less than
1889 case '>=': // Greater than or Equal to
1890 case '<=': // Less than or Equal to
1891 case '=': // Equality
1892 case '<>': // Inequality
1893 $result = $this->executeBinaryComparisonOperation($operand1, $operand2, (string) $token, $stack);
1894 if (isset($storeKey)) {
1895 $branchStore[$storeKey] = $result;
1896 }
1897
1898 break;
1899 // Binary Operators
1900 case ':': // Range
1901 if ($operand1Data['type'] === 'Error') {
1902 $stack->push($operand1Data['type'], $operand1Data['value'], null);
1903
1904 break;
1905 }
1906 if ($operand2Data['type'] === 'Error') {
1907 $stack->push($operand2Data['type'], $operand2Data['value'], null);
1908
1909 break;
1910 }
1911 if ($operand1Data['type'] === 'Defined Name') {
1912 /** @var array{reference: string} $operand1Data */
1913 if (preg_match('/$' . self::CALCULATION_REGEXP_DEFINEDNAME . '^/mui', $operand1Data['reference']) !== false && $this->spreadsheet !== null) {
1914 /** @var string[] $operand1Data */
1915 $definedName = $this->spreadsheet->getNamedRange($operand1Data['reference']);
1916 if ($definedName !== null) {
1917 $operand1Data['reference'] = $operand1Data['value'] = str_replace('$', '', $definedName->getValue());
1918 }
1919 }
1920 }
1921 /** @var array{reference?: ?string} $operand1Data */
1922 if (str_contains($operand1Data['reference'] ?? '', '!')) {
1923 [$sheet1, $operand1Data['reference']] = Worksheet::extractSheetTitle($operand1Data['reference'], true, true);
1924 } else {
1925 $sheet1 = ($pCellWorksheet !== null) ? $pCellWorksheet->getTitle() : '';
1926 }
1927 //$sheet1 ??= ''; // phpstan level 10 says this is unneeded
1928
1929 /** @var string */
1930 $op2ref = $operand2Data['reference'];
1931 [$sheet2, $operand2Data['reference']] = Worksheet::extractSheetTitle($op2ref, true, true);
1932 if (empty($sheet2)) {
1933 $sheet2 = $sheet1;
1934 }
1935
1936 if ($sheet1 === $sheet2) {
1937 /** @var array{reference: ?string, value: string|string[]} $operand1Data */
1938 if ($operand1Data['reference'] === null && $cell !== null) {
1939 if (is_array($operand1Data['value'])) {
1940 $operand1Data['reference'] = $cell->getCoordinate();
1941 } elseif ((trim($operand1Data['value']) != '') && (is_numeric($operand1Data['value']))) {
1942 $operand1Data['reference'] = $cell->getColumn() . $operand1Data['value'];
1943 } elseif (trim($operand1Data['value']) == '') {
1944 $operand1Data['reference'] = $cell->getCoordinate();
1945 } else {
1946 $operand1Data['reference'] = $operand1Data['value'] . $cell->getRow();
1947 }
1948 }
1949 /** @var array{reference: ?string, value: string|string[]} $operand2Data */
1950 if ($operand2Data['reference'] === null && $cell !== null) {
1951 if (is_array($operand2Data['value'])) {
1952 $operand2Data['reference'] = $cell->getCoordinate();
1953 } elseif ((trim($operand2Data['value']) != '') && (is_numeric($operand2Data['value']))) {
1954 $operand2Data['reference'] = $cell->getColumn() . $operand2Data['value'];
1955 } elseif (trim($operand2Data['value']) == '') {
1956 $operand2Data['reference'] = $cell->getCoordinate();
1957 } else {
1958 $operand2Data['reference'] = $operand2Data['value'] . $cell->getRow();
1959 }
1960 }
1961
1962 $oData = array_merge(explode(':', $operand1Data['reference'] ?? ''), explode(':', $operand2Data['reference'] ?? ''));
1963 $oCol = $oRow = [];
1964 $breakNeeded = false;
1965 foreach ($oData as $oDatum) {
1966 try {
1967 $oCR = Coordinate::coordinateFromString($oDatum);
1968 $oCol[] = Coordinate::columnIndexFromString($oCR[0]) - 1;
1969 $oRow[] = $oCR[1];
1970 } catch (\Exception $exception) {
1971 $stack->push('Error', ExcelError::REF(), null);
1972 $breakNeeded = true;
1973
1974 break;
1975 }
1976 }
1977 if ($breakNeeded) {
1978 break;
1979 }
1980 $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow);
1981 if ($pCellParent !== null && $this->spreadsheet !== null) {
1982 $cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($sheet1), false);
1983 } else {
1984 return $this->raiseFormulaError('Unable to access Cell Reference');
1985 }
1986
1987 $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellValue));
1988 $stack->push('Cell Reference', $cellValue, $cellRef);
1989 } else {
1990 $this->debugLog->writeDebugLog('Evaluation Result is a #REF! Error');
1991 $stack->push('Error', ExcelError::REF(), null);
1992 }
1993
1994 break;
1995 case '+': // Addition
1996 case '-': // Subtraction
1997 case '*': // Multiplication
1998 case '/': // Division
1999 case '^': // Exponential
2000 $result = $this->executeNumericBinaryOperation($operand1, $operand2, $token, $stack);
2001 if (isset($storeKey)) {
2002 $branchStore[$storeKey] = $result;
2003 }
2004
2005 break;
2006 case '&': // Concatenation
2007 // If either of the operands is a matrix, we need to treat them both as matrices
2008 // (converting the other operand to a matrix if need be); then perform the required
2009 // matrix operation
2010 $operand1 = self::boolToString($operand1);
2011 $operand2 = self::boolToString($operand2);
2012 if (is_array($operand1) || is_array($operand2)) {
2013 if (is_string($operand1)) {
2014 $operand1 = self::unwrapResult($operand1);
2015 }
2016 if (is_string($operand2)) {
2017 $operand2 = self::unwrapResult($operand2);
2018 }
2019 // Ensure that both operands are arrays/matrices
2020 [$rows, $columns] = self::checkMatrixOperands($operand1, $operand2, 2);
2021
2022 for ($row = 0; $row < $rows; ++$row) {
2023 for ($column = 0; $column < $columns; ++$column) {
2024 /** @var mixed[][] $operand1 */
2025 $op1x = self::boolToString($operand1[$row][$column]);
2026 /** @var mixed[][] $operand2 */
2027 $op2x = self::boolToString($operand2[$row][$column]);
2028 if (Information\ErrorValue::isError($op1x)) {
2029 // no need to do anything
2030 } elseif (Information\ErrorValue::isError($op2x)) {
2031 $operand1[$row][$column] = $op2x;
2032 } else {
2033 /** @var string $op1x */
2034 /** @var string $op2x */
2035 $operand1[$row][$column]
2036 = StringHelper::substring(
2037 $op1x . $op2x,
2038 0,
2039 DataType::MAX_STRING_LENGTH
2040 );
2041 }
2042 }
2043 }
2044 $result = $operand1;
2045 } else {
2046 if (Information\ErrorValue::isError($operand1)) {
2047 $result = $operand1;
2048 } elseif (Information\ErrorValue::isError($operand2)) {
2049 $result = $operand2;
2050 } else {
2051 $result = str_replace('""', self::FORMULA_STRING_QUOTE, self::unwrapResult($operand1) . self::unwrapResult($operand2)); //* @phpstan-ignore binaryOp.invalid (unwrapresult can return mixed rather than string)
2052 $result = StringHelper::substring(
2053 $result,
2054 0,
2055 DataType::MAX_STRING_LENGTH
2056 );
2057 $result = self::FORMULA_STRING_QUOTE . $result . self::FORMULA_STRING_QUOTE;
2058 }
2059 }
2060 $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result));
2061 $stack->push('Value', $result);
2062
2063 if (isset($storeKey)) {
2064 $branchStore[$storeKey] = $result;
2065 }
2066
2067 break;
2068 case '∩': // Intersect
2069 /** @var mixed[][] $operand1 */
2070 /** @var mixed[][] $operand2 */
2071 $rowIntersect = array_intersect_key($operand1, $operand2);
2072 $cellIntersect = $oCol = $oRow = [];
2073 foreach (array_keys($rowIntersect) as $row) {
2074 $oRow[] = $row;
2075 foreach ($rowIntersect[$row] as $col => $data) {
2076 $oCol[] = Coordinate::columnIndexFromString($col) - 1;
2077 $cellIntersect[$row] = array_intersect_key($operand1[$row], $operand2[$row]);
2078 }
2079 }
2080 if (count(Functions::flattenArray($cellIntersect)) === 0) {
2081 $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
2082 $stack->push('Error', ExcelError::null(), null);
2083 } else {
2084 $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' // @phpstan-ignore argument.type ($oCol or $oRow might be empty), argument.type (ditto)
2085 . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore argument.type ($oCol or $oRow might be empty), argument.type (ditto)
2086 $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
2087 $stack->push('Value', $cellIntersect, $cellRef);
2088 }
2089
2090 break;
2091 case '∪': // union
2092 /** @var mixed[][] $operand1 */
2093 /** @var mixed[][] $operand2 */
2094 $cellUnion = array_merge($operand1, $operand2);
2095 $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellUnion));
2096 $stack->push('Value', $cellUnion, 'A1');
2097
2098 break;
2099 }
2100 } elseif (($token === '~') || ($token === '%')) { // @phpstan-ignore booleanOr.alwaysFalse (phpstan says token can't be plain string), identical.alwaysFalse (ditto), identical.alwaysFalse (ditto)
2101 // if the token is a unary operator, pop one value off the stack, do the operation, and push it back on
2102 if (($arg = $stack->pop()) === null) {
2103 return $this->raiseFormulaError('Internal error - Operand value missing from stack');
2104 }
2105 $arg = $arg['value'];
2106 if ($token === '~') { // @phpstan-ignore identical.alwaysFalse (phpstan says token can't be plain string)
2107 $this->debugLog->writeDebugLog('Evaluating Negation of %s', $this->showValue($arg));
2108 $multiplier = -1;
2109 } else {
2110 $this->debugLog->writeDebugLog('Evaluating Percentile of %s', $this->showValue($arg));
2111 $multiplier = 0.01;
2112 }
2113 if (is_array($arg)) {
2114 $operand2 = $multiplier;
2115 $result = $arg;
2116 [$rows, $columns] = self::checkMatrixOperands($result, $operand2, 0);
2117 for ($row = 0; $row < $rows; ++$row) {
2118 for ($column = 0; $column < $columns; ++$column) {
2119 /** @var mixed[][] $result */
2120 if (self::isNumericOrBool($result[$row][$column])) {
2121 /** @var float|int|numeric-string */
2122 $temp = $result[$row][$column];
2123 $result[$row][$column] = $temp * $multiplier;
2124 } else {
2125 $result[$row][$column] = self::makeError($result[$row][$column]);
2126 }
2127 }
2128 }
2129
2130 $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result));
2131 $stack->push('Value', $result);
2132 if (isset($storeKey)) {
2133 $branchStore[$storeKey] = $result;
2134 }
2135 } else {
2136 $this->executeNumericBinaryOperation($multiplier, $arg, '*', $stack);
2137 }
2138 } elseif (Preg::isMatch('/^' . self::CALCULATION_REGEXP_CELLREF . '$/i', StringHelper::convertToString($token ?? ''), $matches)) {
2139 $cellRef = null;
2140
2141 /* Phpstan says matches[8/9/10] is never set,
2142 and code coverage report seems to confirm.
2143 regex101.com confirms - only 7 capturing groups.
2144 My theory is that this code expected regexp to
2145 match cell *or* cellRange, but it does not
2146 match the latter. Retain the code for now in case
2147 we do want to add the range match later.
2148 Probably delete this block later.
2149 Until delete happens, turn code coverage off.
2150 */
2151 if (isset($matches[self::$matchIndex8])) {
2152 // @codeCoverageIgnoreStart
2153 if ($cell === null) {
2154 // We can't access the range, so return a REF error
2155 $cellValue = ExcelError::REF();
2156 } else {
2157 $cellRef = $matches[6] . $matches[7] . ':' . $matches[self::$matchIndex9] . $matches[self::$matchIndex10];
2158 $matches[2] = (string) $matches[2];
2159 if ($matches[2] > '') {
2160 $matches[2] = trim($matches[2], "\"'");
2161 if ((str_contains($matches[2], '[')) || (str_contains($matches[2], ']'))) {
2162 // It's a Reference to an external spreadsheet (not currently supported)
2163 return $this->raiseFormulaError('Unable to access External Workbook');
2164 }
2165 $matches[2] = trim($matches[2], "\"'");
2166 $this->debugLog->writeDebugLog('Evaluating Cell Range %s in worksheet %s', $cellRef, $matches[2]);
2167 if ($pCellParent !== null && $this->spreadsheet !== null) {
2168 $cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($matches[2]), false);
2169 } else {
2170 return $this->raiseFormulaError('Unable to access Cell Reference');
2171 }
2172 $this->debugLog->writeDebugLog('Evaluation Result for cells %s in worksheet %s is %s', $cellRef, $matches[2], $this->showTypeDetails($cellValue));
2173 } else {
2174 $this->debugLog->writeDebugLog('Evaluating Cell Range %s in current worksheet', $cellRef);
2175 if ($pCellParent !== null) {
2176 $cellValue = $this->extractCellRange($cellRef, $pCellWorksheet, false);
2177 } else {
2178 return $this->raiseFormulaError('Unable to access Cell Reference');
2179 }
2180 $this->debugLog->writeDebugLog('Evaluation Result for cells %s is %s', $cellRef, $this->showTypeDetails($cellValue));
2181 }
2182 }
2183 // @codeCoverageIgnoreEnd
2184 } else {
2185 if ($cell === null) {
2186 // We can't access the cell, so return a REF error
2187 $cellValue = ExcelError::REF();
2188 } else {
2189 $cellRef = $matches[6] . $matches[7];
2190 $matches[2] = (string) $matches[2];
2191 if ($matches[2] > '') {
2192 $matches[2] = trim($matches[2], "\"'");
2193 if ((str_contains($matches[2], '[')) || (str_contains($matches[2], ']'))) {
2194 // It's a Reference to an external spreadsheet (not currently supported)
2195 return $this->raiseFormulaError('Unable to access External Workbook');
2196 }
2197 $this->debugLog->writeDebugLog('Evaluating Cell %s in worksheet %s', $cellRef, $matches[2]);
2198 if ($pCellParent !== null && $this->spreadsheet !== null) {
2199 $cellSheet = $this->spreadsheet->getSheetByName($matches[2]);
2200 if ($cellSheet && !$cellSheet->cellExists($cellRef)) {
2201 try {
2202 $cellSheet->setCellValue($cellRef, null);
2203 } catch (SpreadsheetException $exception) {
2204 // do nothing
2205 }
2206 }
2207 if ($cellSheet && $cellSheet->cellExists($cellRef)) {
2208 $cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($matches[2]), false);
2209 $cell->attach($pCellParent);
2210 } else {
2211 $cellRef = ($cellSheet !== null) ? "'{$matches[2]}'!{$cellRef}" : $cellRef;
2212 $cellValue = ($cellSheet !== null) ? null : ExcelError::REF();
2213 }
2214 } else {
2215 return $this->raiseFormulaError('Unable to access Cell Reference');
2216 }
2217 $this->debugLog->writeDebugLog('Evaluation Result for cell %s in worksheet %s is %s', $cellRef, $matches[2], $this->showTypeDetails($cellValue));
2218 } else {
2219 $this->debugLog->writeDebugLog('Evaluating Cell %s in current worksheet', $cellRef);
2220 if ($pCellParent !== null && $pCellParent->has($cellRef)) {
2221 $cellValue = $this->extractCellRange($cellRef, $pCellWorksheet, false);
2222 $cell->attach($pCellParent);
2223 } else {
2224 $cellValue = null;
2225 }
2226 $this->debugLog->writeDebugLog('Evaluation Result for cell %s is %s', $cellRef, $this->showTypeDetails($cellValue));
2227 }
2228 }
2229 }
2230
2231 if ($this->getInstanceArrayReturnType() === self::RETURN_ARRAY_AS_ARRAY && !$this->processingAnchorArray && is_array($cellValue)) {
2232 while (is_array($cellValue)) {
2233 $cellValue = array_shift($cellValue);
2234 }
2235 if (is_string($cellValue)) {
2236 $cellValue = Preg::replace('/"/', '""', $cellValue);
2237 }
2238 $this->debugLog->writeDebugLog('Scalar Result for cell %s is %s', $cellRef, $this->showTypeDetails($cellValue));
2239 }
2240 $this->processingAnchorArray = false;
2241 $stack->push('Cell Value', $cellValue, $cellRef);
2242 if (isset($storeKey)) {
2243 $branchStore[$storeKey] = $cellValue;
2244 }
2245 } elseif (preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', StringHelper::convertToString($token ?? ''), $matches)) {
2246 // if the token is a function, pop arguments off the stack, hand them to the function, and push the result back on
2247 if ($cell !== null && $pCellParent !== null) {
2248 $cell->attach($pCellParent);
2249 }
2250
2251 $functionName = $matches[1];
2252 /** @var array<string, int> $argCount */
2253 $argCount = $stack->pop();
2254 $argCount = $argCount['value'];
2255 if ($functionName !== 'MKMATRIX') {
2256 $this->debugLog->writeDebugLog('Evaluating Function %s() with %s argument%s', self::localeFunc($functionName), (($argCount == 0) ? 'no' : $argCount), (($argCount == 1) ? '' : 's'));
2257 }
2258 if ((isset($phpSpreadsheetFunctions[$functionName])) || (isset(self::$controlFunctions[$functionName]))) { // function
2259 $passByReference = false;
2260 $passCellReference = false;
2261 $functionCall = null;
2262 if (isset($phpSpreadsheetFunctions[$functionName])) {
2263 $functionCall = $phpSpreadsheetFunctions[$functionName]['functionCall'];
2264 $passByReference = isset($phpSpreadsheetFunctions[$functionName]['passByReference']);
2265 $passCellReference = isset($phpSpreadsheetFunctions[$functionName]['passCellReference']);
2266 } elseif (isset(self::$controlFunctions[$functionName])) {
2267 $functionCall = self::$controlFunctions[$functionName]['functionCall'];
2268 $passByReference = isset(self::$controlFunctions[$functionName]['passByReference']);
2269 $passCellReference = isset(self::$controlFunctions[$functionName]['passCellReference']);
2270 }
2271
2272 // get the arguments for this function
2273 $args = $argArrayVals = [];
2274 $emptyArguments = [];
2275 for ($i = 0; $i < $argCount; ++$i) {
2276 $arg = $stack->pop();
2277 $a = $argCount - $i - 1;
2278 if (
2279 ($passByReference)
2280 && (isset($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])) //* @phpstan-ignore offsetAccess.nonOffsetAccessible (possibly need to pass 2 arguments to isset)
2281 && ($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])
2282 ) {
2283 /** @var mixed[] $arg */
2284 if ($arg['reference'] === null) {
2285 $nextArg = $cellID;
2286 if ($functionName === 'ISREF' && ($arg['type'] ?? '') === 'Value') {
2287 if (array_key_exists('value', $arg)) {
2288 $argValue = $arg['value'];
2289 if (is_scalar($argValue)) {
2290 $nextArg = $argValue;
2291 } elseif (empty($argValue)) {
2292 $nextArg = '';
2293 }
2294 }
2295 } elseif (($arg['type'] ?? '') === 'Error') {
2296 $argValue = $arg['value'];
2297 if (is_scalar($argValue)) {
2298 $nextArg = $argValue;
2299 } elseif (empty($argValue)) {
2300 $nextArg = '';
2301 }
2302 }
2303 $args[] = $nextArg;
2304 if ($functionName !== 'MKMATRIX') {
2305 $argArrayVals[] = $this->showValue($cellID);
2306 }
2307 } else {
2308 $args[] = $arg['reference'];
2309 if ($functionName !== 'MKMATRIX') {
2310 $argArrayVals[] = $this->showValue($arg['reference']);
2311 }
2312 }
2313 } else {
2314 /** @var mixed[] $arg */
2315 if ($arg['type'] === 'Empty Argument' && in_array($functionName, ['MIN', 'MINA', 'MAX', 'MAXA', 'IF'], true)) {
2316 $emptyArguments[] = false;
2317 $args[] = $arg['value'] = 0;
2318 $this->debugLog->writeDebugLog('Empty Argument reevaluated as 0');
2319 } else {
2320 $emptyArguments[] = $arg['type'] === 'Empty Argument';
2321 $args[] = self::unwrapResult($arg['value']);
2322 }
2323 if ($functionName !== 'MKMATRIX') {
2324 $argArrayVals[] = $this->showValue($arg['value']);
2325 }
2326 }
2327 }
2328
2329 // Reverse the order of the arguments
2330 krsort($args);
2331 krsort($emptyArguments);
2332
2333 if ($argCount > 0 && is_array($functionCall)) {
2334 /** @var string[] */
2335 $functionCallCopy = $functionCall;
2336 $args = $this->addDefaultArgumentValues($functionCallCopy, $args, $emptyArguments);
2337 }
2338
2339 if (($passByReference) && ($argCount == 0)) {
2340 $args[] = $cellID;
2341 $argArrayVals[] = $this->showValue($cellID);
2342 }
2343
2344 if ($functionName !== 'MKMATRIX') {
2345 if ($this->debugLog->getWriteDebugLog()) {
2346 krsort($argArrayVals);
2347 $this->debugLog->writeDebugLog('Evaluating %s ( %s )', self::localeFunc($functionName), implode(self::$localeArgumentSeparator . ' ', Functions::flattenArray($argArrayVals))); // @phpstan-ignore argument.type (flattenArray returns array<mixed> rather than array<string>)
2348 }
2349 }
2350
2351 // Process the argument with the appropriate function call
2352 if ($pCellWorksheet !== null && $originalCoordinate !== null) {
2353 $pCellWorksheet->getCell($originalCoordinate);
2354 }
2355 /** @var array<string>|string $functionCall */
2356 $args = $this->addCellReference($args, $passCellReference, $functionCall, $cell);
2357
2358 if (!is_array($functionCall)) {
2359 foreach ($args as &$arg) {
2360 $arg = Functions::flattenSingleValue($arg);
2361 }
2362 unset($arg);
2363 }
2364
2365 /** @var callable $functionCall */
2366 try {
2367 $result = call_user_func_array($functionCall, $args);
2368 } catch (TypeError $e) {
2369 if (!$this->suppressFormulaErrors) {
2370 throw $e;
2371 }
2372 $result = false;
2373 }
2374 if ($functionName !== 'MKMATRIX') {
2375 $this->debugLog->writeDebugLog('Evaluation Result for %s() function call is %s', self::localeFunc($functionName), $this->showTypeDetails($result));
2376 }
2377 $stack->push('Value', self::wrapResult($result));
2378 if (isset($storeKey)) {
2379 $branchStore[$storeKey] = $result;
2380 }
2381 }
2382 } else {
2383 // if the token is a number, boolean, string or an Excel error, push it onto the stack
2384 /** @var null|numeric-string $token */
2385 if (isset(self::EXCEL_CONSTANTS[strtoupper($token ?? '')])) {
2386 $excelConstant = strtoupper("$token");
2387 $stack->push('Constant Value', self::EXCEL_CONSTANTS[$excelConstant]);
2388 if (isset($storeKey)) {
2389 $branchStore[$storeKey] = self::EXCEL_CONSTANTS[$excelConstant];
2390 }
2391 $this->debugLog->writeDebugLog('Evaluating Constant %s as %s', $excelConstant, $this->showTypeDetails(self::EXCEL_CONSTANTS[$excelConstant]));
2392 } elseif ((is_numeric($token)) || ($token === null) || (is_bool($token)) || ($token == '') || ($token[0] == self::FORMULA_STRING_QUOTE) || ($token[0] == '#')) { //* @phpstan-ignore function.alreadyNarrowedType (phpstan says is_bool has argument of *NEVER*?), equal.alwaysFalse (ditto)
2393 /** @var array{type: string, reference: ?string} $tokenData */
2394 $stack->push($tokenData['type'], $token, $tokenData['reference']);
2395 if (isset($storeKey)) {
2396 $branchStore[$storeKey] = $token;
2397 }
2398 } elseif (preg_match('/^' . self::CALCULATION_REGEXP_DEFINEDNAME . '$/miu', $token, $matches)) {
2399 // if the token is a named range or formula, evaluate it and push the result onto the stack
2400 $definedName = $matches[6];
2401 if (str_starts_with($definedName, '_xleta')) {
2402 return Functions::NOT_YET_IMPLEMENTED;
2403 }
2404 if ($cell === null || $pCellWorksheet === null) {
2405 return $this->raiseFormulaError("undefined name '$token'");
2406 }
2407 $specifiedWorksheet = trim($matches[2], "'");
2408
2409 $this->debugLog->writeDebugLog('Evaluating Defined Name %s', $definedName);
2410 $namedRange = DefinedName::resolveName($definedName, $pCellWorksheet, $specifiedWorksheet);
2411 // If not Defined Name, try as Table.
2412 if ($namedRange === null && $this->spreadsheet !== null) {
2413 $table = $this->spreadsheet->getTableByName($definedName);
2414 if ($table !== null) {
2415 $tableRange = Coordinate::getRangeBoundaries($table->getRange());
2416 if ($table->getShowHeaderRow()) {
2417 ++$tableRange[0][1];
2418 }
2419 if ($table->getShowTotalsRow()) {
2420 --$tableRange[1][1];
2421 }
2422 $tableRangeString
2423 = '$' . $tableRange[0][0]
2424 . '$' . $tableRange[0][1]
2425 . ':'
2426 . '$' . $tableRange[1][0]
2427 . '$' . $tableRange[1][1];
2428 $namedRange = new NamedRange($definedName, $table->getWorksheet(), $tableRangeString);
2429 }
2430 }
2431 if ($namedRange === null) {
2432 $result = ExcelError::NAME();
2433 $stack->push('Error', $result, null);
2434 $this->debugLog->writeDebugLog("Error $result");
2435 } else {
2436 $result = $this->evaluateDefinedName($cell, $namedRange, $pCellWorksheet, $stack, $specifiedWorksheet !== '');
2437 }
2438
2439 if (isset($storeKey)) {
2440 $branchStore[$storeKey] = $result;
2441 }
2442 } else {
2443 return $this->raiseFormulaError("undefined name '$token'");
2444 }
2445 }
2446 }
2447 // when we're out of tokens, the stack should have a single element, the final result
2448 if ($stack->count() != 1) {
2449 return $this->raiseFormulaError('internal error');
2450 }
2451 /** @var array<string, array<int, mixed>|false|string> */
2452 $output = $stack->pop();
2453 $output = $output['value'];
2454
2455 return $output;
2456 }
2457
2458 /**
2459 * @param mixed $operand
2460 */
2461 private function validateBinaryOperand(&$operand, Stack &$stack): bool
2462 {
2463 if (is_array($operand)) {
2464 if ((count($operand, COUNT_RECURSIVE) - count($operand)) == 1) {
2465 do {
2466 $operand = array_pop($operand);
2467 } while (is_array($operand));
2468 }
2469 }
2470 // Numbers, matrices and booleans can pass straight through, as they're already valid
2471 if (is_string($operand)) {
2472 // We only need special validations for the operand if it is a string
2473 // Start by stripping off the quotation marks we use to identify true excel string values internally
2474 if ($operand > '' && $operand[0] == self::FORMULA_STRING_QUOTE) {
2475 $operand = StringHelper::convertToString(self::unwrapResult($operand));
2476 }
2477 // If the string is a numeric value, we treat it as a numeric, so no further testing
2478 if (!is_numeric($operand)) {
2479 // If not a numeric, test to see if the value is an Excel error, and so can't be used in normal binary operations
2480 if ($operand > '' && $operand[0] == '#') {
2481 $stack->push('Value', $operand);
2482 $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($operand));
2483
2484 return false;
2485 } elseif (Engine\FormattedNumber::convertToNumberIfFormatted($operand) === false) {
2486 // If not a numeric, a fraction or a percentage, then it's a text string, and so can't be used in mathematical binary operations
2487 $stack->push('Error', '#VALUE!');
2488 $this->debugLog->writeDebugLog('Evaluation Result is a %s', $this->showTypeDetails('#VALUE!'));
2489
2490 return false;
2491 }
2492 }
2493 }
2494
2495 // return a true if the value of the operand is one that we can use in normal binary mathematical operations
2496 return true;
2497 }
2498
2499 /** @return mixed[]
2500 * @param mixed $operand1
2501 * @param mixed $operand2 */
2502 private function executeArrayComparison($operand1, $operand2, string $operation, Stack &$stack, bool $recursingArrays): array
2503 {
2504 $result = [];
2505 if (!is_array($operand2) && is_array($operand1)) {
2506 // Operand 1 is an array, Operand 2 is a scalar
2507 foreach ($operand1 as $x => $operandData) {
2508 $this->debugLog->writeDebugLog('Evaluating Comparison %s %s %s', $this->showValue($operandData), $operation, $this->showValue($operand2));
2509 $this->executeBinaryComparisonOperation($operandData, $operand2, $operation, $stack);
2510 /** @var array<string, mixed> $r */
2511 $r = $stack->pop();
2512 $result[$x] = $r['value'];
2513 }
2514 } elseif (is_array($operand2) && !is_array($operand1)) {
2515 // Operand 1 is a scalar, Operand 2 is an array
2516 foreach ($operand2 as $x => $operandData) {
2517 $this->debugLog->writeDebugLog('Evaluating Comparison %s %s %s', $this->showValue($operand1), $operation, $this->showValue($operandData));
2518 $this->executeBinaryComparisonOperation($operand1, $operandData, $operation, $stack);
2519 /** @var array<string, mixed> $r */
2520 $r = $stack->pop();
2521 $result[$x] = $r['value'];
2522 }
2523 } elseif (is_array($operand2) /*&& is_array($operand1)*/) {
2524 // Operand 1 and Operand 2 are both arrays
2525 if (!$recursingArrays) {
2526 self::checkMatrixOperands($operand1, $operand2, 2);
2527 }
2528 foreach ($operand1 as $x => $operandData) {
2529 $this->debugLog->writeDebugLog('Evaluating Comparison %s %s %s', $this->showValue($operandData), $operation, $this->showValue($operand2[$x]));
2530 $this->executeBinaryComparisonOperation($operandData, $operand2[$x], $operation, $stack, true);
2531 /** @var array<string, mixed> $r */
2532 $r = $stack->pop();
2533 $result[$x] = $r['value'];
2534 }
2535 } else {
2536 throw new Exception('Neither operand is an array');
2537 }
2538 // Log the result details
2539 $this->debugLog->writeDebugLog('Comparison Evaluation Result is %s', $this->showTypeDetails($result));
2540 // And push the result onto the stack
2541 $stack->push('Array', $result);
2542
2543 return $result;
2544 }
2545
2546 /** @return array<mixed>|bool|string
2547 * @param mixed $operand1
2548 * @param mixed $operand2 */
2549 private function executeBinaryComparisonOperation($operand1, $operand2, string $operation, Stack &$stack, bool $recursingArrays = false)
2550 {
2551 // If we're dealing with matrix operations, we want a matrix result
2552 if ((is_array($operand1)) || (is_array($operand2))) {
2553 return $this->executeArrayComparison($operand1, $operand2, $operation, $stack, $recursingArrays);
2554 }
2555
2556 $result = BinaryComparison::compare($operand1, $operand2, $operation);
2557
2558 // Log the result details
2559 $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result));
2560 // And push the result onto the stack
2561 $stack->push('Value', $result);
2562
2563 return $result;
2564 }
2565
2566 /**
2567 * @param mixed $operand1
2568 * @param mixed $operand2
2569 * @return mixed
2570 */
2571 private function executeNumericBinaryOperation($operand1, $operand2, string $operation, Stack &$stack)
2572 {
2573 // Validate the two operands
2574 if (
2575 ($this->validateBinaryOperand($operand1, $stack) === false)
2576 || ($this->validateBinaryOperand($operand2, $stack) === false)
2577 ) {
2578 return false;
2579 }
2580
2581 if (
2582 (Functions::getCompatibilityMode() != Functions::COMPATIBILITY_OPENOFFICE)
2583 && ((is_string($operand1) && !is_numeric($operand1) && $operand1 !== '')
2584 || (is_string($operand2) && !is_numeric($operand2) && $operand2 !== ''))
2585 ) {
2586 $result = ExcelError::VALUE();
2587 } elseif (is_array($operand1) || is_array($operand2)) {
2588 // Ensure that both operands are arrays/matrices
2589 if (is_array($operand1)) {
2590 foreach ($operand1 as $key => $value) {
2591 $operand1[$key] = Functions::flattenArray($value);
2592 }
2593 }
2594 if (is_array($operand2)) {
2595 foreach ($operand2 as $key => $value) {
2596 $operand2[$key] = Functions::flattenArray($value);
2597 }
2598 }
2599 [$rows, $columns] = self::checkMatrixOperands($operand1, $operand2, 3);
2600
2601 for ($row = 0; $row < $rows; ++$row) {
2602 for ($column = 0; $column < $columns; ++$column) {
2603 /** @var mixed[][] $operand1 */
2604 if (($operand1[$row][$column] ?? null) === null) {
2605 $operand1[$row][$column] = 0;
2606 } elseif (!self::isNumericOrBool($operand1[$row][$column])) {
2607 $operand1[$row][$column] = self::makeError($operand1[$row][$column]);
2608
2609 continue;
2610 }
2611 /** @var mixed[][] $operand2 */
2612 if (($operand2[$row][$column] ?? null) === null) {
2613 $operand2[$row][$column] = 0;
2614 } elseif (!self::isNumericOrBool($operand2[$row][$column])) {
2615 $operand1[$row][$column] = self::makeError($operand2[$row][$column]);
2616
2617 continue;
2618 }
2619 /** @var float|int */
2620 $operand1Val = $operand1[$row][$column];
2621 /** @var float|int */
2622 $operand2Val = $operand2[$row][$column];
2623 switch ($operation) {
2624 case '+':
2625 $operand1[$row][$column] = $operand1Val + $operand2Val;
2626
2627 break;
2628 case '-':
2629 $operand1[$row][$column] = $operand1Val - $operand2Val;
2630
2631 break;
2632 case '*':
2633 $operand1[$row][$column] = $operand1Val * $operand2Val;
2634
2635 break;
2636 case '/':
2637 if ($operand2Val == 0) {
2638 $operand1[$row][$column] = ExcelError::DIV0();
2639 } else {
2640 $operand1[$row][$column] = $operand1Val / $operand2Val;
2641 }
2642
2643 break;
2644 case '^':
2645 $operand1[$row][$column] = $operand1Val ** $operand2Val;
2646
2647 break;
2648
2649 default:
2650 throw new Exception('Unsupported numeric binary operation');
2651 }
2652 }
2653 }
2654 $result = $operand1;
2655 } else {
2656 // If we're dealing with non-matrix operations, execute the necessary operation
2657 /** @var float|int $operand1 */
2658 /** @var float|int $operand2 */
2659 switch ($operation) {
2660 // Addition
2661 case '+':
2662 $result = $operand1 + $operand2;
2663
2664 break;
2665 // Subtraction
2666 case '-':
2667 $result = $operand1 - $operand2;
2668
2669 break;
2670 // Multiplication
2671 case '*':
2672 $result = $operand1 * $operand2;
2673
2674 break;
2675 // Division
2676 case '/':
2677 if ($operand2 == 0) {
2678 // Trap for Divide by Zero error
2679 $stack->push('Error', ExcelError::DIV0());
2680 $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails(ExcelError::DIV0()));
2681
2682 return false;
2683 }
2684 $result = $operand1 / $operand2;
2685
2686 break;
2687 // Power
2688 case '^':
2689 $result = $operand1 ** $operand2;
2690
2691 break;
2692
2693 default:
2694 throw new Exception('Unsupported numeric binary operation');
2695 }
2696 }
2697
2698 // Log the result details
2699 $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($result));
2700 // And push the result onto the stack
2701 $stack->push('Value', $result);
2702
2703 return $result;
2704 }
2705
2706 /**
2707 * Trigger an error, but nicely, if need be.
2708 *
2709 * @return false
2710 */
2711 protected function raiseFormulaError(string $errorMessage, int $code = 0, ?Throwable $exception = null): bool
2712 {
2713 $this->formulaError = $errorMessage;
2714 $this->cyclicReferenceStack->clear();
2715 $suppress = $this->suppressFormulaErrors;
2716 $suppressed = $suppress ? ' $suppressed' : '';
2717 $this->debugLog->writeDebugLog("Raise Error$suppressed $errorMessage");
2718 if (!$suppress) {
2719 throw new Exception($errorMessage, $code, $exception);
2720 }
2721
2722 return false;
2723 }
2724
2725 /**
2726 * Extract range values.
2727 *
2728 * @param string $range String based range representation
2729 * @param ?Worksheet $worksheet Worksheet
2730 * @param bool $resetLog Flag indicating whether calculation log should be reset or not
2731 *
2732 * @return mixed[] Array of values in range if range contains more than one element. Otherwise, a single value is returned.
2733 */
2734 public function extractCellRange(string &$range = 'A1', ?Worksheet $worksheet = null, bool $resetLog = true, bool $createCell = false): array
2735 {
2736 // Return value
2737 /** @var mixed[][] */
2738 $returnValue = [];
2739
2740 if ($worksheet !== null) {
2741 $worksheetName = $worksheet->getTitle();
2742
2743 if (str_contains($range, '!')) {
2744 [$worksheetName, $range] = Worksheet::extractSheetTitle($range, true, true);
2745 $worksheet = ($this->spreadsheet === null) ? null : $this->spreadsheet->getSheetByName($worksheetName);
2746 }
2747
2748 // Extract range
2749 $aReferences = Coordinate::extractAllCellReferencesInRange($range);
2750 $range = "'" . $worksheetName . "'" . '!' . $range;
2751 $currentCol = '';
2752 $currentRow = 0;
2753 if (!isset($aReferences[1])) {
2754 // Single cell in range
2755 sscanf($aReferences[0], '%[A-Z]%d', $currentCol, $currentRow);
2756 /** @var string $currentCol */
2757 /** @var int $currentRow */
2758 if ($createCell && $worksheet !== null && !$worksheet->cellExists($aReferences[0])) {
2759 $worksheet->setCellValue($aReferences[0], null);
2760 }
2761 if ($worksheet !== null && $worksheet->cellExists($aReferences[0])) {
2762 $temp = $worksheet->getCell($aReferences[0])->getCalculatedValue($resetLog);
2763 if ($this->getInstanceArrayReturnType() === self::RETURN_ARRAY_AS_ARRAY) {
2764 while (is_array($temp)) {
2765 $temp = array_shift($temp);
2766 }
2767 }
2768 $returnValue[$currentRow][$currentCol] = $temp;
2769 } else {
2770 $returnValue[$currentRow][$currentCol] = null;
2771 }
2772 } else {
2773 // Extract cell data for all cells in the range
2774 foreach ($aReferences as $reference) {
2775 // Extract range
2776 sscanf($reference, '%[A-Z]%d', $currentCol, $currentRow);
2777 /** @var string $currentCol */
2778 /** @var int $currentRow */
2779 if ($createCell && $worksheet !== null && !$worksheet->cellExists($reference)) {
2780 $worksheet->setCellValue($reference, null);
2781 }
2782 if ($worksheet !== null && $worksheet->cellExists($reference)) {
2783 $temp = $worksheet->getCell($reference)->getCalculatedValue($resetLog);
2784 if ($this->getInstanceArrayReturnType() === self::RETURN_ARRAY_AS_ARRAY) {
2785 while (is_array($temp)) {
2786 $temp = array_shift($temp);
2787 }
2788 }
2789 $returnValue[$currentRow][$currentCol] = $temp;
2790 } else {
2791 $returnValue[$currentRow][$currentCol] = null;
2792 }
2793 }
2794 }
2795 }
2796
2797 return $returnValue;
2798 }
2799
2800 /**
2801 * Extract range values.
2802 *
2803 * @param string $range String based range representation
2804 * @param null|Worksheet $worksheet Worksheet
2805 * @param bool $resetLog Flag indicating whether calculation log should be reset or not
2806 *
2807 * @return mixed[]|string Array of values in range if range contains more than one element. Otherwise, a single value is returned.
2808 */
2809 public function extractNamedRange(string &$range = 'A1', ?Worksheet $worksheet = null, bool $resetLog = true)
2810 {
2811 // Return value
2812 $returnValue = [];
2813
2814 if ($worksheet !== null) {
2815 if (str_contains($range, '!')) {
2816 [$worksheetName, $range] = Worksheet::extractSheetTitle($range, true, true);
2817 $worksheet = ($this->spreadsheet === null) ? null : $this->spreadsheet->getSheetByName($worksheetName);
2818 }
2819
2820 // Named range?
2821 $namedRange = ($worksheet === null) ? null : DefinedName::resolveName($range, $worksheet);
2822 if ($namedRange === null) {
2823 return ExcelError::REF();
2824 }
2825
2826 $worksheet = $namedRange->getWorksheet();
2827 $range = $namedRange->getValue();
2828 $splitRange = Coordinate::splitRange($range);
2829 // Convert row and column references
2830 if ($worksheet !== null && ctype_alpha($splitRange[0][0])) {
2831 $range = $splitRange[0][0] . '1:' . $splitRange[0][1] . $worksheet->getHighestRow();
2832 } elseif ($worksheet !== null && ctype_digit($splitRange[0][0])) {
2833 $range = 'A' . $splitRange[0][0] . ':' . $worksheet->getHighestColumn() . $splitRange[0][1];
2834 }
2835
2836 // Extract range
2837 $aReferences = Coordinate::extractAllCellReferencesInRange($range);
2838 if (!isset($aReferences[1])) {
2839 // Single cell (or single column or row) in range
2840 [$currentCol, $currentRow] = Coordinate::coordinateFromString($aReferences[0]);
2841 /** @var mixed[][] $returnValue */
2842 if ($worksheet !== null && $worksheet->cellExists($aReferences[0])) {
2843 $returnValue[$currentRow][$currentCol] = $worksheet->getCell($aReferences[0])->getCalculatedValue($resetLog);
2844 } else {
2845 $returnValue[$currentRow][$currentCol] = null;
2846 }
2847 } else {
2848 // Extract cell data for all cells in the range
2849 foreach ($aReferences as $reference) {
2850 // Extract range
2851 [$currentCol, $currentRow] = Coordinate::coordinateFromString($reference);
2852 if ($worksheet !== null && $worksheet->cellExists($reference)) {
2853 $returnValue[$currentRow][$currentCol] = $worksheet->getCell($reference)->getCalculatedValue($resetLog);
2854 } else {
2855 $returnValue[$currentRow][$currentCol] = null;
2856 }
2857 }
2858 }
2859 }
2860
2861 return $returnValue;
2862 }
2863
2864 /**
2865 * Is a specific function implemented?
2866 *
2867 * @param string $function Function Name
2868 */
2869 public function isImplemented(string $function): bool
2870 {
2871 $function = strtoupper($function);
2872 $phpSpreadsheetFunctions = &self::getFunctionsAddress();
2873 $notImplemented = !isset($phpSpreadsheetFunctions[$function]) || (is_array($phpSpreadsheetFunctions[$function]['functionCall']) && $phpSpreadsheetFunctions[$function]['functionCall'][1] === 'DUMMY');
2874
2875 return !$notImplemented;
2876 }
2877
2878 /**
2879 * Get a list of implemented Excel function names.
2880 *
2881 * @return string[]
2882 */
2883 public function getImplementedFunctionNames(): array
2884 {
2885 $returnValue = [];
2886 $phpSpreadsheetFunctions = &self::getFunctionsAddress();
2887 foreach ($phpSpreadsheetFunctions as $functionName => $function) {
2888 if ($this->isImplemented($functionName)) {
2889 $returnValue[] = $functionName;
2890 }
2891 }
2892
2893 return $returnValue;
2894 }
2895
2896 /**
2897 * @param string[] $functionCall
2898 * @param mixed[] $args
2899 * @param mixed[] $emptyArguments
2900 *
2901 * @return mixed[]
2902 */
2903 private function addDefaultArgumentValues(array $functionCall, array $args, array $emptyArguments): array
2904 {
2905 $reflector = new ReflectionMethod($functionCall[0], $functionCall[1]);
2906 if (PHP_VERSION_ID < 80100) {
2907 $reflector->setAccessible(true);
2908 }
2909 $methodArguments = $reflector->getParameters();
2910
2911 if (count($methodArguments) > 0) {
2912 // Apply any defaults for empty argument values
2913 foreach ($emptyArguments as $argumentId => $isArgumentEmpty) {
2914 if ($isArgumentEmpty === true) {
2915 $reflectedArgumentId = count($args) - (int) $argumentId - 1;
2916 if (
2917 !array_key_exists($reflectedArgumentId, $methodArguments)
2918 || $methodArguments[$reflectedArgumentId]->isVariadic()
2919 ) {
2920 break;
2921 }
2922
2923 $args[$argumentId] = $this->getArgumentDefaultValue($methodArguments[$reflectedArgumentId]);
2924 }
2925 }
2926 }
2927
2928 return $args;
2929 }
2930
2931 /**
2932 * @return mixed
2933 */
2934 private function getArgumentDefaultValue(ReflectionParameter $methodArgument)
2935 {
2936 $defaultValue = null;
2937
2938 if ($methodArgument->isDefaultValueAvailable()) {
2939 $defaultValue = $methodArgument->getDefaultValue();
2940 if ($methodArgument->isDefaultValueConstant()) {
2941 $constantName = $methodArgument->getDefaultValueConstantName() ?? '';
2942 // read constant value
2943 if (str_contains($constantName, '::')) {
2944 [$className, $constantName] = explode('::', $constantName);
2945 /** @var class-string $className */
2946 $constantReflector = new ReflectionClassConstant($className, $constantName);
2947
2948 return $constantReflector->getValue();
2949 }
2950
2951 return constant($constantName);
2952 }
2953 }
2954
2955 return $defaultValue;
2956 }
2957
2958 /**
2959 * Add cell reference if needed while making sure that it is the last argument.
2960 *
2961 * @param mixed[] $args
2962 * @param string|string[] $functionCall
2963 *
2964 * @return mixed[]
2965 */
2966 private function addCellReference(array $args, bool $passCellReference, $functionCall, ?Cell $cell = null): array
2967 {
2968 if ($passCellReference) {
2969 if (is_array($functionCall)) {
2970 $className = $functionCall[0];
2971 $methodName = $functionCall[1];
2972
2973 $reflectionMethod = new ReflectionMethod($className, $methodName);
2974 if (PHP_VERSION_ID < 80100) {
2975 $reflectionMethod->setAccessible(true);
2976 }
2977 $argumentCount = count($reflectionMethod->getParameters());
2978 while (count($args) < $argumentCount - 1) {
2979 $args[] = null;
2980 }
2981 }
2982
2983 $args[] = $cell;
2984 }
2985
2986 return $args;
2987 }
2988
2989 /**
2990 * @return mixed
2991 */
2992 private function evaluateDefinedName(Cell $cell, DefinedName $namedRange, Worksheet $cellWorksheet, Stack $stack, bool $ignoreScope = false)
2993 {
2994 $definedNameScope = $namedRange->getScope();
2995 if ($definedNameScope !== null && $definedNameScope !== $cellWorksheet && !$ignoreScope) {
2996 // The defined name isn't in our current scope, so #REF
2997 $result = ExcelError::REF();
2998 $stack->push('Error', $result, $namedRange->getName());
2999
3000 return $result;
3001 }
3002
3003 $definedNameValue = $namedRange->getValue();
3004 $definedNameType = $namedRange->isFormula() ? 'Formula' : 'Range';
3005 if ($definedNameType === 'Range') {
3006 if (Preg::isMatch('/^(.*!)?(.*)$/', $definedNameValue, $matches)) {
3007 $matches2 = Preg::replace(
3008 ['/ +/', '/,/'],
3009 [' ∩ ', ' ∪ '],
3010 trim($matches[2])
3011 );
3012 $definedNameValue = $matches[1] . $matches2;
3013 }
3014 }
3015 $definedNameWorksheet = $namedRange->getWorksheet();
3016
3017 if ($definedNameValue[0] !== '=') {
3018 $definedNameValue = '=' . $definedNameValue;
3019 }
3020
3021 $this->debugLog->writeDebugLog('Defined Name is a %s with a value of %s', $definedNameType, $definedNameValue);
3022
3023 $originalCoordinate = $cell->getCoordinate();
3024 $recursiveCalculationCell = ($definedNameType !== 'Formula' && $definedNameWorksheet !== null && $definedNameWorksheet !== $cellWorksheet)
3025 ? $definedNameWorksheet->getCell('A1')
3026 : $cell;
3027 $recursiveCalculationCellAddress = $recursiveCalculationCell->getCoordinate();
3028
3029 // Adjust relative references in ranges and formulae so that we execute the calculation for the correct rows and columns
3030 $definedNameValue = ReferenceHelper::getInstance()
3031 ->updateFormulaReferencesAnyWorksheet(
3032 $definedNameValue,
3033 Coordinate::columnIndexFromString(
3034 $cell->getColumn()
3035 ) - 1,
3036 $cell->getRow() - 1
3037 );
3038
3039 $this->debugLog->writeDebugLog('Value adjusted for relative references is %s', $definedNameValue);
3040
3041 $recursiveCalculator = new self($this->spreadsheet);
3042 $recursiveCalculator->getDebugLog()->setWriteDebugLog($this->getDebugLog()->getWriteDebugLog());
3043 $recursiveCalculator->getDebugLog()->setEchoDebugLog($this->getDebugLog()->getEchoDebugLog());
3044 $result = $recursiveCalculator->_calculateFormulaValue($definedNameValue, $recursiveCalculationCellAddress, $recursiveCalculationCell, true);
3045 $cellWorksheet->getCell($originalCoordinate);
3046
3047 if ($this->getDebugLog()->getWriteDebugLog()) {
3048 $this->debugLog->mergeDebugLog(array_slice($recursiveCalculator->getDebugLog()->getLog(), 3));
3049 $this->debugLog->writeDebugLog('Evaluation Result for Named %s %s is %s', $definedNameType, $namedRange->getName(), $this->showTypeDetails($result));
3050 }
3051
3052 $y = ($nullsafeVariable2 = $namedRange->getWorksheet()) ? $nullsafeVariable2->getTitle() : null;
3053 $x = $namedRange->getLocalOnly();
3054 if ($x && $y !== null) {
3055 $stack->push('Defined Name', $result, "'$y'!" . $namedRange->getName());
3056 } else {
3057 $stack->push('Defined Name', $result, $namedRange->getName());
3058 }
3059
3060 return $result;
3061 }
3062
3063 public function setSuppressFormulaErrors(bool $suppressFormulaErrors): self
3064 {
3065 $this->suppressFormulaErrors = $suppressFormulaErrors;
3066
3067 return $this;
3068 }
3069
3070 public function getSuppressFormulaErrors(): bool
3071 {
3072 return $this->suppressFormulaErrors;
3073 }
3074
3075 /**
3076 * @param mixed $operand1
3077 * @return mixed
3078 */
3079 public static function boolToString($operand1)
3080 {
3081 if (is_bool($operand1)) {
3082 $operand1 = ($operand1) ? self::$localeBoolean['TRUE'] : self::$localeBoolean['FALSE'];
3083 } elseif ($operand1 === null) {
3084 $operand1 = '';
3085 }
3086
3087 return $operand1;
3088 }
3089
3090 /**
3091 * @param mixed $operand
3092 */
3093 private static function isNumericOrBool($operand): bool
3094 {
3095 return is_numeric($operand) || is_bool($operand);
3096 }
3097
3098 /**
3099 * @param mixed $operand
3100 */
3101 private static function makeError($operand = ''): string
3102 {
3103 return (is_string($operand) && Information\ErrorValue::isError($operand)) ? $operand : ExcelError::VALUE();
3104 }
3105
3106 private static function swapOperands(Stack $stack, string $opCharacter): bool
3107 {
3108 $retVal = false;
3109 if ($stack->count() > 0) {
3110 $o2 = $stack->last();
3111 if ($o2) {
3112 /** @var array{value: string} $o2 */
3113 if (isset(self::CALCULATION_OPERATORS[$o2['value']])) {
3114 $retVal = (self::OPERATOR_PRECEDENCE[$opCharacter] ?? 0) <= self::OPERATOR_PRECEDENCE[$o2['value']];
3115 }
3116 }
3117 }
3118
3119 return $retVal;
3120 }
3121
3122 public function getSpreadsheet(): ?Spreadsheet
3123 {
3124 return $this->spreadsheet;
3125 }
3126 }
3127