From 148bee19910c2cd655063d03baae36b2855412f2 Mon Sep 17 00:00:00 2001 From: Josh Grant Date: Sat, 24 Feb 2018 12:14:48 +0000 Subject: [PATCH] Support `_xlfn.` prefix and add `ISFORMULA`, `MODE.SNGL`, `STDEV.S`, `STDEV.P` This change adds support for newer functions that are prefixed by _xlfn. (#356). The calculation engine has been updated to recognise these as functions, and drop the _xlfn. part. It also add a couple of the new functions such as STDEV.S/P, MODE.SNGL, ISFORMULA. Fixes #356 Closes #390 --- CHANGELOG.md | 1 + .../Calculation/Calculation.php | 28 +++++++-- src/PhpSpreadsheet/Calculation/Functions.php | 19 ++++++ .../Calculation/CalculationTest.php | 15 +++++ .../Calculation/FunctionsTest.php | 40 +++++++++++++ .../data/Calculation/Functions/ISFORMULA.php | 59 +++++++++++++++++++ 6 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 tests/data/Calculation/Functions/ISFORMULA.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 82eff442..1a60764c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - HTML writer creates a generator meta tag - [#312](https://github.com/PHPOffice/PhpSpreadsheet/issues/312) - Support invalid zoom value in XLSX format - [#350](https://github.com/PHPOffice/PhpSpreadsheet/pull/350) +- Support for `_xlfn.` prefixed functions and `ISFORMULA`, `MODE.SNGL`, `STDEV.S`, `STDEV.P` - [#390](https://github.com/PHPOffice/PhpSpreadsheet/pull/390) ### Fixed diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index 80d5b288..2d3a28ca 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -23,7 +23,7 @@ class Calculation // Opening bracket const CALCULATION_REGEXP_OPENBRACE = '\('; // Function (allow for the old @ symbol that could be used to prefix a function, but we'll ignore it) - const CALCULATION_REGEXP_FUNCTION = '@?([A-Z][A-Z0-9\.]*)[\s]*\('; + const CALCULATION_REGEXP_FUNCTION = '@?(?:_xlfn\.)?([A-Z][A-Z0-9\.]*)[\s]*\('; // Cell reference (cell or range of cells, with or without a sheet reference) const CALCULATION_REGEXP_CELLREF = '((([^\s,!&%^\/\*\+<>=-]*)|(\'[^\']*\')|(\"[^\"]*\"))!)?\$?([a-z]{1,3})\$?(\d{1,7})'; // Named Range of cells @@ -1082,6 +1082,13 @@ class Calculation 'functionCall' => [Functions::class, 'isEven'], 'argumentCount' => '1', ], + 'ISFORMULA' => [ + 'category' => Category::CATEGORY_INFORMATION, + 'functionCall' => [Functions::class, 'isFormula'], + 'argumentCount' => '1', + 'passCellReference' => true, + 'passByReference' => [true], + ], 'ISLOGICAL' => [ 'category' => Category::CATEGORY_INFORMATION, 'functionCall' => [Functions::class, 'isLogical'], @@ -1302,6 +1309,11 @@ class Calculation 'functionCall' => [Statistical::class, 'MODE'], 'argumentCount' => '1+', ], + 'MODE.SNGL' => [ + 'category' => Category::CATEGORY_STATISTICAL, + 'functionCall' => [Statistical::class, 'MODE'], + 'argumentCount' => '1+', + ], 'MONTH' => [ 'category' => Category::CATEGORY_DATE_AND_TIME, 'functionCall' => [DateTime::class, 'MONTHOFYEAR'], @@ -1700,6 +1712,16 @@ class Calculation 'functionCall' => [Statistical::class, 'STDEV'], 'argumentCount' => '1+', ], + 'STDEV.S' => [ + 'category' => Category::CATEGORY_STATISTICAL, + 'functionCall' => [Statistical::class, 'STDEV'], + 'argumentCount' => '1+', + ], + 'STDEV.P' => [ + 'category' => Category::CATEGORY_STATISTICAL, + 'functionCall' => [Statistical::class, 'STDEVP'], + 'argumentCount' => '1+', + ], 'STDEVA' => [ 'category' => Category::CATEGORY_STATISTICAL, 'functionCall' => [Statistical::class, 'STDEVA'], @@ -3772,10 +3794,6 @@ class Calculation $namedRange = $matches[6]; $this->debugLog->writeDebugLog('Evaluating Named Range ', $namedRange); - if (substr($namedRange, 0, 6) === '_xlfn.') { - return $this->raiseFormulaError("undefined named range / function '$token'"); - } - $cellValue = $this->extractNamedRange($namedRange, ((null !== $pCell) ? $pCellWorksheet : null), false); $pCell->attach($pCellParent); $this->debugLog->writeDebugLog('Evaluation Result for named range ', $namedRange, ' is ', $this->showTypeDetails($cellValue)); diff --git a/src/PhpSpreadsheet/Calculation/Functions.php b/src/PhpSpreadsheet/Calculation/Functions.php index 98ca7fa8..b6389a8d 100644 --- a/src/PhpSpreadsheet/Calculation/Functions.php +++ b/src/PhpSpreadsheet/Calculation/Functions.php @@ -2,6 +2,8 @@ namespace PhpOffice\PhpSpreadsheet\Calculation; +use PhpOffice\PhpSpreadsheet\Cell\Cell; + class Functions { const PRECISION = 8.88E-016; @@ -642,4 +644,21 @@ class Functions return $value; } + + /** + * ISFORMULA. + * + * @param mixed $value The cell to check + * @param Cell $pCell The current cell (containing this formula) + * + * @return bool|string + */ + public static function isFormula($value = '', Cell $pCell = null) + { + if ($pCell === null) { + return self::REF(); + } + + return substr($pCell->getWorksheet()->getCell($value)->getValue(), 0, 1) === '='; + } } diff --git a/tests/PhpSpreadsheetTests/Calculation/CalculationTest.php b/tests/PhpSpreadsheetTests/Calculation/CalculationTest.php index b6d49742..871b6ef5 100644 --- a/tests/PhpSpreadsheetTests/Calculation/CalculationTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/CalculationTest.php @@ -102,4 +102,19 @@ class CalculationTest extends TestCase ['tr'], ]; } + + public function testDoesHandleXlfnFunctions() + { + $calculation = Calculation::getInstance(); + + $tree = $calculation->parseFormula('=_xlfn.ISFORMULA(A1)'); + self::assertCount(3, $tree); + $function = $tree[2]; + self::assertEquals('Function', $function['type']); + + $tree = $calculation->parseFormula('=_xlfn.STDEV.S(A1:B2)'); + self::assertCount(5, $tree); + $function = $tree[4]; + self::assertEquals('Function', $function['type']); + } } diff --git a/tests/PhpSpreadsheetTests/Calculation/FunctionsTest.php b/tests/PhpSpreadsheetTests/Calculation/FunctionsTest.php index 01805b3a..ed85e80c 100644 --- a/tests/PhpSpreadsheetTests/Calculation/FunctionsTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/FunctionsTest.php @@ -3,6 +3,8 @@ namespace PhpOffice\PhpSpreadsheetTests\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\Functions; +use PhpOffice\PhpSpreadsheet\Cell\Cell; +use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PHPUnit\Framework\TestCase; class FunctionsTest extends TestCase @@ -267,4 +269,42 @@ class FunctionsTest extends TestCase { return require 'data/Calculation/Functions/N.php'; } + + /** + * @dataProvider providerIsFormula + * + * @param mixed $expectedResult + * @param mixed $value + */ + public function testIsFormula($expectedResult, $value = 'undefined') + { + $ourCell = null; + if ($value !== 'undefined') { + $remoteCell = $this->getMockBuilder(Cell::class) + ->disableOriginalConstructor() + ->getMock(); + $remoteCell->method('getValue') + ->will($this->returnValue($value)); + + $sheet = $this->getMockBuilder(Worksheet::class) + ->disableOriginalConstructor() + ->getMock(); + $sheet->method('getCell') + ->will($this->returnValue($remoteCell)); + + $ourCell = $this->getMockBuilder(Cell::class) + ->disableOriginalConstructor() + ->getMock(); + $ourCell->method('getWorksheet') + ->will($this->returnValue($sheet)); + } + + $result = Functions::isFormula($value, $ourCell); + self::assertEquals($expectedResult, $result, null, 1E-8); + } + + public function providerIsFormula() + { + return require 'data/Calculation/Functions/ISFORMULA.php'; + } } diff --git a/tests/data/Calculation/Functions/ISFORMULA.php b/tests/data/Calculation/Functions/ISFORMULA.php new file mode 100644 index 00000000..d9e2a44d --- /dev/null +++ b/tests/data/Calculation/Functions/ISFORMULA.php @@ -0,0 +1,59 @@ +