Save Excel 2010+ Functions Properly

For functions introduced in Excel 2010 and beyond, Excel saves them
in formulas with the xlfn_ prefix. PhpSpreadsheet does not do this;
as a result, when a spreadsheet so created is opened, the cells
which use the new functions display a #NAME? error.
This the cause of bug report 1246:
https://github.com/PHPOffice/PhpSpreadsheet/issues/1246
This change corrects that problem when the Xlsx writer encounters
a 2010+ formula for a cell or a conditional style. A new class
Writer/Xlsx/Xlfn, with 2 static methods,
is introduced to facilitate this change.

As part of the testing for this, I found some additional problems.
When an unknown function name is used, Excel generates a #NAME? error.
However, when an unknown function is used in PhpSpreadsheet:
  - if there are no parameters, it returns #VALUE!, which is wrong
  - if there are parameters, it throws an exception, which is horrible
Both of these situations will now return #NAME?
Tests have been added for these situations.

The MODE (and MODE.SNGL) function is not quite in alignment with Excel.
MODE(3, 3, 4, 4) returns 3 in both Excel and PhpSpreadsheet.
However, MODE(4, 3, 3, 4) returns 4 in Excel, but 3 in PhpSpreadsheet.
Both situations will now match Excel's result.
Also, Excel allows its parameters for MODE to be an array,
but PhpSpreadsheet did not; it now will.
There had not been any tests for MODE. Now there are.

The SHEET and SHEETS functions were introduced in Excel 2013,
but were not introduced in PhpSpreadsheet. They are now introduced
as DUMMY functions so that they can be parsed appropriately.

Finally, in common with the "rate" changes for which I am
creating a pull request at the same time as this one:
samples/Basic/13_CalculationCyclicFormulae
PhpUnit started reporting an error like "too much regression".
The test deals with an infinite cyclic formula, and allowed
the calculation engine to run for 100 cycles. The actual number of cycles
seems irrelevant for the purpose of this test. I changed it to 15,
and PhpUnit no longer complains.
This commit is contained in:
Owen Leibman 2020-05-03 07:06:12 -07:00 committed by Adrien Crivelli
parent 414e5695ef
commit 4f6d4af396
No known key found for this signature in database
GPG Key ID: B182FD79DC6DE92E
11 changed files with 667 additions and 157 deletions

View File

