22
33namespace PhpOffice \PhpSpreadsheet \Calculation ;
44
5+ use Composer \Pcre \Preg ; // many pregs in this program use u modifier, which has side-effects which make it unsuitable for this
56use PhpOffice \PhpSpreadsheet \Calculation \Engine \BranchPruner ;
67use PhpOffice \PhpSpreadsheet \Calculation \Engine \CyclicReferenceStack ;
78use PhpOffice \PhpSpreadsheet \Calculation \Engine \Logger ;
@@ -36,7 +37,7 @@ class Calculation extends CalculationLocale
3637 const CALCULATION_REGEXP_OPENBRACE = '\( ' ;
3738 // Function (allow for the old @ symbol that could be used to prefix a function, but we'll ignore it)
3839 const CALCULATION_REGEXP_FUNCTION = '@?(?:_xlfn\.)?(?:_xlws\.)?((?:__xludf\.)?[\p{L}][\p{L}\p{N}\.]*)[\s]*\( ' ;
39- // Cell reference (cell or range of cells , with or without a sheet reference)
40+ // Cell reference, with or without a sheet reference)
4041 const CALCULATION_REGEXP_CELLREF = '((([^\s,!&%^\/\*\+<>=:`-]*)|( \'(?:[^ \']| \'[^!])+? \')|(\"(?:[^\"]|\"[^!])+?\"))!)?\$?\b([a-z]{1,3})\$?(\d{1,7})(?![\w.]) ' ;
4142 // Used only to detect spill operator #
4243 const CALCULATION_REGEXP_CELLREF_SPILL = '/ ' . self ::CALCULATION_REGEXP_CELLREF . '#/i ' ;
@@ -415,7 +416,7 @@ public static function wrapResult(mixed $value): mixed
415416 {
416417 if (is_string ($ value )) {
417418 // Error values cannot be "wrapped"
418- if (preg_match ('/^ ' . self ::CALCULATION_REGEXP_ERROR . '$/i ' , $ value , $ match )) {
419+ if (Preg:: isMatch ('/^ ' . self ::CALCULATION_REGEXP_ERROR . '$/i ' , $ value , $ match )) {
419420 // Return Excel errors "as is"
420421 return $ value ;
421422 }
@@ -494,7 +495,7 @@ public function calculateCellValue(?Cell $cell = null, bool $resetLog = true): m
494495 try {
495496 $ value = $ cell ->getValue ();
496497 if (is_string ($ value ) && $ cell ->getDataType () === DataType::TYPE_FORMULA ) {
497- $ value = preg_replace_callback (
498+ $ value = Preg:: replaceCallback (
498499 self ::CALCULATION_REGEXP_CELLREF_SPILL ,
499500 fn (array $ matches ) => 'ANCHORARRAY( ' . substr ($ matches [0 ], 0 , -1 ) . ') ' ,
500501 $ value
@@ -557,11 +558,11 @@ public function calculateCellValue(?Cell $cell = null, bool $resetLog = true): m
557558 */
558559 public function parseFormula (string $ formula ): array |bool
559560 {
560- $ formula = preg_replace_callback (
561+ $ formula = Preg:: replaceCallback (
561562 self ::CALCULATION_REGEXP_CELLREF_SPILL ,
562563 fn (array $ matches ) => 'ANCHORARRAY( ' . substr ($ matches [0 ], 0 , -1 ) . ') ' ,
563564 $ formula
564- ) ?? $ formula ;
565+ );
565566 // Basic validation that this is indeed a formula
566567 // We return an empty array if not
567568 $ formula = trim ($ formula );
@@ -657,8 +658,9 @@ public function _calculateFormulaValue(string $formula, ?string $cellID = null,
657658 return self ::wrapResult ((string ) $ formula );
658659 }
659660
661+ // https://www.reddit.com/r/excel/comments/chr41y/cmd_formula_stopped_working_since_last_update/
660662 if (preg_match ('/^=\s*cmd\s*\|/miu ' , $ formula ) !== 0 ) {
661- return self :: wrapResult ( $ formula );
663+ return ExcelError:: REF (); // returns #BLOCKED in newer versions
662664 }
663665
664666 // Basic validation that this is indeed a formula
@@ -1052,6 +1054,28 @@ private function convertMatrixReferences(string $formula): false|string
10521054 '> ' => 0 , '< ' => 0 , '= ' => 0 , '>= ' => 0 , '<= ' => 0 , '<> ' => 0 , // Comparison
10531055 ];
10541056
1057+ /** @param string[] $matches */
1058+ private static function unionForComma (array $ matches ): string
1059+ {
1060+ return $ matches [1 ] . str_replace (', ' , '∪ ' , $ matches [2 ]);
1061+ }
1062+
1063+ private const CELL_OR_CELLRANGE_OR_DEFINED_NAME
1064+ = '(?: '
1065+ . self ::CALCULATION_REGEXP_CELLREF // cell address
1066+ . '(?:: ' . self ::CALCULATION_REGEXP_CELLREF . ')? ' // optional range address, non-capturing
1067+ . '| ' . self ::CALCULATION_REGEXP_DEFINEDNAME
1068+ . ') '
1069+ ;
1070+
1071+ public const UNIONABLE_COMMAS = '/((?:[,(]|^)\s*) ' // comma or open paren or start of string, followed by optional whitespace
1072+ . '([(] ' // open paren
1073+ . self ::CELL_OR_CELLRANGE_OR_DEFINED_NAME // cell address
1074+ . '(?:\s*,\s* ' // optioonal whitespace, comma, optional whitespace, non-capturing
1075+ . self ::CELL_OR_CELLRANGE_OR_DEFINED_NAME // cell address
1076+ . ')+ ' // one or more occurrences
1077+ . '\s*[)])/i ' ; // optional whitespace, end paren
1078+
10551079 /**
10561080 * @return array<int, mixed>|false
10571081 */
@@ -1060,6 +1084,12 @@ private function internalParseFormula(string $formula, ?Cell $cell = null): bool
10601084 if (($ formula = $ this ->convertMatrixReferences (trim ($ formula ))) === false ) {
10611085 return false ;
10621086 }
1087+
1088+ $ oldFormula = $ formula ;
1089+ $ formula = Preg::replaceCallback (self ::UNIONABLE_COMMAS , self ::unionForComma (...), $ formula ); // @phpstan-ignore-line
1090+ if ($ oldFormula !== $ formula ) {
1091+ $ this ->debugLog ->writeDebugLog ('Reformulated as %s ' , $ formula );
1092+ }
10631093 $ phpSpreadsheetFunctions = &self ::getFunctionsAddress ();
10641094
10651095 // If we're using cell caching, then $pCell may well be flushed back to the cache (which detaches the parent worksheet),
@@ -1179,7 +1209,7 @@ private function internalParseFormula(string $formula, ?Cell $cell = null): bool
11791209 }
11801210 }
11811211 } elseif (is_string ($ expectedArgumentCount ) && $ expectedArgumentCount !== '* ' ) {
1182- if (1 !== preg_match ('/(\d*)([-+,])(\d*)/ ' , $ expectedArgumentCount , $ argMatch )) {
1212+ if (!Preg:: isMatch ('/(\d*)([-+,])(\d*)/ ' , $ expectedArgumentCount , $ argMatch )) {
11831213 $ argMatch = ['' , '' , '' , '' ];
11841214 }
11851215 switch ($ argMatch [2 ]) {
@@ -1236,16 +1266,14 @@ private function internalParseFormula(string $formula, ?Cell $cell = null): bool
12361266 // because at least the braces are paired up (at this stage in the formula)
12371267 // MS Excel allows this if the content is cell references; but doesn't allow actual values,
12381268 // but at this point, we can't differentiate (so allow both)
1239- return $ this ->raiseFormulaError ('Formula Error: Unexpected , ' );
1240- /* The following code may be a better choice, but, with
1241- the other changes for this PR, I can no longer come up
1242- with a test case that gets here
1269+ //return $this->raiseFormulaError('Formula Error: Unexpected ,');
1270+
12431271 $ stack ->push ('Binary Operator ' , '∪ ' );
12441272
12451273 ++$ index ;
12461274 $ expectingOperator = false ;
12471275
1248- continue;*/
1276+ continue ;
12491277 }
12501278
12511279 /** @var array<string, int> $d */
@@ -1271,7 +1299,8 @@ private function internalParseFormula(string $formula, ?Cell $cell = null): bool
12711299 $ length = strlen ($ val );
12721300
12731301 if (preg_match ('/^ ' . self ::CALCULATION_REGEXP_FUNCTION . '$/miu ' , $ val , $ matches )) {
1274- $ val = (string ) preg_replace ('/\s/u ' , '' , $ val );
1302+ // $val is known to be valid unicode from statement above, so Preg::replace is okay even with u modifier
1303+ $ val = Preg::replace ('/\s/u ' , '' , $ val );
12751304 if (isset ($ phpSpreadsheetFunctions [strtoupper ($ matches [1 ])]) || isset (self ::$ controlFunctions [strtoupper ($ matches [1 ])])) { // it's a function
12761305 $ valToUpper = strtoupper ($ val );
12771306 } else {
@@ -1988,20 +2017,27 @@ private function processTokenStack(false|array $tokens, ?string $cellID = null,
19882017 } else {
19892018 $ this ->executeNumericBinaryOperation ($ multiplier , $ arg , '* ' , $ stack );
19902019 }
1991- } elseif (preg_match ('/^ ' . self ::CALCULATION_REGEXP_CELLREF . '$/i ' , StringHelper::convertToString ($ token ?? '' ), $ matches )) {
2020+ } elseif (Preg:: isMatch ('/^ ' . self ::CALCULATION_REGEXP_CELLREF . '$/i ' , StringHelper::convertToString ($ token ?? '' ), $ matches )) {
19922021 $ cellRef = null ;
19932022
19942023 /* Phpstan says matches[8/9/10] is never set,
19952024 and code coverage report seems to confirm.
1996- Appease PhpStan for now;
1997- probably delete this block later.
2025+ regex101.com confirms - only 7 capturing groups.
2026+ My theory is that this code expected regexp to
2027+ match cell *or* cellRange, but it does not
2028+ match the latter. Retain the code for now in case
2029+ we do want to add the range match later.
2030+ Probably delete this block later.
2031+ Until delete happens, turn code coverage off.
19982032 */
19992033 if (isset ($ matches [self ::$ matchIndex8 ])) {
2034+ // @codeCoverageIgnoreStart
20002035 if ($ cell === null ) {
20012036 // We can't access the range, so return a REF error
20022037 $ cellValue = ExcelError::REF ();
20032038 } else {
20042039 $ cellRef = $ matches [6 ] . $ matches [7 ] . ': ' . $ matches [self ::$ matchIndex9 ] . $ matches [self ::$ matchIndex10 ];
2040+ $ matches [2 ] = (string ) $ matches [2 ];
20052041 if ($ matches [2 ] > '' ) {
20062042 $ matches [2 ] = trim ($ matches [2 ], "\"' " );
20072043 if ((str_contains ($ matches [2 ], '[ ' )) || (str_contains ($ matches [2 ], '] ' ))) {
@@ -2026,12 +2062,14 @@ private function processTokenStack(false|array $tokens, ?string $cellID = null,
20262062 $ this ->debugLog ->writeDebugLog ('Evaluation Result for cells %s is %s ' , $ cellRef , $ this ->showTypeDetails ($ cellValue ));
20272063 }
20282064 }
2065+ // @codeCoverageIgnoreEnd
20292066 } else {
20302067 if ($ cell === null ) {
20312068 // We can't access the cell, so return a REF error
20322069 $ cellValue = ExcelError::REF ();
20332070 } else {
20342071 $ cellRef = $ matches [6 ] . $ matches [7 ];
2072+ $ matches [2 ] = (string ) $ matches [2 ];
20352073 if ($ matches [2 ] > '' ) {
20362074 $ matches [2 ] = trim ($ matches [2 ], "\"' " );
20372075 if ((str_contains ($ matches [2 ], '[ ' )) || (str_contains ($ matches [2 ], '] ' ))) {
@@ -2073,7 +2111,7 @@ private function processTokenStack(false|array $tokens, ?string $cellID = null,
20732111 $ cellValue = array_shift ($ cellValue );
20742112 }
20752113 if (is_string ($ cellValue )) {
2076- $ cellValue = preg_replace ('/"/ ' , '"" ' , $ cellValue );
2114+ $ cellValue = Preg:: replace ('/"/ ' , '"" ' , $ cellValue );
20772115 }
20782116 $ this ->debugLog ->writeDebugLog ('Scalar Result for cell %s is %s ' , $ cellRef , $ this ->showTypeDetails ($ cellValue ));
20792117 }
@@ -2819,10 +2857,12 @@ private function evaluateDefinedName(Cell $cell, DefinedName $namedRange, Worksh
28192857 $ definedNameValue = $ namedRange ->getValue ();
28202858 $ definedNameType = $ namedRange ->isFormula () ? 'Formula ' : 'Range ' ;
28212859 if ($ definedNameType === 'Range ' ) {
2822- if (preg_match ('/^(.*!)?(.*)$/ ' , $ definedNameValue , $ matches ) === 1 ) {
2823- $ matches2 = trim ($ matches [2 ]);
2824- $ matches2 = preg_replace ('/ +/ ' , ' ∩ ' , $ matches2 ) ?? $ matches2 ;
2825- $ matches2 = preg_replace ('/,/ ' , ' ∪ ' , $ matches2 ) ?? $ matches2 ;
2860+ if (Preg::isMatch ('/^(.*!)?(.*)$/ ' , $ definedNameValue , $ matches )) {
2861+ $ matches2 = Preg::replace (
2862+ ['/ +/ ' , '/,/ ' ],
2863+ [' ∩ ' , ' ∪ ' ],
2864+ trim ($ matches [2 ])
2865+ );
28262866 $ definedNameValue = $ matches [1 ] . $ matches2 ;
28272867 }
28282868 }
0 commit comments