@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
- Fix Chart samples by updating chart parameter from 0 to DataSeries::EMPTY_AS_GAP [#1448](https://github.com/PHPOffice/PhpSpreadsheet/pull/1448)
- Fix return type in docblock for the Cells::get() [#1398](https://github.com/PHPOffice/PhpSpreadsheet/pull/1398)
- Fix RATE, PRICE, XIRR, and XNPV Functions [#1456](https://github.com/PHPOffice/PhpSpreadsheet/pull/1456)
- Save Excel 2010+ functions properly in XLSX [#1461](https://github.com/PHPOffice/PhpSpreadsheet/pull/1461)
### Changed

View File

@ -1853,6 +1853,16 @@ class Calculation
'functionCall' => [MathTrig::class, 'SERIESSUM'],
'argumentCount' => '4',
],
'SHEET' => [
'category' => Category::CATEGORY_INFORMATION,
'functionCall' => [Functions::class, 'DUMMY'],
'argumentCount' => '0,1',
],
'SHEETS' => [
'category' => Category::CATEGORY_INFORMATION,
'functionCall' => [Functions::class, 'DUMMY'],
'argumentCount' => '0,1',
],
'SIGN' => [
'category' => Category::CATEGORY_MATH_AND_TRIG,
'functionCall' => [MathTrig::class, 'SIGN'],
@ -2247,6 +2257,10 @@ class Calculation
'argumentCount' => '*',
'functionCall' => [__CLASS__, 'mkMatrix'],
],
'NAME.ERROR' => [
'argumentCount' => '*',
'functionCall' => [Functions::class, 'NAME'],
],
];
public function __construct(Spreadsheet $spreadsheet = null)
@ -3615,6 +3629,9 @@ class Calculation
$val = preg_replace('/\s/u', '', $val);
if (isset(self::$phpSpreadsheetFunctions[strtoupper($matches[1])]) || isset(self::$controlFunctions[strtoupper($matches[1])])) { // it's a function
$valToUpper = strtoupper($val);
} else {
$valToUpper = 'NAME.ERROR(';
}
// here $matches[1] will contain values like "IF"
// and $val "IF("
if ($this->branchPruningEnabled && ($valToUpper == 'IF(')) { // we handle a new if
@ -3622,7 +3639,7 @@ class Calculation
$pendingStoreKeysStack[] = $pendingStoreKey;
$expectingConditionMap[$pendingStoreKey] = true;
$parenthesisDepthMap[$pendingStoreKey] = 0;
} else { // this is not a if but we good deeper
} else { // this is not an if but we go deeper
if (!empty($pendingStoreKey) && array_key_exists($pendingStoreKey, $parenthesisDepthMap)) {
$parenthesisDepthMap[$pendingStoreKey] += 1;
}
@ -3630,7 +3647,7 @@ class Calculation
$stack->push('Function', $valToUpper, null, $currentCondition, $currentOnlyIf, $currentOnlyIfNot);
// tests if the function is closed right after opening
$ax = preg_match('/^\s*(\s*\))/ui', substr($formula, $index + $length), $amatch);
$ax = preg_match('/^\s*\)/u', substr($formula, $index + $length));
if ($ax) {
$stack->push('Operand Count for Function ' . $valToUpper . ')', 0, null, $currentCondition, $currentOnlyIf, $currentOnlyIfNot);
$expectingOperator = true;
@ -3639,9 +3656,6 @@ class Calculation
$expectingOperator = false;
}
$stack->push('Brace', '(');
} else { // it's a var w/ implicit multiplication
$output[] = ['type' => 'Value', 'value' => $matches[1], 'reference' => null];
}
} elseif (preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/i', $val, $matches)) {
// Watch for this case-change when modifying to allow cell references in different worksheets...
// Should only be applied to the actual cell column, not the worksheet name

View File

@ -2468,11 +2468,27 @@ class Statistical
private static function modeCalc($data)
{
$frequencyArray = [];
$index = 0;
$maxfreq = 0;
$maxfreqkey = '';
$maxfreqdatum = '';
foreach ($data as $datum) {
$found = false;
++$index;
foreach ($frequencyArray as $key => $value) {
if ((string) $value['value'] == (string) $datum) {
++$frequencyArray[$key]['frequency'];
$freq = $frequencyArray[$key]['frequency'];
if ($freq > $maxfreq) {
$maxfreq = $freq;
$maxfreqkey = $key;
$maxfreqdatum = $datum;
} elseif ($freq == $maxfreq) {
if ($frequencyArray[$key]['index'] < $frequencyArray[$maxfreqkey]['index']) {
$maxfreqkey = $key;
$maxfreqdatum = $datum;
}
}
$found = true;
break;
@ -2482,21 +2498,16 @@ class Statistical
$frequencyArray[] = [
'value' => $datum,
'frequency' => 1,
'index' => $index,
];
}
}
foreach ($frequencyArray as $key => $value) {
$frequencyList[$key] = $value['frequency'];
$valueList[$key] = $value['value'];
}
array_multisort($frequencyList, SORT_DESC, $valueList, SORT_ASC, SORT_NUMERIC, $frequencyArray);
if ($frequencyArray[0]['frequency'] == 1) {
if ($maxfreq <= 1) {
return Functions::NA();
}
return $frequencyArray[0]['value'];
return $maxfreqdatum;
}
/**

View File

@ -316,6 +316,8 @@ SEC
SECH
SECOND
SERIESSUM
SHEET
SHEETS
SIGN
SIN
SINH

View File

@ -449,6 +449,55 @@ class Worksheet extends WriterPart
$objWriter->endElement();
}
private static function writeAttributeIf(XMLWriter $objWriter, $condition, string $attr, string $val): void
{
if ($condition) {
$objWriter->writeAttribute($attr, $val);
}
}
private static function writeElementIf(XMLWriter $objWriter, $condition, string $attr, string $val): void
{
if ($condition) {
$objWriter->writeElement($attr, $val);
}
}
private static function writeOtherCondElements(XMLWriter $objWriter, Conditional $conditional, string $cellCoordinate): void
{
if ($conditional->getConditionType() == Conditional::CONDITION_CELLIS
|| $conditional->getConditionType() == Conditional::CONDITION_CONTAINSTEXT
|| $conditional->getConditionType() == Conditional::CONDITION_EXPRESSION) {
foreach ($conditional->getConditions() as $formula) {
// Formula
$objWriter->writeElement('formula', Xlfn::addXlfn($formula));
}
} elseif ($conditional->getConditionType() == Conditional::CONDITION_CONTAINSBLANKS) {
// formula copied from ms xlsx xml source file
$objWriter->writeElement('formula', 'LEN(TRIM(' . $cellCoordinate . '))=0');
} elseif ($conditional->getConditionType() == Conditional::CONDITION_NOTCONTAINSBLANKS) {
// formula copied from ms xlsx xml source file
$objWriter->writeElement('formula', 'LEN(TRIM(' . $cellCoordinate . '))>0');
}
}
private static function writeTextCondElements(XMLWriter $objWriter, Conditional $conditional, string $cellCoordinate): void
{
$txt = $conditional->getText();
if ($txt !== null) {
$objWriter->writeAttribute('text', $txt);
if ($conditional->getOperatorType() == Conditional::OPERATOR_CONTAINSTEXT) {
$objWriter->writeElement('formula', 'NOT(ISERROR(SEARCH("' . $txt . '",' . $cellCoordinate . ')))');
} elseif ($conditional->getOperatorType() == Conditional::OPERATOR_BEGINSWITH) {
$objWriter->writeElement('formula', 'LEFT(' . $cellCoordinate . ',' . strlen($txt) . ')="' . $txt . '"');
} elseif ($conditional->getOperatorType() == Conditional::OPERATOR_ENDSWITH) {
$objWriter->writeElement('formula', 'RIGHT(' . $cellCoordinate . ',' . strlen($txt) . ')="' . $txt . '"');
} elseif ($conditional->getOperatorType() == Conditional::OPERATOR_NOTCONTAINS) {
$objWriter->writeElement('formula', 'ISERROR(SEARCH("' . $txt . '",' . $cellCoordinate . '))');
}
}
}
/**
* Write ConditionalFormatting.
*
@ -478,49 +527,20 @@ class Worksheet extends WriterPart
$objWriter->writeAttribute('dxfId', $this->getParentWriter()->getStylesConditionalHashTable()->getIndexForHashCode($conditional->getHashCode()));
$objWriter->writeAttribute('priority', $id++);
if (($conditional->getConditionType() == Conditional::CONDITION_CELLIS || $conditional->getConditionType() == Conditional::CONDITION_CONTAINSTEXT)
&& $conditional->getOperatorType() != Conditional::OPERATOR_NONE) {
$objWriter->writeAttribute('operator', $conditional->getOperatorType());
}
self::writeAttributeif(
$objWriter,
($conditional->getConditionType() == Conditional::CONDITION_CELLIS || $conditional->getConditionType() == Conditional::CONDITION_CONTAINSTEXT)
&& $conditional->getOperatorType() != Conditional::OPERATOR_NONE,
'operator',
$conditional->getOperatorType()
);
if ($conditional->getConditionType() == Conditional::CONDITION_CONTAINSTEXT
&& $conditional->getText() !== null) {
$objWriter->writeAttribute('text', $conditional->getText());
}
self::writeAttributeIf($objWriter, $conditional->getStopIfTrue(), 'stopIfTrue', '1');
if ($conditional->getStopIfTrue()) {
$objWriter->writeAttribute('stopIfTrue', '1');
}
if ($conditional->getConditionType() == Conditional::CONDITION_CONTAINSTEXT
&& $conditional->getOperatorType() == Conditional::OPERATOR_CONTAINSTEXT
&& $conditional->getText() !== null) {
$objWriter->writeElement('formula', 'NOT(ISERROR(SEARCH("' . $conditional->getText() . '",' . $cellCoordinate . ')))');
} elseif ($conditional->getConditionType() == Conditional::CONDITION_CONTAINSTEXT
&& $conditional->getOperatorType() == Conditional::OPERATOR_BEGINSWITH
&& $conditional->getText() !== null) {
$objWriter->writeElement('formula', 'LEFT(' . $cellCoordinate . ',' . strlen($conditional->getText()) . ')="' . $conditional->getText() . '"');
} elseif ($conditional->getConditionType() == Conditional::CONDITION_CONTAINSTEXT
&& $conditional->getOperatorType() == Conditional::OPERATOR_ENDSWITH
&& $conditional->getText() !== null) {
$objWriter->writeElement('formula', 'RIGHT(' . $cellCoordinate . ',' . strlen($conditional->getText()) . ')="' . $conditional->getText() . '"');
} elseif ($conditional->getConditionType() == Conditional::CONDITION_CONTAINSTEXT
&& $conditional->getOperatorType() == Conditional::OPERATOR_NOTCONTAINS
&& $conditional->getText() !== null) {
$objWriter->writeElement('formula', 'ISERROR(SEARCH("' . $conditional->getText() . '",' . $cellCoordinate . '))');
} elseif ($conditional->getConditionType() == Conditional::CONDITION_CELLIS
|| $conditional->getConditionType() == Conditional::CONDITION_CONTAINSTEXT
|| $conditional->getConditionType() == Conditional::CONDITION_EXPRESSION) {
foreach ($conditional->getConditions() as $formula) {
// Formula
$objWriter->writeElement('formula', $formula);
}
} elseif ($conditional->getConditionType() == Conditional::CONDITION_CONTAINSBLANKS) {
// formula copied from ms xlsx xml source file
$objWriter->writeElement('formula', 'LEN(TRIM(' . $cellCoordinate . '))=0');
} elseif ($conditional->getConditionType() == Conditional::CONDITION_NOTCONTAINSBLANKS) {
// formula copied from ms xlsx xml source file
$objWriter->writeElement('formula', 'LEN(TRIM(' . $cellCoordinate . '))>0');
if ($conditional->getConditionType() == Conditional::CONDITION_CONTAINSTEXT) {
self::writeTextCondElements($objWriter, $conditional, $cellCoordinate);
} else {
self::writeOtherCondElements($objWriter, $conditional, $cellCoordinate);
}
$objWriter->endElement();
@ -1029,56 +1049,13 @@ class Worksheet extends WriterPart
}
/**
* Write Cell.
*
* @param XMLWriter $objWriter XML Writer
* @param PhpspreadsheetWorksheet $pSheet Worksheet
* @param Cell $pCellAddress Cell Address
* @param string[] $pFlippedStringTable String table (flipped), for faster index searching
* @param XMLWriter $objWriter
* @param string $mappedType
* @param RichText|string $cellValue
*/
private function writeCell(XMLWriter $objWriter, PhpspreadsheetWorksheet $pSheet, $pCellAddress, array $pFlippedStringTable)
private function writeCellInlineStr(XMLWriter $objWriter, string $mappedType, $cellValue): void
{
// Cell
$pCell = $pSheet->getCell($pCellAddress);
$objWriter->startElement('c');
$objWriter->writeAttribute('r', $pCellAddress);
// Sheet styles
if ($pCell->getXfIndex() != '') {
$objWriter->writeAttribute('s', $pCell->getXfIndex());
}
// If cell value is supplied, write cell value
$cellValue = $pCell->getValue();
if (is_object($cellValue) || $cellValue !== '') {
// Map type
$mappedType = $pCell->getDataType();
// Write data type depending on its type
switch (strtolower($mappedType)) {
case 'inlinestr': // Inline string
case 's': // String
case 'b': // Boolean
$objWriter->writeAttribute('t', $mappedType);
break;
case 'f': // Formula
$calculatedValue = ($this->getParentWriter()->getPreCalculateFormulas()) ?
$pCell->getCalculatedValue() : $cellValue;
if (is_string($calculatedValue)) {
$objWriter->writeAttribute('t', 'str');
} elseif (is_bool($calculatedValue)) {
$objWriter->writeAttribute('t', 'b');
}
break;
case 'e': // Error
$objWriter->writeAttribute('t', $mappedType);
}
// Write data depending on its type
switch (strtolower($mappedType)) {
case 'inlinestr': // Inline string
if (!$cellValue instanceof RichText) {
$objWriter->writeElement('t', StringHelper::controlCharacterPHP2OOXML(htmlspecialchars($cellValue)));
} elseif ($cellValue instanceof RichText) {
@ -1086,45 +1063,30 @@ class Worksheet extends WriterPart
$this->getParentWriter()->getWriterPart('stringtable')->writeRichText($objWriter, $cellValue);
$objWriter->endElement();
}
break;
case 's': // String
if (!$cellValue instanceof RichText) {
if (isset($pFlippedStringTable[$cellValue])) {
$objWriter->writeElement('v', $pFlippedStringTable[$cellValue]);
}
} elseif ($cellValue instanceof RichText) {
/**
* @param XMLWriter $objWriter
* @param string $mappedType
* @param RichText|string $cellValue
* @param string[] $pFlippedStringTable
*/
private function writeCellString(XMLWriter $objWriter, string $mappedType, $cellValue, array $pFlippedStringTable): void
{
$objWriter->writeAttribute('t', $mappedType);
if (!$cellValue instanceof RichText) {
self::writeElementIf($objWriter, isset($pFlippedStringTable[$cellValue]), 'v', $pFlippedStringTable[$cellValue]);
} else {
$objWriter->writeElement('v', $pFlippedStringTable[$cellValue->getHashCode()]);
}
break;
case 'f': // Formula
$attributes = $pCell->getFormulaAttributes();
if (($attributes['t'] ?? null) === 'array') {
$objWriter->startElement('f');
$objWriter->writeAttribute('t', 'array');
$objWriter->writeAttribute('ref', $pCellAddress);
$objWriter->writeAttribute('aca', '1');
$objWriter->writeAttribute('ca', '1');
$objWriter->text(substr($cellValue, 1));
$objWriter->endElement();
} else {
$objWriter->writeElement('f', substr($cellValue, 1));
}
if ($this->getParentWriter()->getOffice2003Compatibility() === false) {
if ($this->getParentWriter()->getPreCalculateFormulas()) {
if (!is_array($calculatedValue) && substr($calculatedValue, 0, 1) !== '#') {
$objWriter->writeElement('v', StringHelper::formatNumber($calculatedValue));
} else {
$objWriter->writeElement('v', '0');
}
} else {
$objWriter->writeElement('v', '0');
}
}
break;
case 'n': // Numeric
/**
* @param XMLWriter $objWriter
* @param float|int $cellValue
*/
private function writeCellNumeric(XMLWriter $objWriter, $cellValue): void
{
//force a decimal to be written if the type is float
if (is_float($cellValue)) {
// force point as decimal separator in case current locale uses comma
@ -1134,21 +1096,107 @@ class Worksheet extends WriterPart
}
}
$objWriter->writeElement('v', $cellValue);
}
private function writeCellBoolean(XMLWriter $objWriter, string $mappedType, bool $cellValue): void
{
$objWriter->writeAttribute('t', $mappedType);
$objWriter->writeElement('v', $cellValue ? '1' : '0');
}
private function writeCellError(XMLWriter $objWriter, string $mappedType, string $cellValue, string $formulaerr = '#NULL!'): void
{
$objWriter->writeAttribute('t', $mappedType);
$cellIsFormula = substr($cellValue, 0, 1) === '=';
self::writeElementIf($objWriter, $cellIsFormula, 'f', Xlfn::addXlfnStripEquals($cellValue));
$objWriter->writeElement('v', $cellIsFormula ? $formulaerr : $cellValue);
}
private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell $pCell): void
{
$calculatedValue = $this->getParentWriter()->getPreCalculateFormulas() ? $pCell->getCalculatedValue() : $cellValue;
if (is_string($calculatedValue)) {
if (substr($calculatedValue, 0, 1) === '#') {
$this->writeCellError($objWriter, 'e', $cellValue, $calculatedValue);
return;
}
$objWriter->writeAttribute('t', 'str');
} elseif (is_bool($calculatedValue)) {
$objWriter->writeAttribute('t', 'b');
}
// array values are not yet supported
//$attributes = $pCell->getFormulaAttributes();
//if (($attributes['t'] ?? null) === 'array') {
// $objWriter->startElement('f');
// $objWriter->writeAttribute('t', 'array');
// $objWriter->writeAttribute('ref', $pCellAddress);
// $objWriter->writeAttribute('aca', '1');
// $objWriter->writeAttribute('ca', '1');
// $objWriter->text(substr($cellValue, 1));
// $objWriter->endElement();
//} else {
// $objWriter->writeElement('f', Xlfn::addXlfnStripEquals($cellValue));
//}
$objWriter->writeElement('f', Xlfn::addXlfnStripEquals($cellValue));
self::writeElementIf(
$objWriter,
$this->getParentWriter()->getOffice2003Compatibility() === false,
'v',
($this->getParentWriter()->getPreCalculateFormulas() && !is_array($calculatedValue) && substr($calculatedValue, 0, 1) !== '#')
? StringHelper::formatNumber($calculatedValue) : '0'
);
}
/**
* Write Cell.
*
* @param XMLWriter $objWriter XML Writer
* @param PhpspreadsheetWorksheet $pSheet Worksheet
* @param string $pCellAddress Cell Address
* @param string[] $pFlippedStringTable String table (flipped), for faster index searching
*/
private function writeCell(XMLWriter $objWriter, PhpspreadsheetWorksheet $pSheet, string $pCellAddress, array $pFlippedStringTable)
{
// Cell
$pCell = $pSheet->getCell($pCellAddress);
$objWriter->startElement('c');
$objWriter->writeAttribute('r', $pCellAddress);
// Sheet styles
$xfi = $pCell->getXfIndex();
self::writeAttributeIf($objWriter, $xfi, 's', $xfi);
// If cell value is supplied, write cell value
$cellValue = $pCell->getValue();
if (is_object($cellValue) || $cellValue !== '') {
// Map type
$mappedType = $pCell->getDataType();
// Write data depending on its type
switch (strtolower($mappedType)) {
case 'inlinestr': // Inline string
$this->writeCellInlineStr($objWriter, $mappedType, $cellValue);
break;
case 's': // String
$this->writeCellString($objWriter, $mappedType, $cellValue, $pFlippedStringTable);
break;
case 'f': // Formula
$this->writeCellFormula($objWriter, $cellValue, $pCell);
break;
case 'n': // Numeric
$this->writeCellNumeric($objWriter, $cellValue);
break;
case 'b': // Boolean
$objWriter->writeElement('v', ($cellValue ? '1' : '0'));
$this->writeCellBoolean($objWriter, $mappedType, $cellValue);
break;
case 'e': // Error
if (substr($cellValue, 0, 1) === '=') {
$objWriter->writeElement('f', substr($cellValue, 1));
$objWriter->writeElement('v', substr($cellValue, 1));
} else {
$objWriter->writeElement('v', $cellValue);
}
break;
$this->writeCellError($objWriter, $mappedType, $cellValue);
}
}

View File

@ -0,0 +1,159 @@
<?php
namespace PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class Xlfn
{
const XLFNREGEXP = '/(?<!_xlfn[.])\\b('
// functions added with Excel 2010
. 'beta[.]dist'
. '|beta[.]inv'
. '|binom[.]dist'
. '|binom[.]inv'
. '|chisq[.]dist'
. '|chisq[.]dist[.]rt'
. '|chisq[.]inv'
. '|chisq[.]inv[.]rt'
. '|chisq[.]test'
. '|confidence[.]norm'
. '|confidence[.]t'
. '|covariance[.]p'
. '|covariance[.]s'
. '|erf[.]precise'
. '|erfc[.]precise'
. '|expon[.]dist'
. '|f[.]dist'
. '|f[.]dist[.]rt'
. '|f[.]inv'
. '|f[.]inv[.]rt'
. '|f[.]test'
. '|gamma[.]dist'
. '|gamma[.]inv'
. '|gammaln[.]precise'
. '|lognorm[.]dist'
. '|lognorm[.]inv'
. '|mode[.]mult'
. '|mode[.]sngl'
. '|negbinom[.]dist'
. '|networkdays[.]intl'
. '|norm[.]dist'
. '|norm[.]inv'
. '|norm[.]s[.]dist'
. '|norm[.]s[.]inv'
. '|percentile[.]exc'
. '|percentile[.]inc'
. '|percentrank[.]exc'
. '|percentrank[.]inc'
. '|poisson[.]dist'
. '|quartile[.]exc'
. '|quartile[.]inc'
. '|rank[.]avg'
. '|rank[.]eq'
. '|stdev[.]p'
. '|stdev[.]s'
. '|t[.]dist'
. '|t[.]dist[.]2t'
. '|t[.]dist[.]rt'
. '|t[.]inv'
. '|t[.]inv[.]2t'
. '|t[.]test'
. '|var[.]p'
. '|var[.]s'
. '|weibull[.]dist'
. '|z[.]test'
// functions added with Excel 2013
. '|acot'
. '|acoth'
. '|arabic'
. '|averageifs'
. '|binom[.]dist[.]range'
. '|bitand'
. '|bitlshift'
. '|bitor'
. '|bitrshift'
. '|bitxor'
. '|ceiling[.]math'
. '|combina'
. '|cot'
. '|coth'
. '|csc'
. '|csch'
. '|days'
. '|dbcs'
. '|decimal'
. '|encodeurl'
. '|filterxml'
. '|floor[.]math'
. '|formulatext'
. '|gamma'
. '|gauss'
. '|ifna'
. '|imcosh'
. '|imcot'
. '|imcsc'
. '|imcsch'
. '|imsec'
. '|imsech'
. '|imsinh'
. '|imtan'
. '|isformula'
. '|iso[.]ceiling'
. '|isoweeknum'
. '|munit'
. '|numbervalue'
. '|pduration'
. '|permutationa'
. '|phi'
. '|rri'
. '|sec'
. '|sech'
. '|sheet'
. '|sheets'
. '|skew[.]p'
. '|unichar'
. '|unicode'
. '|webservice'
. '|xor'
// functions added with Excel 2016
. '|forecast[.]et2'
. '|forecast[.]ets[.]confint'
. '|forecast[.]ets[.]seasonality'
. '|forecast[.]ets[.]stat'
. '|forecast[.]linear'
. '|switch'
// functions added with Excel 2019
. '|concat'
. '|countifs'
. '|ifs'
. '|maxifs'
. '|minifs'
. '|sumifs'
. '|textjoin'
// functions added with Excel 365
. '|filter'
. '|randarray'
. '|sequence'
. '|sort'
. '|sortby'
. '|unique'
. '|xlookup'
. '|xmatch'
. ')(?=\\s*[(])/i';
/**
* Prefix function name in string with _xlfn. where required.
*/
public static function addXlfn(string $funcstring): string
{
return preg_replace(self::XLFNREGEXP, '_xlfn.$1', $funcstring);
}
/**
* Prefix function name in string with _xlfn. where required.
* Leading character, expected to be equals sign, is stripped.
*/
public static function addXlfnStripEquals(string $funcstring): string
{
return self::addXlfn(substr($funcstring, 1));
}
}

View File

@ -310,6 +310,7 @@ class CalculationTest extends TestCase
* be set in cache
* @param string[] $shouldNotBeSetInCacheCells coordinates of cells that must
* not be set in cache because of pruning
*
* @dataProvider dataProviderBranchPruningFullExecution
*/
public function testFullExecution(
@ -353,4 +354,18 @@ class CalculationTest extends TestCase
{
return require 'tests/data/Calculation/Calculation.php';
}
public function testUnknownFunction(): void
{
$workbook = new Spreadsheet();
$sheet = $workbook->getActiveSheet();
$sheet->setCellValue('A1', '=gzorg()');
$sheet->setCellValue('A2', '=mode.gzorg(1)');
$sheet->setCellValue('A3', '=gzorg(1,2)');
$sheet->setCellValue('A4', '=3+IF(gzorg(),1,2)');
self::assertEquals('#NAME?', $sheet->getCell('A1')->getCalculatedValue());
self::assertEquals('#NAME?', $sheet->getCell('A2')->getCalculatedValue());
self::assertEquals('#NAME?', $sheet->getCell('A3')->getCalculatedValue());
self::assertEquals('#NAME?', $sheet->getCell('A4')->getCalculatedValue());
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\Statistical;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PHPUnit\Framework\TestCase;
class ModeTest extends TestCase
{
/**
* @dataProvider providerMODE
*
* @param mixed $expectedResult
* @param string $str
*/
public function testMODE($expectedResult, string $str): void
{
$workbook = new Spreadsheet();
$sheet = $workbook->getActiveSheet();
$row = 1;
$sheet->setCellValue("B$row", "=MODE($str)");
$sheet->setCellValue("C$row", "=MODE.SNGL($str)");
self::assertEquals($expectedResult, $sheet->getCell("B$row")->getCalculatedValue());
self::assertEquals($expectedResult, $sheet->getCell("C$row")->getCalculatedValue());
}
public function providerMODE(): array
{
return require 'tests/data/Calculation/Statistical/MODE.php';
}
public function testMODENoArgs(): void
{
$this->expectException(\PhpOffice\PhpSpreadsheet\Calculation\Exception::class);
$workbook = new Spreadsheet();
$sheet = $workbook->getActiveSheet();
$sheet->setCellValue('B1', '=MODE()');
self::assertEquals('#N/A', $sheet->getCell('B1')->getCalculatedValue());
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace PhpOffice\PhpSpreadsheetTests\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Shared\File;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Color;
use PhpOffice\PhpSpreadsheet\Style\Conditional;
use PhpOffice\PhpSpreadsheet\Style\Fill;
class XlfnFunctionsTest extends \PHPUnit\Framework\TestCase
{
public function testXlfn(): void
{
$formulas = [
// null indicates function not implemented in Calculation engine
['2010', 'A1', '=MODE.SNGL({5.6,4,4,3,2,4})', '=_xlfn.MODE.SNGL({5.6,4,4,3,2,4})', 4],
['2010', 'A2', '=MODE.SNGL({"x","y"})', '=_xlfn.MODE.SNGL({"x","y"})', '#N/A'],
['2013', 'A1', '=ISOWEEKNUM("2019-12-19")', '=_xlfn.ISOWEEKNUM("2019-12-19")', 51],
['2013', 'A2', '=SHEET("2019")', '=_xlfn.SHEET("2019")', null],
['2013', 'A3', '2019-01-04', '2019-01-04', null],
['2013', 'A4', '2019-07-04', '2019-07-04', null],
['2013', 'A5', '2019-12-04', '2019-12-04', null],
['2013', 'B3', 1, 1, null],
['2013', 'B4', 2, 2, null],
['2013', 'B5', -3, -3, null],
// multiple xlfn functions interleaved with non-xlfn
['2013', 'C3', '=ISOWEEKNUM(A3)+WEEKNUM(A4)+ISOWEEKNUM(A5)', '=_xlfn.ISOWEEKNUM(A3)+WEEKNUM(A4)+_xlfn.ISOWEEKNUM(A5)', 77],
['2016', 'A1', '=SWITCH(WEEKDAY("2019-12-22",1),1,"Sunday",2,"Monday","No Match")', '=_xlfn.SWITCH(WEEKDAY("2019-12-22",1),1,"Sunday",2,"Monday","No Match")', 'Sunday'],
['2016', 'B1', '=SWITCH(WEEKDAY("2019-12-20",1),1,"Sunday",2,"Monday","No Match")', '=_xlfn.SWITCH(WEEKDAY("2019-12-20",1),1,"Sunday",2,"Monday","No Match")', 'No Match'],
['2019', 'A1', '=CONCAT("The"," ","sun"," ","will"," ","come"," ","up"," ","tomorrow.")', '=_xlfn.CONCAT("The"," ","sun"," ","will"," ","come"," ","up"," ","tomorrow.")', 'The sun will come up tomorrow.'],
];
$workbook = new Spreadsheet();
$sheet = $workbook->getActiveSheet();
$sheet->setTitle('2010');
$sheet = $workbook->createSheet();
$sheet->setTitle('2013');
$sheet = $workbook->createSheet();
$sheet->setTitle('2016');
$sheet = $workbook->createSheet();
$sheet->setTitle('2019');
foreach ($formulas as $values) {
$sheet = $workbook->setActiveSheetIndexByName($values[0]);
$sheet->setCellValue($values[1], $values[2]);
}
$sheet = $workbook->setActiveSheetIndexByName('2013');
$sheet->getStyle('A3:A5')->getNumberFormat()->setFormatCode('yyyy-mm-dd');
$sheet->getColumnDimension('A')->setAutoSize(true);
$condition0 = new Conditional();
$condition0->setConditionType(Conditional::CONDITION_EXPRESSION);
$condition0->addCondition('ABS(B3)<2');
$condition0->getStyle()->getFill()->setFillType(Fill::FILL_SOLID);
$condition0->getStyle()->getFill()->getEndColor()->setARGB(Color::COLOR_RED);
$condition1 = new Conditional();
$condition1->setConditionType(Conditional::CONDITION_EXPRESSION);
$condition1->addCondition('ABS(B3)>2');
$condition1->getStyle()->getFill()->setFillType(Fill::FILL_SOLID);
$condition1->getStyle()->getFill()->getEndColor()->setARGB(Color::COLOR_GREEN);
$cond = [$condition0, $condition1];
$sheet->getStyle('B3:B5')->setConditionalStyles($cond);
$condition0 = new Conditional();
$condition0->setConditionType(Conditional::CONDITION_EXPRESSION);
$condition0->addCondition('ISOWEEKNUM(A3)<10');
$condition0->getStyle()->getFill()->setFillType(Fill::FILL_SOLID);
$condition0->getStyle()->getFill()->getEndColor()->setARGB(Color::COLOR_RED);
$condition1 = new Conditional();
$condition1->setConditionType(Conditional::CONDITION_EXPRESSION);
$condition1->addCondition('ISOWEEKNUM(A3)>40');
$condition1->getStyle()->getFill()->setFillType(Fill::FILL_SOLID);
$condition1->getStyle()->getFill()->getEndColor()->setARGB(Color::COLOR_GREEN);
$cond = [$condition0, $condition1];
$sheet->getStyle('A3:A5')->setConditionalStyles($cond);
$sheet->setSelectedCell('B1');
$writer = \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($workbook, 'Xlsx');
$oufil = tempnam(File::sysGetTempDir(), 'phpspreadsheet-test');
$writer->save($oufil);
$reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader('Xlsx');
$rdobj = $reader->load($oufil);
unlink($oufil);
foreach ($formulas as $values) {
$sheet = $rdobj->setActiveSheetIndexByName($values[0]);
self::assertEquals($values[3], $sheet->getCell($values[1])->getValue());
if ($values[4] !== null) {
self::assertEquals($values[4], $sheet->getCell($values[1])->getCalculatedValue());
}
}
$sheet = $rdobj->setActiveSheetIndexByName('2013');
$cond = $sheet->getConditionalStyles('A3:A5');
self::assertEquals('_xlfn.ISOWEEKNUM(A3)<10', $cond[0]->getConditions()[0]);
self::assertEquals('_xlfn.ISOWEEKNUM(A3)>40', $cond[1]->getConditions()[0]);
$cond = $sheet->getConditionalStyles('B3:B5');
self::assertEquals('ABS(B3)<2', $cond[0]->getConditions()[0]);
self::assertEquals('ABS(B3)>2', $cond[1]->getConditions()[0]);
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace PhpOffice\PhpSpreadsheetTests\Functional;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Conditional;
use PhpOffice\PhpSpreadsheet\Style\Fill;
class ConditionalTextTest extends AbstractFunctional
{
const COLOR_GREEN = 'FF99FF66';
const COLOR_RED = 'FFFF5050';
const COLOR_BLUE = 'FF5050FF';
const COLOR_YELLOW = 'FFFFFF50';
public function testConditionalText(): void
{
$format = 'Xlsx';
$spreadsheet = new Spreadsheet();
$conditionalStyles = [];
// if text contains 'anywhere' - red background
$condition0 = new Conditional();
$condition0->setConditionType(Conditional::CONDITION_CONTAINSTEXT);
$condition0->setOperatorType(Conditional::CONDITION_CONTAINSTEXT);
$condition0->setText('anywhere');
$condition0->getStyle()->getFill()
->setFillType(Fill::FILL_SOLID)
->getEndColor()->setARGB(self::COLOR_RED);
array_push($conditionalStyles, $condition0);
// if text contains 'Left' on left - green background
$condition1 = new Conditional();
$condition1->setConditionType(Conditional::CONDITION_CONTAINSTEXT);
$condition1->setOperatorType(Conditional::OPERATOR_BEGINSWITH);
$condition1->setText('Left');
$condition1->getStyle()->getFill()
->setFillType(Fill::FILL_SOLID)
->getEndColor()->setARGB(self::COLOR_GREEN);
array_push($conditionalStyles, $condition1);
// if text contains 'right' on right - blue background
$condition2 = new Conditional();
$condition2->setConditionType(Conditional::CONDITION_CONTAINSTEXT);
$condition2->setOperatorType(Conditional::OPERATOR_ENDSWITH);
$condition2->setText('right');
$condition2->getStyle()->getFill()
->setFillType(Fill::FILL_SOLID)
->getEndColor()->setARGB(self::COLOR_BLUE);
array_push($conditionalStyles, $condition2);
// if text contains no spaces - yellow background
$condition3 = new Conditional();
$condition3->setConditionType(Conditional::CONDITION_CONTAINSTEXT);
$condition3->setOperatorType(Conditional::OPERATOR_NOTCONTAINS);
$condition3->setText(' ');
$condition3->getStyle()->getFill()
->setFillType(Fill::FILL_SOLID)
->getEndColor()->setARGB(self::COLOR_YELLOW);
array_push($conditionalStyles, $condition3);
$sheet = $spreadsheet->getActiveSheet();
$sheet->setCellValue('B1', 'This should match anywhere, right?');
$sheet->setCellValue('B2', 'This should match nowhere, right?');
$sheet->setCellValue('B3', 'Left match');
$sheet->setCellValue('B4', 'Match on right');
$sheet->setCellValue('B5', 'nospaces');
$xpCoordinate = 'B1:B5';
$spreadsheet->getActiveSheet()->setConditionalStyles($xpCoordinate, $conditionalStyles);
$sheet->getColumnDimension('B')->setAutoSize(true);
$reloadedSpreadsheet = $this->writeAndReload($spreadsheet, $format);
// see if we successfully written conditional text elements
$newConditionalStyles = $reloadedSpreadsheet->getActiveSheet()->getConditionalStyles($xpCoordinate);
$cnt = count($conditionalStyles);
for ($i = 0; $i < $cnt; ++$i) {
self::assertEquals(
$conditionalStyles[$i]->getConditionType(),
$newConditionalStyles[$i]->getConditionType(),
"Failure on condition type $i"
);
self::assertEquals(
$conditionalStyles[$i]->getOperatorType(),
$newConditionalStyles[$i]->getOperatorType(),
"Failure on operator type $i"
);
self::assertEquals(
$conditionalStyles[$i]->getText(),
$newConditionalStyles[$i]->getText(),
"Failure on text $i"
);
$filCond = $conditionalStyles[$i]->getStyle()->getFill();
$newCond = $newConditionalStyles[$i]->getStyle()->getFill();
self::assertEquals(
$filCond->getFillType(),
$newCond->getFillType(),
"Failure on fill type $i"
);
self::assertEquals(
$filCond->getEndColor()->getARGB(),
$newCond->getEndColor()->getARGB(),
"Failure on end color $i"
);
}
}
}

View File

@ -0,0 +1,11 @@
<?php
return [
[4.1, '{5.6,4.1,4.1,3,2,4.1}'], // Calculated value was #N/A
[4.1, '5.6,4.1,4.1,3,2,4.1'],
[3, '3,3,4,4'],
[4, '4,3,3,4'], // Calculated value was 3
['#N/A', '1,2,3,4'],
[2, '1,2,2,"3","3","3"'],
['#N/A', '"3","3","3"'],
];