From be25c443137582f8de4c8faea9427f97d9a79023 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Tue, 26 Mar 2024 17:22:32 -0700 Subject: [PATCH 01/31] WIP Excel Adding At-Signs to Functions This has come up a number of times, most recently with issue #3901, and also issue #3659. It will certainly come up more often in days to come. Excel is changing formulas which PhpSpreadsheet has output as `=UNIQUE(A1:A19)`; Excel is processing the formula as it were `=@UNIQUE(A1:A19)`. This behavior is explained, in part, by https://github.com/PHPOffice/PhpSpreadsheet/pull/3659#issuecomment-1663040464. It is doing so in order to ensure that the function returns only a single value rather than an array of values, in case the spreadsheet is being processed (or possibly was created) by a less current version of Excel which cannot handle the array result. PhpSpreadsheet follows Excel to a certain extent; it defaults to returning a single calculated value when an array would be returned. Further, its support for outputting an array even when that default is overridden is incomplete. I am not prepared to do everything that Excel does for the array functions (details below), but this PR is a start in that direction. If the default is changed via: ```php use PhpOffice\PhpSpreadsheet\Calculation\Calculation; Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); ``` When that is done, `getCalculatedValue` will return an array (no code change necessary). However, Writer/Xlsx will now be updated to look at that value, and if an array is returned in that circumstance, will indicate in the Xml that the result is an array *and* will include a reference to the bounds of the array. This gets us close, although not completely there, to what Excel does, and may be good enough for now. Excel will still mess with the formula, but now it will treat it as `{=UNIQUE(A1:A19)}`. This means that the spreadsheet will now look correct; there will be superficial differences, but all cells will have the expected value. Technically, the major difference between what PhpSpreadsheet will output now, and what Excel does on its own, is that Excel supplies values in the xml for all the cells in the range. That would be difficult for PhpSpreadsheet to do; that could be a project for another day. Excel will treat the output from PhpSpreadsheet as "Array Formulas" (a.k.a. CSE (control shift enter) formulas because you need to use that combination of keys to manually enter them in older versions of Excel). Current versions of Excel will instead use "Dynamic Array Formulas". Dynamic Array Formulas can be changed by the user; Array Formulas need to be deleted and re-entered if you want to change them. I don't know what else might have to change to get Excel to use the latter for PhpSpreadsheet formulas, and I will probably not even try to look now, saving it for a future date. Unit testing of this change uncovered a bug in Calculation::calculateCellValue. That routine saves off ArrayReturnType, and may change it, and is supposed to restore it. But it does not do the restore if the calculation throws an exception. It is changed to do so. --- .../Calculation/Calculation.php | 1 + src/PhpSpreadsheet/Cell/Cell.php | 2 +- src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 16 ++- .../Worksheet/Table/Issue3659Test.php | 61 +++++++++ .../Writer/Xlsx/ArrayFunctionsTest.php | 127 ++++++++++++++++++ 5 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index 6028a9d72f..7a2137dc06 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -3489,6 +3489,7 @@ public function calculateCellValue(?Cell $cell = null, bool $resetLog = true): m $testSheet->getCell($cellAddress['cell']); } } + self::$returnArrayAsType = $returnArrayAsType; throw new Exception($e->getMessage(), $e->getCode(), $e); } diff --git a/src/PhpSpreadsheet/Cell/Cell.php b/src/PhpSpreadsheet/Cell/Cell.php index 987e1a361e..543574e849 100644 --- a/src/PhpSpreadsheet/Cell/Cell.php +++ b/src/PhpSpreadsheet/Cell/Cell.php @@ -356,7 +356,7 @@ public function getCalculatedValue(bool $resetLog = true): mixed $this->getWorksheet()->setSelectedCells($selected); $this->getWorksheet()->getParentOrThrow()->setActiveSheetIndex($index); // We don't yet handle array returns - if (is_array($result)) { + if (is_array($result) && Calculation::getArrayReturnType() !== Calculation::RETURN_ARRAY_AS_ARRAY) { while (is_array($result)) { $result = array_shift($result); } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index a14ff28125..301d988ab6 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -1402,10 +1402,24 @@ private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell } $attributes = $cell->getFormulaAttributes(); + $ref = $cell->getCoordinate(); + if (is_array($calculatedValue)) { + $attributes['t'] = 'array'; + $rows = max(1, count($calculatedValue)); + $cols = 1; + foreach ($calculatedValue as $row) { + $cols = max($cols, is_array($row) ? count($row) : 1); + } + $firstCellArray = Coordinate::indexesFromString($ref); + $lastRow = $firstCellArray[1] + $rows - 1; + $lastColumn = $firstCellArray[0] + $cols - 1; + $lastColumnString = Coordinate::stringFromColumnIndex($lastColumn); + $ref .= ":$lastColumnString$lastRow"; + } if (($attributes['t'] ?? null) === 'array') { $objWriter->startElement('f'); $objWriter->writeAttribute('t', 'array'); - $objWriter->writeAttribute('ref', $cell->getCoordinate()); + $objWriter->writeAttribute('ref', $ref); $objWriter->writeAttribute('aca', '1'); $objWriter->writeAttribute('ca', '1'); $objWriter->text(FunctionPrefix::addFunctionPrefixStripEquals($cellValue)); diff --git a/tests/PhpSpreadsheetTests/Worksheet/Table/Issue3659Test.php b/tests/PhpSpreadsheetTests/Worksheet/Table/Issue3659Test.php index 3ec7c6bd5f..ff1108fe8d 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/Table/Issue3659Test.php +++ b/tests/PhpSpreadsheetTests/Worksheet/Table/Issue3659Test.php @@ -4,10 +4,24 @@ namespace PhpOffice\PhpSpreadsheetTests\Worksheet\Table; +use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Worksheet\Table; class Issue3659Test extends SetupTeardown { + private string $arrayReturnType; + + protected function setUp(): void + { + $this->arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + parent::tearDown(); + Calculation::setArrayReturnType($this->arrayReturnType); + } + public function testTableOnOtherSheet(): void { $spreadsheet = $this->getSpreadsheet(); @@ -44,4 +58,51 @@ public function testTableOnOtherSheet(): void self::assertSame('F8', $tableSheet->getSelectedCells()); self::assertSame($sheet, $spreadsheet->getActiveSheet()); } + + public function testTableAsArray(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = $this->getSpreadsheet(); + $sheet = $this->getSheet(); + $sheet->setTitle('Feuil1'); + $tableSheet = $spreadsheet->createSheet(); + $tableSheet->setTitle('sheet_with_table'); + $tableSheet->fromArray( + [ + ['MyCol', 'Colonne2', 'Colonne3'], + [10, 20], + [2], + [3], + [4], + ], + null, + 'B1', + true + ); + $table = new Table('B1:D5', 'Tableau1'); + $tableSheet->addTable($table); + $sheet->setSelectedCells('F7'); + $tableSheet->setSelectedCells('F8'); + self::assertSame($sheet, $spreadsheet->getActiveSheet()); + $sheet->getCell('F1')->setValue('=Tableau1[MyCol]'); + $sheet->getCell('H1')->setValue('=Tableau1[]'); + $sheet->getCell('F9')->setValue('=Tableau1'); + $sheet->getCell('J9')->setValue('=CONCAT(Tableau1)'); + $sheet->getCell('J11')->setValue('=SUM(Tableau1[])'); + $expectedResult = [2 => ['B' => 10], ['B' => 2], ['B' => 3], ['B' => 4]]; + self::assertSame($expectedResult, $sheet->getCell('F1')->getCalculatedValue()); + $expectedResult = [ + 2 => ['B' => 10, 'C' => 20, 'D' => null], + ['B' => 2, 'C' => null, 'D' => null], + ['B' => 3, 'C' => null, 'D' => null], + ['B' => 4, 'C' => null, 'D' => null], + ]; + self::assertSame($expectedResult, $sheet->getCell('H1')->getCalculatedValue()); + self::assertSame($expectedResult, $sheet->getCell('F9')->getCalculatedValue()); + self::assertSame('1020234', $sheet->getCell('J9')->getCalculatedValue(), 'Header row not included'); + self::assertSame(39, $sheet->getCell('J11')->getCalculatedValue(), 'Header row not included'); + self::assertSame('F7', $sheet->getSelectedCells()); + self::assertSame('F8', $tableSheet->getSelectedCells()); + self::assertSame($sheet, $spreadsheet->getActiveSheet()); + } } diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php new file mode 100644 index 0000000000..39cc72ff72 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php @@ -0,0 +1,127 @@ +arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + Calculation::setArrayReturnType($this->arrayReturnType); + if ($this->outputFile !== '') { + unlink($this->outputFile); + $this->outputFile = ''; + } + } + + public function testArrayOutput(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $columnArray = [ + [41], + [57], + [51], + [54], + [49], + [43], + [35], + [35], + [44], + [47], + [48], + [26], + [57], + [34], + [61], + [34], + [28], + [29], + [41], + ]; + $sheet->fromArray($columnArray, 'A1'); + $sheet->setCellValue('C1', '=UNIQUE(A1:A19)'); + $sheet->setCellValue('D1', '=SORT(A1:A19)'); + $writer = new XlsxWriter($spreadsheet); + $this->outputFile = File::temporaryFilename(); + $writer->save($this->outputFile); + $spreadsheet->disconnectWorksheets(); + + $reader = new XlsxReader(); + $spreadsheet2 = $reader->load($this->outputFile); + $sheet2 = $spreadsheet2->getActiveSheet(); + $expectedUnique = [ + ['41'], + ['57'], + ['51'], + ['54'], + ['49'], + ['43'], + ['35'], + ['44'], + ['47'], + ['48'], + ['26'], + ['34'], + ['61'], + ['28'], + ['29'], + ]; + self::assertCount(15, $expectedUnique); + self::assertSame($expectedUnique, $sheet2->getCell('C1')->getCalculatedValue()); + $expectedSort = [ + [26], + [28], + [29], + [34], + [34], + [35], + [35], + [41], + [41], + [43], + [44], + [47], + [48], + [49], + [51], + [54], + [57], + [57], + [61], + ]; + self::assertCount(19, $expectedSort); + self::assertCount(19, $columnArray); + self::assertSame($expectedSort, $sheet2->getCell('D1')->getCalculatedValue()); + $spreadsheet2->disconnectWorksheets(); + + $file = 'zip://'; + $file .= $this->outputFile; + $file .= '#xl/worksheets/sheet1.xml'; + $data = file_get_contents($file); + if ($data === false) { + self::fail('Unable to read file'); + } else { + self::assertStringContainsString('_xlfn.UNIQUE(A1:A19)', $data, '15 results for UNIQUE'); + self::assertStringContainsString('_xlfn._xlws.SORT(A1:A19)', $data, '19 results for SORT'); + } + } +} From b53a4b760246e07254a95103b6e11f1d6fb7619b Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Tue, 16 Apr 2024 19:45:13 -0700 Subject: [PATCH 02/31] Xlsx Reader Use Dimensions from Functions With Array Results Thinking about #3958 - user wondered if unsupported formulas with array results could be handled better. I said that the answer was "no", but I think Xlsx Reader can make use of the dimensions of the result after all, so the answer is actually "sometimes". This is an initial attempt to do that. Implementing it revealed a bug in how Xlsx Reader handles array formula attributes, and that is now corrected. Likewise, Xlsx Writer did not indicate a value for the first cell in the array, and does now. --- src/PhpSpreadsheet/Reader/Xlsx.php | 13 ++++-- src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 15 ++++++- .../Writer/Xlsx/ArrayFunctionsTest.php | 40 +++++++++++++++++- tests/data/Reader/XLSX/atsign.choosecols.xlsx | Bin 0 -> 9555 bytes 4 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 tests/data/Reader/XLSX/atsign.choosecols.xlsx diff --git a/src/PhpSpreadsheet/Reader/Xlsx.php b/src/PhpSpreadsheet/Reader/Xlsx.php index c8b2de2a9a..2daa2dc07c 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/src/PhpSpreadsheet/Reader/Xlsx.php @@ -891,9 +891,16 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet } else { // Formula $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToString'); - if (isset($c->f['t'])) { - $attributes = $c->f['t']; - $docSheet->getCell($r)->setFormulaAttributes(['t' => (string) $attributes]); + $formulaAttributes = []; + $attributes = $c->f->attributes(); + if (isset($attributes['t'])) { + $formulaAttributes['t'] = (string) $attributes['t']; + } + if (isset($attributes['ref'])) { + $formulaAttributes['ref'] = (string) $attributes['ref']; + } + if (!empty($formulaAttributes)) { + $docSheet->getCell($r)->setFormulaAttributes($formulaAttributes); } } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index 301d988ab6..1b149c8330 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -1401,8 +1401,8 @@ private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell $calculatedValue = (int) $calculatedValue; } - $attributes = $cell->getFormulaAttributes(); - $ref = $cell->getCoordinate(); + $attributes = $cell->getFormulaAttributes() ?? []; + $ref = array_key_exists('ref', $attributes) ? $attributes['ref'] : $cell->getCoordinate(); if (is_array($calculatedValue)) { $attributes['t'] = 'array'; $rows = max(1, count($calculatedValue)); @@ -1424,6 +1424,17 @@ private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell $objWriter->writeAttribute('ca', '1'); $objWriter->text(FunctionPrefix::addFunctionPrefixStripEquals($cellValue)); $objWriter->endElement(); + $result = $calculatedValue; + while (is_array($result)) { + $result = array_shift($result); + } + if ( + is_scalar($result) + && $this->getParentWriter()->getOffice2003Compatibility() === false + && $this->getParentWriter()->getPreCalculateFormulas() + ) { + $objWriter->writeElement('v', (string) $result); + } } else { $objWriter->writeElement('f', FunctionPrefix::addFunctionPrefixStripEquals($cellValue)); self::writeElementIf( diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php index 39cc72ff72..fc26beb544 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php @@ -120,8 +120,44 @@ public function testArrayOutput(): void if ($data === false) { self::fail('Unable to read file'); } else { - self::assertStringContainsString('_xlfn.UNIQUE(A1:A19)', $data, '15 results for UNIQUE'); - self::assertStringContainsString('_xlfn._xlws.SORT(A1:A19)', $data, '19 results for SORT'); + self::assertStringContainsString('_xlfn.UNIQUE(A1:A19)41', $data, '15 results for UNIQUE'); + self::assertStringContainsString('_xlfn._xlws.SORT(A1:A19)26', $data, '19 results for SORT'); + } + } + + public function testUnimplementedArrayOutput(): void + { + //Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); // not required for this test + $reader = new XlsxReader(); + $spreadsheet = $reader->load('tests/data/Reader/XLSX/atsign.choosecols.xlsx'); + $writer = new XlsxWriter($spreadsheet); + $this->outputFile = File::temporaryFilename(); + $writer->save($this->outputFile); + $spreadsheet->disconnectWorksheets(); + + $reader = new XlsxReader(); + $spreadsheet2 = $reader->load($this->outputFile); + $sheet2 = $spreadsheet2->getActiveSheet(); + self::assertSame('=_xlfn.CHOOSECOLS(A1:C5,3,1)', $sheet2->getCell('F1')->getValue()); + $expectedFG = [ + ['11', '1'], + ['12', '2'], + ['13', '3'], + ['14', '4'], + ['15', '5'], + ]; + $actualFG = $sheet2->rangeToArray('F1:G5'); + self::assertSame($expectedFG, $actualFG); + $spreadsheet2->disconnectWorksheets(); + + $file = 'zip://'; + $file .= $this->outputFile; + $file .= '#xl/worksheets/sheet1.xml'; + $data = file_get_contents($file); + if ($data === false) { + self::fail('Unable to read file'); + } else { + self::assertStringContainsString('_xlfn.CHOOSECOLS(A1:C5,3,1)11', $data); } } } diff --git a/tests/data/Reader/XLSX/atsign.choosecols.xlsx b/tests/data/Reader/XLSX/atsign.choosecols.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..45ea6ca2ae5a7613923705d5105b70c8a030a635 GIT binary patch literal 9555 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 literal 0 HcmV?d00001 From 61f24ca0991d57ec0dd822508792c817534fb36e Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Fri, 19 Apr 2024 00:04:27 -0700 Subject: [PATCH 03/31] Sample Submitted by @jr212 Address sample code submitted by @jr212 which was not working correctly. --- src/PhpSpreadsheet/Cell/Cell.php | 12 +++++++ .../Style/NumberFormat/Formatter.php | 5 ++- src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 33 +++++++++++++++++-- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/PhpSpreadsheet/Cell/Cell.php b/src/PhpSpreadsheet/Cell/Cell.php index 543574e849..a6be2f8ce1 100644 --- a/src/PhpSpreadsheet/Cell/Cell.php +++ b/src/PhpSpreadsheet/Cell/Cell.php @@ -361,6 +361,18 @@ public function getCalculatedValue(bool $resetLog = true): mixed $result = array_shift($result); } } + // if return_as_array for formula like '=sheet!cell' + if (is_array($result) && count($result) === 1) { + $resultKey = array_keys($result)[0]; + $resultValue = $result[$resultKey]; + if (is_int($resultKey) && is_array($resultValue) && count($resultValue) === 1) { + $resultKey2 = array_keys($resultValue)[0]; + $resultValue2 = $resultValue[$resultKey2]; + if (is_string($resultKey2) && !is_array($resultValue2) && preg_match('/[a-zA-Z]{1,3}/', $resultKey2) === 1) { + $result = $resultValue2; + } + } + } } catch (SpreadsheetException $ex) { if (($ex->getMessage() === 'Unable to access External Workbook') && ($this->calculatedValue !== null)) { return $this->calculatedValue; // Fallback for calculations referencing external files. diff --git a/src/PhpSpreadsheet/Style/NumberFormat/Formatter.php b/src/PhpSpreadsheet/Style/NumberFormat/Formatter.php index 984aa174e7..c394a0ee26 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat/Formatter.php +++ b/src/PhpSpreadsheet/Style/NumberFormat/Formatter.php @@ -107,7 +107,7 @@ private static function splitFormatForSectionSelection(array $sections, mixed $v /** * Convert a value in a pre-defined format to a PHP string. * - * @param null|bool|float|int|RichText|string $value Value to format + * @param null|array|bool|float|int|RichText|string $value Value to format * @param string $format Format code: see = self::FORMAT_* for predefined values; * or can be any valid MS Excel custom format string * @param ?array $callBack Callback function for additional formatting of string @@ -116,6 +116,9 @@ private static function splitFormatForSectionSelection(array $sections, mixed $v */ public static function toFormattedString($value, string $format, ?array $callBack = null): string { + while (is_array($value)) { + $value = array_shift($value); + } if (is_bool($value)) { return $value ? Calculation::getTRUE() : Calculation::getFALSE(); } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index 1b149c8330..56efed67d5 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -1402,7 +1402,12 @@ private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell } $attributes = $cell->getFormulaAttributes() ?? []; - $ref = array_key_exists('ref', $attributes) ? $attributes['ref'] : $cell->getCoordinate(); + $coordinate = $cell->getCoordinate(); + if (isset($attributes['ref'])) { + $ref = $this->parseRef($coordinate, $attributes['ref']); + } else { + $ref = $coordinate; + } if (is_array($calculatedValue)) { $attributes['t'] = 'array'; $rows = max(1, count($calculatedValue)); @@ -1410,11 +1415,11 @@ private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell foreach ($calculatedValue as $row) { $cols = max($cols, is_array($row) ? count($row) : 1); } - $firstCellArray = Coordinate::indexesFromString($ref); + $firstCellArray = Coordinate::indexesFromString($coordinate); $lastRow = $firstCellArray[1] + $rows - 1; $lastColumn = $firstCellArray[0] + $cols - 1; $lastColumnString = Coordinate::stringFromColumnIndex($lastColumn); - $ref .= ":$lastColumnString$lastRow"; + $ref = "$coordinate:$lastColumnString$lastRow"; } if (($attributes['t'] ?? null) === 'array') { $objWriter->startElement('f'); @@ -1449,6 +1454,28 @@ private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell } } + private function parseRef(string $coordinate, string $ref): string + { + if (preg_match('/^([A-Z]{1,3})([0-9]{1,7})(:([A-Z]{1,3})([0-9]{1,7}))?$/', $ref, $matches) !== 1) { + return $ref; + } + if (!isset($matches[3])) { // single cell, not range + return $coordinate; + } + $minRow = (int) $matches[2]; + $maxRow = (int) $matches[5]; + $rows = $maxRow - $minRow + 1; + $minCol = Coordinate::columnIndexFromString($matches[1]); + $maxCol = Coordinate::columnIndexFromString($matches[4]); + $cols = $maxCol - $minCol + 1; + $firstCellArray = Coordinate::indexesFromString($coordinate); + $lastRow = $firstCellArray[1] + $rows - 1; + $lastColumn = $firstCellArray[0] + $cols - 1; + $lastColumnString = Coordinate::stringFromColumnIndex($lastColumn); + + return "$coordinate:$lastColumnString$lastRow"; + } + /** * Write Cell. * From 08ba00b57598b6af272c320d31ef2e68af998edc Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 6 Jun 2024 06:52:14 -0700 Subject: [PATCH 04/31] Populate Rest of Array Cells, UNIQUE Changes See issue #4062. When calculating an array formula, populate all the cells associated with the result. This is almost the same as Excel's behavior. As yet, there is no attempt to create a #SPILL error, so cells may be inappropriately overwritten. Also, if the array size shrinks (e.g. there are fewer unique values than before), no attempt is made to unpopulate the cells which were in range but are now outside the new dimensions. Spill and unpopulation are somewhat related, and will probably be handled at the same time, but their time has not yet come. UNIQUE, at least for rows, was treating all cell (calculated) values as strings. This is not the same behavior as Excel, which will preserve datatypes, and treat int 3 and string 3 as unique values. Excel will, however, treat int 3 and float 3.0 as non-unique. Within UNIQUE, private function uniqueByRow is changed to try to preserve the the datatype when executing (it will probably treat 3.0 as int - I don't know how I can, or even if I should attempt to, do better - but no int nor float should be treated as a string). --- .../Calculation/LookupRef/Unique.php | 21 +- src/PhpSpreadsheet/Cell/Cell.php | 16 + .../Functions/LookupRef/UniqueTest.php | 11 +- .../Writer/Xlsx/ArrayFunctions2Test.php | 307 ++++++++++++++++++ .../Writer/Xlsx/ArrayFunctionsTest.php | 76 ++++- tests/data/Writer/XLSX/ArrayFunctions2.json | 1 + 6 files changed, 411 insertions(+), 21 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctions2Test.php create mode 100644 tests/data/Writer/XLSX/ArrayFunctions2.json diff --git a/src/PhpSpreadsheet/Calculation/LookupRef/Unique.php b/src/PhpSpreadsheet/Calculation/LookupRef/Unique.php index 220be2d131..103520ed27 100644 --- a/src/PhpSpreadsheet/Calculation/LookupRef/Unique.php +++ b/src/PhpSpreadsheet/Calculation/LookupRef/Unique.php @@ -40,7 +40,17 @@ private static function uniqueByRow(array $lookupVector, bool $exactlyOnce): mix array_walk( $lookupVector, function (array &$value): void { - $value = implode(chr(0x00), $value); + $valuex = ''; + $separator = ''; + $numericIndicator = "\x01"; + foreach ($value as $cellValue) { + $valuex .= $separator . $cellValue; + $separator = "\x00"; + if (is_int($cellValue) || is_float($cellValue)) { + $valuex .= $numericIndicator; + } + } + $value = $valuex; } ); @@ -60,7 +70,14 @@ function (array &$value): void { array_walk( $result, function (string &$value): void { - $value = explode(chr(0x00), $value); + $value = explode("\x00", $value); + foreach ($value as &$stringValue) { + if (str_ends_with($stringValue, "\x01")) { + // x01 should only end a string which is otherwise a float or int, + // so phpstan is technically correct but what it fears should not happen. + $stringValue = 0 + substr($stringValue, 0, -1); //@phpstan-ignore-line + } + } } ); diff --git a/src/PhpSpreadsheet/Cell/Cell.php b/src/PhpSpreadsheet/Cell/Cell.php index 2d225ad642..ffc8641082 100644 --- a/src/PhpSpreadsheet/Cell/Cell.php +++ b/src/PhpSpreadsheet/Cell/Cell.php @@ -390,6 +390,22 @@ public function getCalculatedValue(bool $resetLog = true): mixed } } } + if (is_array($result)) { + $newRow = $row = $this->getRow(); + $column = $this->getColumn(); + foreach ($result as $resultRow) { + $newColumn = $column; + $resultRowx = is_array($resultRow) ? $resultRow : [$resultRow]; + foreach ($resultRowx as $resultValue) { + if ($row !== $newRow || $column !== $newColumn) { + $this->getWorksheet()->getCell($newColumn . $newRow)->setValue($resultValue); + } + ++$newColumn; + } + ++$newRow; + } + $this->getWorksheet()->getCell($column . $row); + } } catch (SpreadsheetException $ex) { if (($ex->getMessage() === 'Unable to access External Workbook') && ($this->calculatedValue !== null)) { return $this->calculatedValue; // Fallback for calculations referencing external files. diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/UniqueTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/UniqueTest.php index 718fe2b8b0..c7996ea2a6 100644 --- a/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/UniqueTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/LookupRef/UniqueTest.php @@ -16,7 +16,7 @@ class UniqueTest extends TestCase public function testUnique(array $expectedResult, array $lookupRef, bool $byColumn = false, bool $exactlyOnce = false): void { $result = LookupRef\Unique::unique($lookupRef, $byColumn, $exactlyOnce); - self::assertEquals($expectedResult, $result); + self::assertSame($expectedResult, $result); } public function testUniqueException(): void @@ -36,10 +36,10 @@ public function testUniqueException(): void ]; $result = LookupRef\Unique::unique($rowLookupData, false, true); - self::assertEquals(ExcelError::CALC(), $result); + self::assertSame(ExcelError::CALC(), $result); $result = LookupRef\Unique::unique($columnLookupData, true, true); - self::assertEquals(ExcelError::CALC(), $result); + self::assertSame(ExcelError::CALC(), $result); } public function testUniqueWithScalar(): void @@ -145,13 +145,16 @@ public static function uniqueTestProvider(): array ], ], [ - [[1.2], [2.1], [2.2], [3.0]], + [[1.2], [2.1], [2.2], [3], ['3'], [8.7]], [ [1.2], [1.2], [2.1], [2.2], [3.0], + [3], + ['3'], + [8.7], ], ], ]; diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctions2Test.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctions2Test.php new file mode 100644 index 0000000000..719215846e --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctions2Test.php @@ -0,0 +1,307 @@ + [ + 'size' => 14, + ], + ]; + private const STYLEBOLD = [ + 'font' => [ + 'bold' => true, + ], + ]; + private const STYLEBOLD14 = [ + 'font' => [ + 'bold' => true, + 'size' => 14, + ], + ]; + private const STYLECENTER = [ + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_CENTER, + ], + ]; + + private const STYLETHICKBORDER = [ + 'borders' => [ + 'outline' => [ + 'borderStyle' => Border::BORDER_THICK, + 'color' => ['argb' => '00000000'], + ], + ], + ]; + + private array $trn; + + private string $arrayReturnType; + + private string $outputFile = ''; + + protected function setUp(): void + { + $this->arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + Calculation::setArrayReturnType($this->arrayReturnType); + if ($this->outputFile !== '') { + unlink($this->outputFile); + $this->outputFile = ''; + } + } + + private function odd(int $i): bool + { + return ($i % 2) === 1; + } + + private function doPartijen(Worksheet $ws): int + { + $saring = explode("\n", $this->trn['PARINGEN']); + $s = $this->trn['PLAYERSNOID']; + $g = $this->trn['RONDEGAMES']; + $KD = $this->trn['KALENDERDATA']; + $si = $this->trn['PLAYERSIDS']; + + $a = [ + ['Wit', null, null, null, 'Zwart', null, null, null, 'Wit', 'Uitslag', 'Zwart', 'Opmerking', 'Datum'], + ['Winstpunten', 'Weerstandspunten', 'punten', 'Tegenpunten', 'Winstpunten', 'Weerstandspunten', 'punten', 'Tegenpunten'], + + ]; + $ws->fromArray($a, null, 'A1'); + + $ws->getStyle('A1:L1')->applyFromArray(self::STYLEBOLD); + $ws->getStyle('A1:L1')->applyFromArray(self::STYLESIZE14); + + $lijn = 1; + for ($i = 1; $i <= $this->trn['RONDEN']; ++$i) {//aantal ronden oneven->heen en even->terug + for ($j = 0; $j < count($saring); ++$j) {//subronden + ++$lijn; + if (isset($KD[(($i - 1) * $this->trn['SUB_RONDEN']) + $j], $KD[(($i - 1) * $this->trn['SUB_RONDEN']) + $j]['RONDE'])) { + $ws->setCellValue([9, $lijn], $KD[(($i - 1) * $this->trn['SUB_RONDEN']) + $j]['RONDE']); + } else { + $ws->setCellValue([9, $lijn], 'Kalenderdata zijn niet(volledig) ingevuld'); + } + if (isset($KD[(($i - 1) * $this->trn['SUB_RONDEN']) + $j], $KD[(($i - 1) * $this->trn['SUB_RONDEN']) + $j]['TXT'])) { + $ws->setCellValue([10, $lijn], $KD[(($i - 1) * $this->trn['SUB_RONDEN']) + $j]['TXT']); + } else { + $ws->setCellValue([10, $lijn], 'Kalenderdata zijn niet(volledig) ingevuld'); + } + + $ws->getStyle('A' . $lijn . ':L' . $lijn . '')->applyFromArray(self::STYLEBOLD14); + + $s2 = explode(' ', $saring[$j]); + for ($k = 0; $k < count($s2); ++$k) {//borden + if (trim($s2[$k]) == '') { + continue; + } + $s3 = explode('-', $s2[$k]); //wit-zwart + $s3[0] = (int) $s3[0]; + $s3[1] = (int) $s3[1]; + ++$lijn; + $ws->setCellValue([1, $lijn], '=IF(SUBSTITUTE(TRIM($J' . $lijn . '),"-","")="","",XLOOKUP($K' . $lijn . ',Spelers!$B$2:$B$' . (count($s) + 1) . ',Spelers!$C$2:$C$' . (count($s) + 1) . ',FALSE) * VLOOKUP(J' . $lijn . ', PuntenLijst,4,FALSE))'); + $ws->setCellValue([2, $lijn], '=IF(SUBSTITUTE(TRIM($J' . $lijn . '),"-","")="","",VLOOKUP($K' . $lijn . ',Spelers!$B$2:$D$' . (count($s) + 1) . ',3,FALSE) * VLOOKUP(J' . $lijn . ', PuntenLijst,5,FALSE))'); + $ws->setCellValue([3, $lijn], '=IF(TRIM($J' . $lijn . ')="","",XLOOKUP($J' . $lijn . ', Punten!$A$2:$A$50,Punten!$B$2:$B$50,0,0))'); + $ws->setCellValue([4, $lijn], '=IF(TRIM($J' . $lijn . ')="","",XLOOKUP($J' . $lijn . ', Punten!$A$2:$A$50,Punten!$G$2:$G$50,0,0))'); + + $ws->setCellValue([5, $lijn], '=IF(SUBSTITUTE(TRIM($J' . $lijn . '),"-","")="","",VLOOKUP($I' . $lijn . ',Spelers!$B$2:$D$' . (count($s) + 1) . ',3,FALSE) * VLOOKUP(J' . $lijn . ', PuntenLijst,6,FALSE))'); + $ws->setCellValue([6, $lijn], '=IF(SUBSTITUTE(TRIM($J' . $lijn . '),"-","")="","",VLOOKUP($I' . $lijn . ',Spelers!$B$2:$D$' . (count($s) + 1) . ',3,FALSE) * VLOOKUP(J' . $lijn . ', PuntenLijst,5,FALSE))'); + $ws->setCellValue([7, $lijn], '=IF(TRIM($J' . $lijn . ')="","",XLOOKUP($J' . $lijn . ', Punten!$A$2:$A$50,Punten!$C$2:$C$50,0,0))'); + $ws->setCellValue([8, $lijn], '=IF(TRIM($J' . $lijn . ')="","",XLOOKUP($J' . $lijn . ', Punten!$A$2:$A$50,Punten!$H$2:$H$50,0,0))'); + + if ($this->odd($i)) { + if ( + isset($g[$i][$si[((int) $s3[0]) - 1]][$si[((int) $s3[1]) - 1]]) + ) { + $pw = $g[$i][$si[((int) $s3[0]) - 1]][$si[((int) $s3[1]) - 1]]; + } else { + $pw = ['SYMBOOLWIT' => '', 'SYMBOOLZWART' => '', 'UITSLAG' => '', 'OPMERKING' => '', 'DATUM' => '']; + } + $ws->setCellValue([9, $lijn], '=Spelers!$B$' . ($s3[0] + 1)); + $ws->setCellValue([11, $lijn], '=Spelers!$B$' . ($s3[1] + 1)); + } else { + if ( + isset($g[$i][$si[((int) $s3[1]) - 1]][$si[((int) $s3[0]) - 1]]) + ) { + $pw = $g[$i][$si[((int) $s3[1]) - 1]][$si[((int) $s3[0]) - 1]]; + } else { + $pw = ['SYMBOOLWIT' => '', 'SYMBOOLZWART' => '', 'UITSLAG' => '', 'OPMERKING' => '', 'DATUM' => '']; + } + $ws->setCellValue([9, $lijn], '=Spelers!$B$' . ($s3[1] + 1)); + $ws->setCellValue([11, $lijn], '=Spelers!$B$' . ($s3[0] + 1)); + } + if ($pw['SYMBOOLWIT'] != '') { + $ws->setCellValue([10, $lijn], $pw['SYMBOOLWIT'] . '-' . $pw['SYMBOOLZWART']); + } + $ws->setCellValue([13, $lijn], $pw['OPMERKING']); + $ws->setCellValue([14, $lijn], $pw['DATUM']); + + $this->doValidationPunten($ws, 'J' . $lijn); + + $ws->getRowDimension($lijn)->setOutlineLevel(1); + } + ++$lijn; + } + ++$lijn; + } + $ws->getStyle('J1:J' . $lijn)->applyFromArray(self::STYLECENTER); + + $ws->getColumnDimension('A')->setVisible(false); + $ws->getColumnDimension('B')->setVisible(false); + $ws->getColumnDimension('C')->setVisible(false); + $ws->getColumnDimension('D')->setVisible(false); + $ws->getColumnDimension('E')->setVisible(false); + $ws->getColumnDimension('F')->setVisible(false); + $ws->getColumnDimension('G')->setVisible(false); + $ws->getColumnDimension('H')->setVisible(false); + + for ($i = 65; $i < ord('M'); ++$i) { + $ws->getColumnDimension(chr($i))->setAutoSize(true); + } + $ws->setAutoFilter('A2:M' . $lijn); + $ws->setSelectedCell('A1'); + + return $lijn; + } + + private function doValidationPunten(Worksheet $s, string $cel): void + { + $validation = $s->getCell($cel)->getDataValidation(); + $validation->setType(DataValidation::TYPE_LIST); + $validation->setErrorStyle(DataValidation::STYLE_STOP); + $validation->setAllowBlank(false); + $validation->setShowInputMessage(false); + $validation->setShowErrorMessage(true); + $validation->setShowDropDown(true); + $validation->setFormula1('=punten'); + } + + private function doPunten(Spreadsheet $ss, Worksheet $ws): void + { + $ws->fromArray(['Uitslag', 'Wit', 'Zwart', 'Winstverhouding WIT', 'Weerstandverhouding', 'Winstverhouding ZWART', 'Tegenpunten Wit', 'Tegenpunten Zwart'], null, 'A1'); + $ws->fromArray(['0-3', '0', '3', '0', '1', '1', '0', '0'], null, 'A2'); + $ws->fromArray(['1-3', '1', '3', '0', '1', '1', '0', '-1'], null, 'A3'); + $ws->fromArray(['2-3', '2', '3', '0', '1', '1', '0', '-2'], null, 'A4'); + $ws->fromArray(['3-0', '3', '0', '1', '1', '0', '0', '0'], null, 'A5'); + $ws->fromArray(['3-1', '3', '1', '1', '1', '0', '-1', '0'], null, 'A6'); + $ws->fromArray(['3-2', '3', '2', '1', '1', '0', '-2', '0'], null, 'A7'); + $ws->fromArray(['U-U', '0', '0', '0', '0', '0', '0', '0'], null, 'A8'); + + $ss->addNamedRange(new NamedRange('Punten', $ws, '=$A$2:$A$8')); + $ss->addNamedRange(new NamedRange('PuntenLijst', $ws, '=$A$2:$H$8')); + + $ws->getStyle('A1:H1')->applyFromArray(self::STYLETHICKBORDER); + $ws->getStyle('A1:A8')->applyFromArray(self::STYLETHICKBORDER); + $ws->getStyle('B2:H8')->applyFromArray(self::STYLETHICKBORDER); + + for ($i = 65; $i < ord('I'); ++$i) { + $ws->getColumnDimension(chr($i))->setAutoSize(true); + } + $ws->setSelectedCell('A1'); + } + + private function doSpelers(Worksheet $ws, int $maxLijn): void + { + $this->doKoppen($ws); + + $i = 1; + foreach ($this->trn['SPELERS'] as $speler) { + $ws->setCellValue([1, ++$i], '=RANK(D' . $i . ',$D$2:$D$' . (count($this->trn['SPELERS']) + 1) . ',0)-1+COUNTIF($D$2:D' . $i . ',D' . $i . ')'); + $ws->setCellValue([2, $i], $speler); + $ws->setCellValue([3, $i], '=COUNTIFS(Partijen!$I$3:$I$' . $maxLijn . ',$B' . $i . ',Partijen!$J$3:$J$' . $maxLijn . ',"<>")+COUNTIFS(Partijen!$K$3:$K$' . $maxLijn . ',Spelers!$B' . $i . ',Partijen!$J$3:$J$' . $maxLijn . ',"<>")'); // - SUM(H' . $i . ':I' . $i . ') + $ws->setCellValue([4, $i], '=SUMIFS(Partijen!C$3:C$' . $maxLijn . ',Partijen!$I$3:$I$' . $maxLijn . ',$B' . $i . ')+SUMIFS(Partijen!G$3:G$' . $maxLijn . ',Partijen!$K$3:$K$' . $maxLijn . ',$B' . $i . ')'); + $ws->setCellValue([5, $i], '=SUMIFS(Partijen!D$3:D$' . $maxLijn . ',Partijen!$I$3:$I$' . $maxLijn . ',$B' . $i . ')+SUMIFS(Partijen!H$3:H$' . $maxLijn . ',Partijen!$K$3:$K$' . $maxLijn . ',$B' . $i . ')'); + $ws->setCellValue([6, $i], '=SUMIFS(Partijen!A$3:A$' . $maxLijn . ',Partijen!$I$3:$I$' . $maxLijn . ',$B' . $i . ')+SUMIFS(Partijen!E$3:E$' . $maxLijn . ',Partijen!$K$3:$K$' . $maxLijn . ',$B' . $i . ')'); + $ws->setCellValue([7, $i], '=SUMIFS(Partijen!B$3:B$' . $maxLijn . ',Partijen!$I$3:$I$' . $maxLijn . ',$B' . $i . ')+SUMIFS(Partijen!F$3:F$' . $maxLijn . ',Partijen!$K$3:$K$' . $maxLijn . ',$B' . $i . ')'); + $ws->setCellValue([8, $i], '=C' . $i . '*MAX(Punten!$B$2:$B$50)-D' . $i . ''); + } + $ws->setSelectedCell('A1'); + } + + private function doSort1(Worksheet $ws): void + { + $ws->setCellValue('A2', '=FILTER(SORTBY(Spelers!A2:H101,Spelers!D2:D101,-1,Spelers!E2:E101,1),(Spelers!B2:B101<>"Bye")*(NOT(ISBLANK(Spelers!B2:B101))))'); + $this->doKoppen($ws); + $ws->setSelectedCell('A1'); + } + + private function doSort2(Worksheet $ws): void + { + $ws->setCellValue('A2', '=SORT(FILTER(Spelers!A2:H101,(Spelers!B2:B101<>"Bye") * (NOT(ISBLANK(Spelers!B2:B101)))),7,1,FALSE)'); + $this->doKoppen($ws); + $ws->setSelectedCell('A1'); + } + + private function doKoppen(Worksheet $ws): void + { + $ws->setCellValue('A1', 'Plaats'); + $ws->setCellValue('B1', 'Naam'); + $ws->setCellValue('C1', 'Partijen'); + $ws->setCellValue('D1', 'Punten'); + $ws->setCellValue('E1', 'Tegenpunten'); + $ws->setCellValue('F1', 'Winstpunten'); + $ws->setCellValue('G1', 'Weerstandspunten'); + $ws->setCellValue('H1', 'Verlorenpunten'); + + for ($i = 65; $i < ord('I'); ++$i) { + $ws->getColumnDimension(chr($i))->setAutoSize(true); + } + + $ws->getStyle('A1:H1')->applyFromArray(self::STYLEBOLD); + } + + public function testManyArraysOutput(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $json = file_get_contents('tests/data/Writer/Xlsx/ArrayFunctions2.json'); + self::assertNotFalse($json); + $this->trn = json_decode($json, true); + $spreadsheet = new Spreadsheet(); + + $wsPartijen = $spreadsheet->getActiveSheet(); + $wsPartijen->setTitle('Partijen'); + $wsPunten = new Worksheet($spreadsheet, 'Punten'); + $wsSpelers = new Worksheet($spreadsheet, 'Spelers'); + $wsSort1 = new Worksheet($spreadsheet, 'Gesorteerd punten'); + $wsSort2 = new Worksheet($spreadsheet, 'Gesorteerd verlorenpunten'); + + $wsPartijen->getTabColor()->setRGB('FF0000'); + $wsPunten->getTabColor()->setRGB('00FF00'); + $wsSpelers->getTabColor()->setRGB('0000FF'); + $wsSort1->getTabColor()->setRGB('FFFF00'); + $wsSort2->getTabColor()->setRGB('00FFFF'); + + foreach ([$wsPunten, $wsSpelers, $wsSort1, $wsSort2] as $ws) { + $spreadsheet->addSheet($ws); + } + + $this->doPunten($spreadsheet, $wsPunten); + $maxLijn = $this->doPartijen($wsPartijen); + $this->doSpelers($wsSpelers, $maxLijn); + $this->doSort1($wsSort1); + $this->doSort2($wsSort2); + + self::assertSame('Dirk', $wsPartijen->getCell('I3')->getCalculatedValue()); + self::assertSame('Rudy', $wsPartijen->getCell('K4')->getCalculatedValue()); + $calcArray = $wsSort2->getCell('A2')->getCalculatedValue(); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php index fc26beb544..984528f3e7 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php @@ -69,24 +69,27 @@ public function testArrayOutput(): void $spreadsheet2 = $reader->load($this->outputFile); $sheet2 = $spreadsheet2->getActiveSheet(); $expectedUnique = [ - ['41'], - ['57'], - ['51'], - ['54'], - ['49'], - ['43'], - ['35'], - ['44'], - ['47'], - ['48'], - ['26'], - ['34'], - ['61'], - ['28'], - ['29'], + [41], + [57], + [51], + [54], + [49], + [43], + [35], + [44], + [47], + [48], + [26], + [34], + [61], + [28], + [29], ]; self::assertCount(15, $expectedUnique); self::assertSame($expectedUnique, $sheet2->getCell('C1')->getCalculatedValue()); + for ($row = 2; $row <= 15; ++$row) { + self::assertSame($expectedUnique[$row - 1][0], $sheet2->getCell("C$row")->getCalculatedValue(), "cell C$row"); + } $expectedSort = [ [26], [28], @@ -160,4 +163,47 @@ public function testUnimplementedArrayOutput(): void self::assertStringContainsString('_xlfn.CHOOSECOLS(A1:C5,3,1)11', $data); } } + + public function testArrayMultipleColumns(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $columnArray = [ + [100, 91], + [85, 1], + [100, 92], + [734, 12], + [100, 91], + [5,2], + ]; + $sheet->fromArray($columnArray); + $sheet->setCellValue('H1', '=UNIQUE(A1:B6)'); + $writer = new XlsxWriter($spreadsheet); + $this->outputFile = File::temporaryFilename(); + $writer->save($this->outputFile); + $spreadsheet->disconnectWorksheets(); + + $reader = new XlsxReader(); + $spreadsheet2 = $reader->load($this->outputFile); + $sheet2 = $spreadsheet2->getActiveSheet(); + $expectedUnique = [ + [100, 91], + [85, 1], + [100, 92], + [734, 12], + //[100, 91], // not unique + [5,2], + ]; + self::assertCount(5, $expectedUnique); + self::assertSame($expectedUnique, $sheet2->getCell('H1')->getCalculatedValue()); + for ($row = 1; $row <= 5; ++$row) { + if ($row > 1) { + self::assertSame($expectedUnique[$row - 1][0], $sheet2->getCell("H$row")->getCalculatedValue(), "cell H$row"); + } + self::assertSame($expectedUnique[$row - 1][1], $sheet2->getCell("I$row")->getCalculatedValue(), "cell I$row"); + } + $spreadsheet2->disconnectWorksheets(); + } + } diff --git a/tests/data/Writer/XLSX/ArrayFunctions2.json b/tests/data/Writer/XLSX/ArrayFunctions2.json new file mode 100644 index 0000000000..7f3e12a68d --- /dev/null +++ b/tests/data/Writer/XLSX/ArrayFunctions2.json @@ -0,0 +1 @@ +{"MAXIMUMPUNTEN":"3","ID":"65","CLUBID":"3","CLUBNAAM":"WSV","NAAM":"Blitz 23-24","REFJAAR":"2023","PARINGEN":"1-5 2-6 3-7 4-8\n1-6 2-5 8-7 4-3\n3-1 8-2 4-6 7-5\n1-7 2-4 6-8 5-3\n4-1 7-2 6-3 5-8\n8-1 3-2 7-6 5-4\n8-3 7-4 6-5 1-2","OMSCHRIJVING":"","RONDEN":1,"ALTERNATIEVE_PARING":"1","SUB_RONDEN":7,"SPEELRONDEN":7,"SPEELDAG":"5","READONLY":"0","ICS":"0","VERBORGEN":"0","TOONWINSTP":"0","AANTALSPELERS":"8","AANTALSPELERSNONBYE":"8","PARTIJEN":[],"AANTALPARTIJEN":"21","AANTALPARTIJENMETUITSLAG":"21","TOONFIDE":"0","KAMPIOENEN":[],"START":"20:00","EINDE":"23:00","TOONVANAF":"2023-11-01","TOONTOT":"9999-12-31","AFGEBROKENPARTIJEN":"0","ONBEKENDEPARTIJEN":"0","UITGESTELDEPARTIJEN":"0","MAXAANTALPARTIJEN":28,"MAXAANTALPARTIJENPERSPELER":7,"KALENDERDATA":[{"DATUM":"2023-11-10","TXT":"10\/11\/2023","SHORT":"10\/11","RONDE":"1 \u00b0 ronde","OPMERKING":null},{"DATUM":"2023-12-08","TXT":"08\/12\/2023","SHORT":"08\/12","RONDE":"2 \u00b0 ronde","OPMERKING":null},{"DATUM":"2024-01-12","TXT":"12\/01\/2024","SHORT":"12\/01","RONDE":"3 \u00b0 ronde","OPMERKING":null},{"DATUM":"2024-02-09","TXT":"09\/02\/2024","SHORT":"09\/02","RONDE":"4 \u00b0 ronde","OPMERKING":null},{"DATUM":"2024-03-08","TXT":"08\/03\/2024","SHORT":"08\/03","RONDE":"5 \u00b0 ronde","OPMERKING":null},{"DATUM":"2024-04-12","TXT":"12\/04\/2024","SHORT":"12\/04","RONDE":"6 \u00b0 ronde","OPMERKING":null},{"DATUM":"2024-05-10","TXT":"10\/05\/2024","SHORT":"10\/05","RONDE":"7 \u00b0 ronde","OPMERKING":null}],"VOLGENDERONDEDATA":[],"VOLGENDERONDEPARTIJEN":[{"ronde":"129","wit":"873","zwart":"872","opmerking":""},{"ronde":"129","wit":"875","zwart":"870","opmerking":""},{"ronde":"129","wit":"868","zwart":"869","opmerking":""},{"ronde":"129","wit":"874","zwart":"871","opmerking":""},{"ronde":"128","wit":"872","zwart":"871","opmerking":""},{"ronde":"128","wit":"875","zwart":"868","opmerking":""},{"ronde":"128","wit":"874","zwart":"873","opmerking":""}],"SPLITPUNTEN":[""],"PLAYERS":{"868":{"id":"868","naam":"Dirk","VOORNAAM":"Dirk","FAMILIENAAM":"Bockaert","stamnummer":"0","elo":"0","opmerking":"Kampioen 2022-2023","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"3","zwartaantalPartijen":"2","witgewonnen":"2","zwartGewonnen":"0","witremise":"0","zwartremise":"0","witVerloren":"1","zwartVerloren":"2","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"3","zwartgespeeld":"2","puntjes":null,"ledenID":null,"puntenWit":"7","puntenZwart":"1","tegenpuntenWit":"0","tegenpuntenZwart":"0"},"869":{"id":"869","naam":"Casper","VOORNAAM":"Casper","FAMILIENAAM":"De Naeyer","stamnummer":"","elo":"0","opmerking":"","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"3","zwartaantalPartijen":"3","witgewonnen":"2","zwartGewonnen":"0","witremise":"0","zwartremise":"0","witVerloren":"1","zwartVerloren":"3","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"3","zwartgespeeld":"3","puntjes":null,"ledenID":null,"puntenWit":"6","puntenZwart":"1","tegenpuntenWit":"-4","tegenpuntenZwart":"0"},"870":{"id":"870","naam":"Jan","VOORNAAM":"Jan","FAMILIENAAM":"Remue","stamnummer":"13718","elo":"0","opmerking":"","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"3","zwartaantalPartijen":"3","witgewonnen":"3","zwartGewonnen":"3","witremise":"0","zwartremise":"0","witVerloren":"0","zwartVerloren":"0","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"3","zwartgespeeld":"3","puntjes":null,"ledenID":null,"puntenWit":"9","puntenZwart":"9","tegenpuntenWit":"0","tegenpuntenZwart":"0"},"871":{"id":"871","naam":"Liam","VOORNAAM":"Liam","FAMILIENAAM":"Valck","stamnummer":"","elo":"0","opmerking":"","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"4","zwartaantalPartijen":"1","witgewonnen":"2","zwartGewonnen":"0","witremise":"0","zwartremise":"0","witVerloren":"2","zwartVerloren":"1","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"4","zwartgespeeld":"1","puntjes":null,"ledenID":null,"puntenWit":"8","puntenZwart":"2","tegenpuntenWit":"-2","tegenpuntenZwart":"0"},"872":{"id":"872","naam":"Wouter","VOORNAAM":"Wouter","FAMILIENAAM":"De Smet","stamnummer":"","elo":"0","opmerking":"","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"2","zwartaantalPartijen":"3","witgewonnen":"1","zwartGewonnen":"3","witremise":"0","zwartremise":"0","witVerloren":"1","zwartVerloren":"0","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"2","zwartgespeeld":"3","puntjes":null,"ledenID":null,"puntenWit":"3","puntenZwart":"9","tegenpuntenWit":"-2","tegenpuntenZwart":"-2"},"873":{"id":"873","naam":"Rudy","VOORNAAM":"Rudy","FAMILIENAAM":"Maes","stamnummer":"","elo":"0","opmerking":"","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"2","zwartaantalPartijen":"3","witgewonnen":"0","zwartGewonnen":"0","witremise":"0","zwartremise":"0","witVerloren":"2","zwartVerloren":"3","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"2","zwartgespeeld":"3","puntjes":null,"ledenID":null,"puntenWit":"0","puntenZwart":"3","tegenpuntenWit":"0","tegenpuntenZwart":"0"},"874":{"id":"874","naam":"Bob","VOORNAAM":"Bob","FAMILIENAAM":"Thys","stamnummer":"","elo":"0","opmerking":"","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"2","zwartaantalPartijen":"3","witgewonnen":"1","zwartGewonnen":"1","witremise":"0","zwartremise":"0","witVerloren":"1","zwartVerloren":"2","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"2","zwartgespeeld":"3","puntjes":null,"ledenID":null,"puntenWit":"4","puntenZwart":"3","tegenpuntenWit":"0","tegenpuntenZwart":"0"},"875":{"id":"875","naam":"Gilles","VOORNAAM":"Gilles","FAMILIENAAM":"Bovijn","stamnummer":"","elo":"0","opmerking":"","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"2","zwartaantalPartijen":"3","witgewonnen":"1","zwartGewonnen":"2","witremise":"0","zwartremise":"0","witVerloren":"1","zwartVerloren":"1","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"2","zwartgespeeld":"3","puntjes":null,"ledenID":null,"puntenWit":"3","puntenZwart":"8","tegenpuntenWit":"-1","tegenpuntenZwart":"-2"}},"PLAYERSIDS":["868","869","870","871","872","873","874","875"],"PLAYERSNOID":[{"id":"868","naam":"Dirk","VOORNAAM":"Dirk","FAMILIENAAM":"Bockaert","stamnummer":"0","elo":"0","opmerking":"Kampioen 2022-2023","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"3","zwartaantalPartijen":"2","witgewonnen":"2","zwartGewonnen":"0","witremise":"0","zwartremise":"0","witVerloren":"1","zwartVerloren":"2","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"3","zwartgespeeld":"2","puntjes":null,"ledenID":null,"puntenWit":"7","puntenZwart":"1","tegenpuntenWit":"0","tegenpuntenZwart":"0"},{"id":"869","naam":"Casper","VOORNAAM":"Casper","FAMILIENAAM":"De Naeyer","stamnummer":"","elo":"0","opmerking":"","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"3","zwartaantalPartijen":"3","witgewonnen":"2","zwartGewonnen":"0","witremise":"0","zwartremise":"0","witVerloren":"1","zwartVerloren":"3","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"3","zwartgespeeld":"3","puntjes":null,"ledenID":null,"puntenWit":"6","puntenZwart":"1","tegenpuntenWit":"-4","tegenpuntenZwart":"0"},{"id":"870","naam":"Jan","VOORNAAM":"Jan","FAMILIENAAM":"Remue","stamnummer":"13718","elo":"0","opmerking":"","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"3","zwartaantalPartijen":"3","witgewonnen":"3","zwartGewonnen":"3","witremise":"0","zwartremise":"0","witVerloren":"0","zwartVerloren":"0","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"3","zwartgespeeld":"3","puntjes":null,"ledenID":null,"puntenWit":"9","puntenZwart":"9","tegenpuntenWit":"0","tegenpuntenZwart":"0"},{"id":"871","naam":"Liam","VOORNAAM":"Liam","FAMILIENAAM":"Valck","stamnummer":"","elo":"0","opmerking":"","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"4","zwartaantalPartijen":"1","witgewonnen":"2","zwartGewonnen":"0","witremise":"0","zwartremise":"0","witVerloren":"2","zwartVerloren":"1","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"4","zwartgespeeld":"1","puntjes":null,"ledenID":null,"puntenWit":"8","puntenZwart":"2","tegenpuntenWit":"-2","tegenpuntenZwart":"0"},{"id":"872","naam":"Wouter","VOORNAAM":"Wouter","FAMILIENAAM":"De Smet","stamnummer":"","elo":"0","opmerking":"","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"2","zwartaantalPartijen":"3","witgewonnen":"1","zwartGewonnen":"3","witremise":"0","zwartremise":"0","witVerloren":"1","zwartVerloren":"0","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"2","zwartgespeeld":"3","puntjes":null,"ledenID":null,"puntenWit":"3","puntenZwart":"9","tegenpuntenWit":"-2","tegenpuntenZwart":"-2"},{"id":"873","naam":"Rudy","VOORNAAM":"Rudy","FAMILIENAAM":"Maes","stamnummer":"","elo":"0","opmerking":"","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"2","zwartaantalPartijen":"3","witgewonnen":"0","zwartGewonnen":"0","witremise":"0","zwartremise":"0","witVerloren":"2","zwartVerloren":"3","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"2","zwartgespeeld":"3","puntjes":null,"ledenID":null,"puntenWit":"0","puntenZwart":"3","tegenpuntenWit":"0","tegenpuntenZwart":"0"},{"id":"874","naam":"Bob","VOORNAAM":"Bob","FAMILIENAAM":"Thys","stamnummer":"","elo":"0","opmerking":"","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"2","zwartaantalPartijen":"3","witgewonnen":"1","zwartGewonnen":"1","witremise":"0","zwartremise":"0","witVerloren":"1","zwartVerloren":"2","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"2","zwartgespeeld":"3","puntjes":null,"ledenID":null,"puntenWit":"4","puntenZwart":"3","tegenpuntenWit":"0","tegenpuntenZwart":"0"},{"id":"875","naam":"Gilles","VOORNAAM":"Gilles","FAMILIENAAM":"Bovijn","stamnummer":"","elo":"0","opmerking":"","GrafOrder":"0","weerstandspunten":0,"winstpunten":0,"voorsprong_punten":"0","voorsprong_weerstandspunten":"0","voorsprong_winstpunten":"0","witaantalPartijen":"2","zwartaantalPartijen":"3","witgewonnen":"1","zwartGewonnen":"2","witremise":"0","zwartremise":"0","witVerloren":"1","zwartVerloren":"1","witGeeftForfait":"0","zwartGeeftForfait":"0","witKrijgtForfait":"0","zwartKrijgtForfait":"0","witafgebroken":"0","zwartafgebroken":"0","witUitgesteld":"0","zwartUitgesteld":"0","witonbekend":"0","zwartonbekend":"0","witextra":"0","zwartextra":"0","witgespeeld":"2","zwartgespeeld":"3","puntjes":null,"ledenID":null,"puntenWit":"3","puntenZwart":"8","tegenpuntenWit":"-1","tegenpuntenZwart":"-2"}],"SPELERS":{"868":"Dirk","869":"Casper","870":"Jan","871":"Liam","872":"Wouter","873":"Rudy","874":"Bob","875":"Gilles"},"SPELERSNOID":[],"NEXTROUNDS":[{"id":"128","id_toernooi":"65","dtm":"12\/04\/2024","naam":"6 \u00b0 ronde","opmerking":"","datum":"2024-04-12","pinkinterval":"0","forecolor":"#000000","backcolor":"#00FFFF","showbyesFromGames":"0","tonen":"2","van":"2024-01-24","OPMERKING":null,"IMAGE":null,"aantalpartijen":"3","vanverleden":"1"},{"id":"136","id_toernooi":"65","dtm":"26\/04\/2024","naam":"inhaal 4","opmerking":"","datum":"2024-04-26","pinkinterval":"0","forecolor":"#000000","backcolor":"#0000ff","showbyesFromGames":"2","tonen":"2","van":"2024-01-24","OPMERKING":"","IMAGE":null,"aantalpartijen":"0","vanverleden":"1"},{"id":"129","id_toernooi":"65","dtm":"10\/05\/2024","naam":"7 \u00b0 ronde","opmerking":"","datum":"2024-05-10","pinkinterval":"0","forecolor":"#000000","backcolor":"#00FFFF","showbyesFromGames":"0","tonen":"2","van":"2024-01-24","OPMERKING":null,"IMAGE":null,"aantalpartijen":"4","vanverleden":"1"},{"id":"151","id_toernooi":"65","dtm":"24\/05\/2024","naam":"inhaal 5","opmerking":"","datum":"2024-05-24","pinkinterval":"0","forecolor":"#000000","backcolor":"#e0ffff","showbyesFromGames":"2","tonen":"2","van":"2024-01-24","OPMERKING":"","IMAGE":null,"aantalpartijen":"0","vanverleden":"1"},{"id":"148","id_toernooi":"65","dtm":"14\/06\/2024","naam":"inhaal 6","opmerking":"","datum":"2024-06-14","pinkinterval":"0","forecolor":"#000000","backcolor":"#e0ffff","showbyesFromGames":"2","tonen":"2","van":"2024-01-24","OPMERKING":"","IMAGE":null,"aantalpartijen":"0","vanverleden":"1"}],"GAMES":{"868":{"872":{"ID":"5164","WIT":"Dirk","ZWART":"Wouter","UITSLAG":"36","DATUM":"2023-12-22","UITSTELLER":"0","METHODE":"kalender","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-02-02 21:21:02","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"1","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":1},"873":{"ID":"5168","WIT":"Dirk","ZWART":"Rudy","UITSLAG":"38","DATUM":"2023-12-08","UITSTELLER":"0","METHODE":"spelersuitslagen","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-04-05 20:59:30","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"0","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":2},"874":{"ID":"5214","WIT":"Dirk","ZWART":"Bob","UITSLAG":"38","DATUM":"2024-02-09","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-02-09 21:09:56","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"0","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":4}},"869":{"871":{"ID":"5218","WIT":"Casper","ZWART":"Liam","UITSLAG":"40","DATUM":"2024-03-08","UITSTELLER":"2","METHODE":"volgenderonden","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-03-08 21:14:36","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"2","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":4},"872":{"ID":"5167","WIT":"Casper","ZWART":"Wouter","UITSLAG":"35","DATUM":"2023-12-08","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:54","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"0","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":2},"873":{"ID":"5163","WIT":"Casper","ZWART":"Rudy","UITSLAG":"40","DATUM":"2023-12-29","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:53","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"2","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":1}},"870":{"868":{"ID":"5173","WIT":"Jan","ZWART":"Dirk","UITSLAG":"38","DATUM":"2024-01-12","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:55","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"0","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":3},"869":{"ID":"5175","WIT":"Jan","ZWART":"Casper","UITSLAG":"38","DATUM":"2023-12-08","UITSTELLER":"0","METHODE":"volgenderonden","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:30:25","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"0","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":6},"874":{"ID":"5165","WIT":"Jan","ZWART":"Bob","UITSLAG":"38","DATUM":"2023-11-17","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:54","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"0","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":1}},"871":{"868":{"ID":"5265","WIT":"Liam","ZWART":"Dirk","UITSLAG":"39","DATUM":"2024-03-15","UITSTELLER":"2","METHODE":"volgenderondenactievetoernooien","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-03-15 21:30:00","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"1","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":5},"870":{"ID":"5170","WIT":"Liam","ZWART":"Jan","UITSLAG":"35","DATUM":"2023-12-08","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:55","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"0","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":2},"873":{"ID":"5174","WIT":"Liam","ZWART":"Rudy","UITSLAG":"39","DATUM":"2024-01-12","UITSTELLER":"0","METHODE":"kruistabel","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-03-01 20:23:11","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"1","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":3},"875":{"ID":"5166","WIT":"Liam","ZWART":"Gilles","UITSLAG":"37","DATUM":"2023-11-17","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:54","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"2","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":1}},"872":{"870":{"ID":"5216","WIT":"Wouter","ZWART":"Jan","UITSLAG":"35","DATUM":"2024-02-09","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-02-09 21:09:57","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"0","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":4},"875":{"ID":"5270","WIT":"Wouter","ZWART":"Gilles","UITSLAG":"40","DATUM":"2024-03-08","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-03-08 21:42:25","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"2","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":5}},"873":{"870":{"ID":"5269","WIT":"Rudy","ZWART":"Jan","UITSLAG":"35","DATUM":"2024-03-08","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-03-08 21:08:28","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"0","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":5},"875":{"ID":"5215","WIT":"Rudy","ZWART":"Gilles","UITSLAG":"35","DATUM":"2024-02-09","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-02-09 21:09:56","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"0","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":4}},"874":{"869":{"ID":"5271","WIT":"Bob","ZWART":"Casper","UITSLAG":"38","DATUM":"2024-03-08","UITSTELLER":"0","METHODE":"kalender","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-03-08 21:53:48","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"0","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":5},"872":{"ID":"5171","WIT":"Bob","ZWART":"Wouter","UITSLAG":"36","DATUM":"2024-03-01","UITSTELLER":"0","METHODE":"kruistabel","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-03-01 20:27:04","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"1","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":3}},"875":{"869":{"ID":"5172","WIT":"Gilles","ZWART":"Casper","UITSLAG":"39","DATUM":"2024-01-12","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:55","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"1","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":3},"874":{"ID":"5169","WIT":"Gilles","ZWART":"Bob","UITSLAG":"35","DATUM":"2023-12-08","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:54","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"0","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":2}}},"RONDEGAMES":{"1":{"868":{"872":{"ID":"5164","WIT":"Dirk","ZWART":"Wouter","UITSLAG":"36","DATUM":"2023-12-22","UITSTELLER":"0","METHODE":"kalender","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-02-02 21:21:02","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"1","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":1},"873":{"ID":"5168","WIT":"Dirk","ZWART":"Rudy","UITSLAG":"38","DATUM":"2023-12-08","UITSTELLER":"0","METHODE":"spelersuitslagen","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-04-05 20:59:30","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"0","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":2},"874":{"ID":"5214","WIT":"Dirk","ZWART":"Bob","UITSLAG":"38","DATUM":"2024-02-09","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-02-09 21:09:56","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"0","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":4}},"869":{"871":{"ID":"5218","WIT":"Casper","ZWART":"Liam","UITSLAG":"40","DATUM":"2024-03-08","UITSTELLER":"2","METHODE":"volgenderonden","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-03-08 21:14:36","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"2","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":4},"872":{"ID":"5167","WIT":"Casper","ZWART":"Wouter","UITSLAG":"35","DATUM":"2023-12-08","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:54","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"0","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":2},"873":{"ID":"5163","WIT":"Casper","ZWART":"Rudy","UITSLAG":"40","DATUM":"2023-12-29","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:53","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"2","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":1}},"870":{"868":{"ID":"5173","WIT":"Jan","ZWART":"Dirk","UITSLAG":"38","DATUM":"2024-01-12","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:55","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"0","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":3},"869":{"ID":"5175","WIT":"Jan","ZWART":"Casper","UITSLAG":"38","DATUM":"2023-12-08","UITSTELLER":"0","METHODE":"volgenderonden","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:30:25","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"0","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":6},"874":{"ID":"5165","WIT":"Jan","ZWART":"Bob","UITSLAG":"38","DATUM":"2023-11-17","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:54","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"0","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":1}},"871":{"868":{"ID":"5265","WIT":"Liam","ZWART":"Dirk","UITSLAG":"39","DATUM":"2024-03-15","UITSTELLER":"2","METHODE":"volgenderondenactievetoernooien","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-03-15 21:30:00","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"1","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":5},"870":{"ID":"5170","WIT":"Liam","ZWART":"Jan","UITSLAG":"35","DATUM":"2023-12-08","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:55","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"0","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":2},"873":{"ID":"5174","WIT":"Liam","ZWART":"Rudy","UITSLAG":"39","DATUM":"2024-01-12","UITSTELLER":"0","METHODE":"kruistabel","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-03-01 20:23:11","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"1","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":3},"875":{"ID":"5166","WIT":"Liam","ZWART":"Gilles","UITSLAG":"37","DATUM":"2023-11-17","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:54","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"2","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":1}},"872":{"870":{"ID":"5216","WIT":"Wouter","ZWART":"Jan","UITSLAG":"35","DATUM":"2024-02-09","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-02-09 21:09:57","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"0","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":4},"875":{"ID":"5270","WIT":"Wouter","ZWART":"Gilles","UITSLAG":"40","DATUM":"2024-03-08","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-03-08 21:42:25","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"2","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":5}},"873":{"870":{"ID":"5269","WIT":"Rudy","ZWART":"Jan","UITSLAG":"35","DATUM":"2024-03-08","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-03-08 21:08:28","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"0","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":5},"875":{"ID":"5215","WIT":"Rudy","ZWART":"Gilles","UITSLAG":"35","DATUM":"2024-02-09","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-02-09 21:09:56","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"0","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":4}},"874":{"869":{"ID":"5271","WIT":"Bob","ZWART":"Casper","UITSLAG":"38","DATUM":"2024-03-08","UITSTELLER":"0","METHODE":"kalender","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-03-08 21:53:48","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"0","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":5},"872":{"ID":"5171","WIT":"Bob","ZWART":"Wouter","UITSLAG":"36","DATUM":"2024-03-01","UITSTELLER":"0","METHODE":"kruistabel","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-03-01 20:27:04","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"1","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":3}},"875":{"869":{"ID":"5172","WIT":"Gilles","ZWART":"Casper","UITSLAG":"39","DATUM":"2024-01-12","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:55","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren","SYMBOOLWIT":"3","SYMBOOLZWART":"1","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":3},"874":{"ID":"5169","WIT":"Gilles","ZWART":"Bob","UITSLAG":"35","DATUM":"2023-12-08","UITSTELLER":"0","METHODE":"gamesplayedNextgames","INGEBRACHTDOOR":"Jan","CHANGEDATE":"2024-01-24 11:28:54","OPMERKING":"","READONLY":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen","SYMBOOLWIT":"0","SYMBOOLZWART":"3","NOTATION":{"NOTATION":null,"DOOR":null,"DATUM":null,"IP":null},"COMMENT":{"COMMENT":null,"W_B":null,"DOOR":null,"DATUM":null,"IP":null},"RONDENR":"1","RONDE":2}}}},"GAMES_U":[],"GAMES_A":[],"GAMES_O":[],"GAMES_NG":[],"PUNTENSYSTEEM":{"35":{"ID":"35","SYSTEEM":"5","NAAM":"0-3","GROEP":"Gespeeld","GROEPVOLGNR":"1","PUNTVOLGNR":"1","SYMBOOLWIT":"0","SYMBOOLZWART":"3","PUNTENWIT":"0","PUNTENZWART":"3","UA_NODIG":"0","VOLGENDERONDE":"0","TEGENWIT":"0","TEGENZWART":"0","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen"},"36":{"ID":"36","SYSTEEM":"5","NAAM":"1-3","GROEP":"Gespeeld","GROEPVOLGNR":"1","PUNTVOLGNR":"2","SYMBOOLWIT":"1","SYMBOOLZWART":"3","PUNTENWIT":"1","PUNTENZWART":"3","UA_NODIG":"0","VOLGENDERONDE":"0","TEGENWIT":"0","TEGENZWART":"-1","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen"},"37":{"ID":"37","SYSTEEM":"5","NAAM":"2-3","GROEP":"Gespeeld","GROEPVOLGNR":"1","PUNTVOLGNR":"3","SYMBOOLWIT":"2","SYMBOOLZWART":"3","PUNTENWIT":"2","PUNTENZWART":"3","UA_NODIG":"0","VOLGENDERONDE":"0","TEGENWIT":"0","TEGENZWART":"-2","CLASSE_WIT":"Verloren","CLASSE_ZWART":"Gewonnen"},"38":{"ID":"38","SYSTEEM":"5","NAAM":"3-0","GROEP":"Gespeeld","GROEPVOLGNR":"1","PUNTVOLGNR":"4","SYMBOOLWIT":"3","SYMBOOLZWART":"0","PUNTENWIT":"3","PUNTENZWART":"0","UA_NODIG":"0","VOLGENDERONDE":"0","TEGENWIT":"0","TEGENZWART":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren"},"39":{"ID":"39","SYSTEEM":"5","NAAM":"3-1","GROEP":"Gespeeld","GROEPVOLGNR":"1","PUNTVOLGNR":"5","SYMBOOLWIT":"3","SYMBOOLZWART":"1","PUNTENWIT":"3","PUNTENZWART":"1","UA_NODIG":"0","VOLGENDERONDE":"0","TEGENWIT":"-1","TEGENZWART":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren"},"40":{"ID":"40","SYSTEEM":"5","NAAM":"3-2","GROEP":"Gespeeld","GROEPVOLGNR":"1","PUNTVOLGNR":"6","SYMBOOLWIT":"3","SYMBOOLZWART":"2","PUNTENWIT":"3","PUNTENZWART":"2","UA_NODIG":"0","VOLGENDERONDE":"0","TEGENWIT":"-2","TEGENZWART":"0","CLASSE_WIT":"Gewonnen","CLASSE_ZWART":"Verloren"},"41":{"ID":"41","SYSTEEM":"5","NAAM":"Uitgesteld","GROEP":"Later","GROEPVOLGNR":"2","PUNTVOLGNR":"1","SYMBOOLWIT":"U","SYMBOOLZWART":"U","PUNTENWIT":"0","PUNTENZWART":"0","UA_NODIG":"1","VOLGENDERONDE":"1","TEGENWIT":"0","TEGENZWART":"0","CLASSE_WIT":"Uitgesteld","CLASSE_ZWART":"Uitgesteld"}},"PUNTENSYSTEEMID":"5","BACKGROUND":"#fef1a9"} From 856a00b8f09cc5c89aaa3f74f35e994062c34203 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 6 Jun 2024 06:55:33 -0700 Subject: [PATCH 05/31] Formatting --- tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php index 984528f3e7..51f6b74776 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php @@ -205,5 +205,4 @@ public function testArrayMultipleColumns(): void } $spreadsheet2->disconnectWorksheets(); } - } From 1b2198419e733efd30e808cb70fb95be0630c06b Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 6 Jun 2024 07:01:45 -0700 Subject: [PATCH 06/31] Incorrect Case for Filename --- tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctions2Test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctions2Test.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctions2Test.php index 719215846e..0eded54202 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctions2Test.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctions2Test.php @@ -271,7 +271,7 @@ private function doKoppen(Worksheet $ws): void public function testManyArraysOutput(): void { Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); - $json = file_get_contents('tests/data/Writer/Xlsx/ArrayFunctions2.json'); + $json = file_get_contents('tests/data/Writer/XLSX/ArrayFunctions2.json'); self::assertNotFalse($json); $this->trn = json_decode($json, true); $spreadsheet = new Spreadsheet(); From 47481c6a12329f63775a86cb716645b9a6051d87 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 6 Jun 2024 07:05:19 -0700 Subject: [PATCH 07/31] More Formatting Frustrating morning. --- tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php index 51f6b74776..788f7ec4be 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php @@ -193,7 +193,7 @@ public function testArrayMultipleColumns(): void [100, 92], [734, 12], //[100, 91], // not unique - [5,2], + [5, 2], ]; self::assertCount(5, $expectedUnique); self::assertSame($expectedUnique, $sheet2->getCell('H1')->getCalculatedValue()); From 6b5bf84fbb3a2f8f3cfab0548187b8c4218db3be Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 6 Jun 2024 07:09:50 -0700 Subject: [PATCH 08/31] Still More Formatting I think I should go back to bed. --- tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php index 788f7ec4be..e54b41bf19 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php @@ -175,7 +175,7 @@ public function testArrayMultipleColumns(): void [100, 92], [734, 12], [100, 91], - [5,2], + [5, 2], ]; $sheet->fromArray($columnArray); $sheet->setCellValue('H1', '=UNIQUE(A1:B6)'); From 3daac0a64082555baaef47b90d3bab6c279d39e2 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 6 Jun 2024 13:16:16 -0700 Subject: [PATCH 09/31] Add TODO Note ArrayFunctions2Test - the calculations seem too complicated for PhpSpreadsheet. The debug log is 21,300 lines, so I don't know how far I will get with it. --- .../Writer/Xlsx/ArrayFunctions2Test.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctions2Test.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctions2Test.php index 0eded54202..3d9ed8803f 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctions2Test.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctions2Test.php @@ -15,6 +15,8 @@ use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PHPUnit\Framework\TestCase; +// TODO - I think the spreadsheet below is too difficult for PhpSpreadsheet to calculate correctly. + class ArrayFunctions2Test extends TestCase { private const STYLESIZE14 = [ @@ -93,7 +95,8 @@ private function doPartijen(Worksheet $ws): int $lijn = 1; for ($i = 1; $i <= $this->trn['RONDEN']; ++$i) {//aantal ronden oneven->heen en even->terug - for ($j = 0; $j < count($saring); ++$j) {//subronden + $countSaring = count($saring); + for ($j = 0; $j < $countSaring; ++$j) {//subronden ++$lijn; if (isset($KD[(($i - 1) * $this->trn['SUB_RONDEN']) + $j], $KD[(($i - 1) * $this->trn['SUB_RONDEN']) + $j]['RONDE'])) { $ws->setCellValue([9, $lijn], $KD[(($i - 1) * $this->trn['SUB_RONDEN']) + $j]['RONDE']); @@ -109,7 +112,8 @@ private function doPartijen(Worksheet $ws): int $ws->getStyle('A' . $lijn . ':L' . $lijn . '')->applyFromArray(self::STYLEBOLD14); $s2 = explode(' ', $saring[$j]); - for ($k = 0; $k < count($s2); ++$k) {//borden + $counts2 = count($s2); + for ($k = 0; $k < $counts2; ++$k) {//borden if (trim($s2[$k]) == '') { continue; } @@ -302,6 +306,7 @@ public function testManyArraysOutput(): void self::assertSame('Dirk', $wsPartijen->getCell('I3')->getCalculatedValue()); self::assertSame('Rudy', $wsPartijen->getCell('K4')->getCalculatedValue()); $calcArray = $wsSort2->getCell('A2')->getCalculatedValue(); + self::assertCount(8, $calcArray); $spreadsheet->disconnectWorksheets(); } } From ef176f382e19a2e01288750a5b4a980b19771e0b Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Sun, 9 Jun 2024 20:48:32 -0700 Subject: [PATCH 10/31] Excel Handle Array Functions as Dynamic Rather than CSE With a number of changes, PhpSpreadsheet can finally generate a spreadsheet which Excel will recognize as a Dynamic Array function rather than CSE. In particular, changes are needed to ContentTypes, workbook.xml.rels, cell definitions in the worksheet, and a new metadata.xml is added. --- src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php | 4 + src/PhpSpreadsheet/Writer/Xlsx.php | 19 +++ .../Writer/Xlsx/ContentTypes.php | 5 + src/PhpSpreadsheet/Writer/Xlsx/Metadata.php | 83 +++++++++++ src/PhpSpreadsheet/Writer/Xlsx/Rels.php | 11 ++ src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 12 +- .../Writer/Xlsx/ArrayFunctionsTest.php | 134 ++++++++++++++++++ 7 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 src/PhpSpreadsheet/Writer/Xlsx/Metadata.php diff --git a/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php b/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php index fa3e57e7b0..114fbe8004 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php +++ b/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php @@ -82,6 +82,8 @@ class Namespaces const CONTENT_TYPES = 'http://schemas.openxmlformats.org/package/2006/content-types'; + const RELATIONSHIPS_METADATA = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sheetMetadata'; + const RELATIONSHIPS_PRINTER_SETTINGS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/printerSettings'; const RELATIONSHIPS_TABLE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table'; @@ -115,4 +117,6 @@ class Namespaces const PURL_CHART = 'http://purl.oclc.org/ooxml/drawingml/chart'; const PURL_WORKSHEET = 'http://purl.oclc.org/ooxml/officeDocument/relationships/worksheet'; + + const DYNAMIC_ARRAY = 'http://schemas.microsoft.com/office/spreadsheetml/2017/dynamicarray'; } diff --git a/src/PhpSpreadsheet/Writer/Xlsx.php b/src/PhpSpreadsheet/Writer/Xlsx.php index f38eaff393..0a50efd840 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx.php +++ b/src/PhpSpreadsheet/Writer/Xlsx.php @@ -136,6 +136,10 @@ class Xlsx extends BaseWriter private bool $explicitStyle0 = false; + private bool $useCSEArrays = false; + + private bool $useDynamicArray = false; + /** * Create a new Xlsx Writer. */ @@ -247,6 +251,7 @@ public function getWriterPartWorksheet(): Worksheet public function save($filename, int $flags = 0): void { $this->processFlags($flags); + $this->useDynamicArray = $this->preCalculateFormulas && Calculation::getInstance($this->spreadSheet)->getArrayReturnType() === Calculation::RETURN_ARRAY_AS_ARRAY && !$this->useCSEArrays; // garbage collect $this->pathNames = []; @@ -277,6 +282,10 @@ public function save($filename, int $flags = 0): void $zipContent = []; // Add [Content_Types].xml to ZIP file $zipContent['[Content_Types].xml'] = $this->getWriterPartContentTypes()->writeContentTypes($this->spreadSheet, $this->includeCharts); + if ($this->useDynamicArrays()) { + $writerPartMetadata = new Xlsx\Metadata($this); + $zipContent['xl/metadata.xml'] = $writerPartMetadata->writeMetadata(); + } //if hasMacros, add the vbaProject.bin file, Certificate file(if exists) if ($this->spreadSheet->hasMacros()) { @@ -711,4 +720,14 @@ public function setExplicitStyle0(bool $explicitStyle0): self return $this; } + + public function setUseCSEArrays(bool $useCSEArrays): void + { + $this->useCSEArrays = $useCSEArrays; + } + + public function useDynamicArrays(): bool + { + return $this->useDynamicArray; + } } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php b/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php index e3b9ba5eb8..dd46fc47f1 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php @@ -209,6 +209,11 @@ public function writeContentTypes(Spreadsheet $spreadsheet, bool $includeCharts } } + // Metadata needed for Dynamic Arrays + if ($this->getParentWriter()->useDynamicArrays()) { + $this->writeOverrideContentType($objWriter, '/xl/metadata.xml', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml'); + } + $objWriter->endElement(); // Return diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Metadata.php b/src/PhpSpreadsheet/Writer/Xlsx/Metadata.php new file mode 100644 index 0000000000..7f6d04bfa3 --- /dev/null +++ b/src/PhpSpreadsheet/Writer/Xlsx/Metadata.php @@ -0,0 +1,83 @@ +getParentWriter()->getUseDiskCaching()) { + $objWriter = new XMLWriter(XMLWriter::STORAGE_DISK, $this->getParentWriter()->getDiskCachingDirectory()); + } else { + $objWriter = new XMLWriter(XMLWriter::STORAGE_MEMORY); + } + + // XML header + $objWriter->startDocument('1.0', 'UTF-8', 'yes'); + + // Types + $objWriter->startElement('metadata'); + $objWriter->writeAttribute('xmlns', Namespaces::MAIN); + $objWriter->writeAttribute('xmlns:xda', Namespaces::DYNAMIC_ARRAY); + + $objWriter->startElement('metadataTypes'); + $objWriter->writeAttribute('count', '1'); + $objWriter->startElement('metadataType'); + $objWriter->writeAttribute('name', 'XLDAPR'); + $objWriter->writeAttribute('minSupportedVersion', '120000'); + $objWriter->writeAttribute('copy', '1'); + $objWriter->writeAttribute('pasteAll', '1'); + $objWriter->writeAttribute('pasteValues', '1'); + $objWriter->writeAttribute('merge', '1'); + $objWriter->writeAttribute('splitFirst', '1'); + $objWriter->writeAttribute('rowColShift', '1'); + $objWriter->writeAttribute('clearFormats', '1'); + $objWriter->writeAttribute('clearComments', '1'); + $objWriter->writeAttribute('assign', '1'); + $objWriter->writeAttribute('coerce', '1'); + $objWriter->writeAttribute('cellMeta', '1'); + $objWriter->endElement(); // metadataType + $objWriter->endElement(); // metadataTypes + + $objWriter->startElement('futureMetadata'); + $objWriter->writeAttribute('name', 'XLDAPR'); + $objWriter->writeAttribute('count', '1'); + $objWriter->startElement('bk'); + $objWriter->startElement('extLst'); + $objWriter->startElement('ext'); + $objWriter->writeAttribute('uri', '{bdbb8cdc-fa1e-496e-a857-3c3f30c029c3}'); + $objWriter->startElement('xda:dynamicArrayProperties'); + $objWriter->writeAttribute('fDynamic', '1'); + $objWriter->writeAttribute('fCollapsed', '0'); + $objWriter->endElement(); // xda:dynamicArrayProperties + $objWriter->endElement(); // ext + $objWriter->endElement(); // extLst + $objWriter->endElement(); // bk + $objWriter->endElement(); // futureMetadata + + $objWriter->startElement('cellMetadata'); + $objWriter->writeAttribute('count', '1'); + $objWriter->startElement('bk'); + $objWriter->startElement('rc'); + $objWriter->writeAttribute('t', '1'); + $objWriter->writeAttribute('v', '0'); + $objWriter->endElement(); // rc + $objWriter->endElement(); // bk + $objWriter->endElement(); // cellMetadata + + $objWriter->endElement(); // metadata + + // Return + return $objWriter->getData(); + } +} diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Rels.php b/src/PhpSpreadsheet/Writer/Xlsx/Rels.php index 1e6e3b493e..ceb78117c6 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Rels.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Rels.php @@ -151,6 +151,17 @@ public function writeWorkbookRelationships(Spreadsheet $spreadsheet): string ++$i; //increment i if needed for an another relation } + // Metadata needed for Dynamic Arrays + if ($this->getParentWriter()->useDynamicArrays()) { + $this->writeRelationShip( + $objWriter, + ($i + 1 + 3), + Namespaces::RELATIONSHIPS_METADATA, + 'metadata.xml' + ); + ++$i; //increment i if needed for an another relation + } + $objWriter->endElement(); return $objWriter->getData(); diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index 14d4b32538..53bcb02b85 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -1558,6 +1558,15 @@ private function writeCell(XMLWriter $objWriter, PhpspreadsheetWorksheet $worksh } $objWriter->startElement('c'); $objWriter->writeAttribute('r', $cellAddress); + $mappedType = $pCell->getDataType(); + if (strtolower($mappedType) === 'f') { + if ($this->getParentWriter()->useDynamicArrays()) { + $tempCalc = $pCell->getCalculatedValue(); + if (is_array($tempCalc)) { + $objWriter->writeAttribute('cm', '1'); + } + } + } // Sheet styles if ($xfi) { @@ -1568,9 +1577,6 @@ private function writeCell(XMLWriter $objWriter, PhpspreadsheetWorksheet $worksh // If cell value is supplied, write cell value if ($writeValue) { - // Map type - $mappedType = $pCell->getDataType(); - // Write data depending on its type switch (strtolower($mappedType)) { case 'inlinestr': // Inline string diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php index e54b41bf19..649d5fff7f 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php @@ -116,6 +116,122 @@ public function testArrayOutput(): void self::assertSame($expectedSort, $sheet2->getCell('D1')->getCalculatedValue()); $spreadsheet2->disconnectWorksheets(); + $file = 'zip://'; + $file .= $this->outputFile; + $file .= '#xl/worksheets/sheet1.xml'; + $data = file_get_contents($file); + if ($data === false) { + self::fail('Unable to read file'); + } else { + self::assertStringContainsString('_xlfn.UNIQUE(A1:A19)41', $data, '15 results for UNIQUE'); + self::assertStringContainsString('_xlfn._xlws.SORT(A1:A19)26', $data, '19 results for SORT'); + } + + $file = 'zip://'; + $file .= $this->outputFile; + $file .= '#xl/metadata.xml'; + $data = @file_get_contents($file); + self::assertNotFalse($data, 'metadata.xml should exist'); + + $file = 'zip://'; + $file .= $this->outputFile; + $file .= '#[Content_Types].xml'; + $data = file_get_contents($file); + self::assertStringContainsString('metadata', $data); + + $file = 'zip://'; + $file .= $this->outputFile; + $file .= '#xl/_rels/workbook.xml.rels'; + $data = file_get_contents($file); + self::assertStringContainsString('metadata', $data); + } + + public function testArrayOutputCSE(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $columnArray = [ + [41], + [57], + [51], + [54], + [49], + [43], + [35], + [35], + [44], + [47], + [48], + [26], + [57], + [34], + [61], + [34], + [28], + [29], + [41], + ]; + $sheet->fromArray($columnArray, 'A1'); + $sheet->setCellValue('C1', '=UNIQUE(A1:A19)'); + $sheet->setCellValue('D1', '=SORT(A1:A19)'); + $writer = new XlsxWriter($spreadsheet); + $this->outputFile = File::temporaryFilename(); + $writer->setUseCSEArrays(true); + $writer->save($this->outputFile); + $spreadsheet->disconnectWorksheets(); + + $reader = new XlsxReader(); + $spreadsheet2 = $reader->load($this->outputFile); + $sheet2 = $spreadsheet2->getActiveSheet(); + $expectedUnique = [ + [41], + [57], + [51], + [54], + [49], + [43], + [35], + [44], + [47], + [48], + [26], + [34], + [61], + [28], + [29], + ]; + self::assertCount(15, $expectedUnique); + self::assertSame($expectedUnique, $sheet2->getCell('C1')->getCalculatedValue()); + for ($row = 2; $row <= 15; ++$row) { + self::assertSame($expectedUnique[$row - 1][0], $sheet2->getCell("C$row")->getCalculatedValue(), "cell C$row"); + } + $expectedSort = [ + [26], + [28], + [29], + [34], + [34], + [35], + [35], + [41], + [41], + [43], + [44], + [47], + [48], + [49], + [51], + [54], + [57], + [57], + [61], + ]; + self::assertCount(19, $expectedSort); + self::assertCount(19, $columnArray); + self::assertSame($expectedSort, $sheet2->getCell('D1')->getCalculatedValue()); + $spreadsheet2->disconnectWorksheets(); + $file = 'zip://'; $file .= $this->outputFile; $file .= '#xl/worksheets/sheet1.xml'; @@ -126,6 +242,24 @@ public function testArrayOutput(): void self::assertStringContainsString('_xlfn.UNIQUE(A1:A19)41', $data, '15 results for UNIQUE'); self::assertStringContainsString('_xlfn._xlws.SORT(A1:A19)26', $data, '19 results for SORT'); } + + $file = 'zip://'; + $file .= $this->outputFile; + $file .= '#xl/metadata.xml'; + $data = @file_get_contents($file); + self::assertFalse($data, 'metadata.xml should not exist'); + + $file = 'zip://'; + $file .= $this->outputFile; + $file .= '#[Content_Types].xml'; + $data = file_get_contents($file); + self::assertStringNotContainsString('metadata', $data); + + $file = 'zip://'; + $file .= $this->outputFile; + $file .= '#xl/_rels/workbook.xml.rels'; + $data = file_get_contents($file); + self::assertStringNotContainsString('metadata', $data); } public function testUnimplementedArrayOutput(): void From 846fec7afab7d1f72660e15cd74be7776babdb1c Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Mon, 10 Jun 2024 09:19:35 -0700 Subject: [PATCH 11/31] Minor Performance Improvements --- src/PhpSpreadsheet/Writer/Xlsx.php | 21 +++++++++++++------ src/PhpSpreadsheet/Writer/Xlsx/Metadata.php | 3 +++ src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 8 +++++-- .../Writer/Xlsx/ArrayFunctionsTest.php | 13 ++++++++++++ 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx.php b/src/PhpSpreadsheet/Writer/Xlsx.php index 0a50efd840..57ca689ad0 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx.php +++ b/src/PhpSpreadsheet/Writer/Xlsx.php @@ -171,6 +171,7 @@ public function __construct(Spreadsheet $spreadsheet) $this->numFmtHashTable = new HashTable(); $this->styleHashTable = new HashTable(); $this->stylesConditionalHashTable = new HashTable(); + $this->determineUseDynamicArrays(); } public function getWriterPartChart(): Chart @@ -251,7 +252,7 @@ public function getWriterPartWorksheet(): Worksheet public function save($filename, int $flags = 0): void { $this->processFlags($flags); - $this->useDynamicArray = $this->preCalculateFormulas && Calculation::getInstance($this->spreadSheet)->getArrayReturnType() === Calculation::RETURN_ARRAY_AS_ARRAY && !$this->useCSEArrays; + $this->determineUseDynamicArrays(); // garbage collect $this->pathNames = []; @@ -282,9 +283,9 @@ public function save($filename, int $flags = 0): void $zipContent = []; // Add [Content_Types].xml to ZIP file $zipContent['[Content_Types].xml'] = $this->getWriterPartContentTypes()->writeContentTypes($this->spreadSheet, $this->includeCharts); - if ($this->useDynamicArrays()) { - $writerPartMetadata = new Xlsx\Metadata($this); - $zipContent['xl/metadata.xml'] = $writerPartMetadata->writeMetadata(); + $metadataData = (new Xlsx\Metadata($this))->writeMetadata(); + if ($metadataData !== '') { + $zipContent['xl/metadata.xml'] = $metadataData; } //if hasMacros, add the vbaProject.bin file, Certificate file(if exists) @@ -721,13 +722,21 @@ public function setExplicitStyle0(bool $explicitStyle0): self return $this; } - public function setUseCSEArrays(bool $useCSEArrays): void + public function setUseCSEArrays(?bool $useCSEArrays): void { - $this->useCSEArrays = $useCSEArrays; + if ($useCSEArrays !== null) { + $this->useCSEArrays = $useCSEArrays; + } + $this->determineUseDynamicArrays(); } public function useDynamicArrays(): bool { return $this->useDynamicArray; } + + private function determineUseDynamicArrays(): void + { + $this->useDynamicArray = $this->preCalculateFormulas && Calculation::getInstance($this->spreadSheet)->getArrayReturnType() === Calculation::RETURN_ARRAY_AS_ARRAY && !$this->useCSEArrays; + } } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Metadata.php b/src/PhpSpreadsheet/Writer/Xlsx/Metadata.php index 7f6d04bfa3..67b5a840a6 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Metadata.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Metadata.php @@ -14,6 +14,9 @@ class Metadata extends WriterPart */ public function writeMetadata(): string { + if (!$this->getParentWriter()->useDynamicArrays()) { + return ''; + } // Create XML writer $objWriter = null; if ($this->getParentWriter()->getUseDiskCaching()) { diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index 53bcb02b85..7dfaaf68d7 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -5,6 +5,7 @@ use PhpOffice\PhpSpreadsheet\Calculation\Information\ErrorValue; use PhpOffice\PhpSpreadsheet\Cell\Cell; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; +use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Namespaces; use PhpOffice\PhpSpreadsheet\RichText\RichText; use PhpOffice\PhpSpreadsheet\Settings; @@ -30,6 +31,8 @@ class Worksheet extends WriterPart private bool $explicitStyle0; + private bool $useDynamicArrays = false; + /** * Write worksheet to XML format. * @@ -40,6 +43,7 @@ class Worksheet extends WriterPart */ public function writeWorksheet(PhpspreadsheetWorksheet $worksheet, array $stringTable = [], bool $includeCharts = false): string { + $this->useDynamicArrays = $this->getParentWriter()->useDynamicArrays(); $this->explicitStyle0 = $this->getParentWriter()->getExplicitStyle0(); $this->numberStoredAsText = ''; $this->formula = ''; @@ -1559,8 +1563,8 @@ private function writeCell(XMLWriter $objWriter, PhpspreadsheetWorksheet $worksh $objWriter->startElement('c'); $objWriter->writeAttribute('r', $cellAddress); $mappedType = $pCell->getDataType(); - if (strtolower($mappedType) === 'f') { - if ($this->getParentWriter()->useDynamicArrays()) { + if ($mappedType === DataType::TYPE_FORMULA) { + if ($this->useDynamicArrays) { $tempCalc = $pCell->getCalculatedValue(); if (is_array($tempCalc)) { $objWriter->writeAttribute('cm', '1'); diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php index 649d5fff7f..bacd972a2e 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php @@ -339,4 +339,17 @@ public function testArrayMultipleColumns(): void } $spreadsheet2->disconnectWorksheets(); } + + public function testMetadataWritten(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $writer = new XlsxWriter($spreadsheet); + $writerMetadata = new XlsxWriter\Metadata($writer); + self::assertNotEquals('', $writerMetadata->writeMetaData()); + $writer->setUseCSEArrays(true); + $writerMetadata2 = new XlsxWriter\Metadata($writer); + self::assertSame('', $writerMetadata2->writeMetaData()); + $spreadsheet->disconnectWorksheets(); + } } From ad2194d73708879d7ad4e73e006bc7a1f198e279 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Fri, 14 Jun 2024 08:30:18 -0700 Subject: [PATCH 12/31] CONCATENATE Changes, and Csv/Html/Ods Support The CONCATENATE function has been treated as equivalent to CONCAT. This is not how it is treated in Excel; it is closer to (and probably identical to) the ampersand concatenate operator. The difference manifests itself when any of the arguments is an array (typically a cell range). Code is added to support this difference. Support for array results is added to Csv Writer, Html Writer, and Ods Reader and Writer. I have not figured out how to get it to work with Xls. --- docs/references/function-list-by-category.md | 2 +- docs/references/function-list-by-name.md | 2 +- .../Calculation/Calculation.php | 6 +- .../Calculation/TextData/Concatenate.php | 54 ++++++++++- src/PhpSpreadsheet/Cell/Cell.php | 38 +++++--- src/PhpSpreadsheet/Reader/Xlsx.php | 4 +- .../Reader/Xlsx/ConditionalStyles.php | 1 + src/PhpSpreadsheet/Worksheet/Worksheet.php | 10 ++ src/PhpSpreadsheet/Writer/Csv.php | 4 +- src/PhpSpreadsheet/Writer/Html.php | 15 +-- src/PhpSpreadsheet/Writer/Ods/Content.php | 1 + .../Writer/Xlsx/FunctionPrefix.php | 39 ++++++-- src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 29 ++++-- .../Functions/TextData/ConcatTest.php | 57 +++++++++++ .../TextData/ConcatenateRangeTest.php | 48 ++++++++++ .../Functions/TextData/ConcatenateTest.php | 22 ++++- .../Calculation/XlfnFunctionsTest.php | 49 ++++++++-- .../Writer/Csv/CsvArrayTest.php | 65 +++++++++++++ .../Writer/Html/HtmlArrayTest.php | 65 +++++++++++++ .../Writer/Ods/ArrayTest.php | 95 +++++++++++++++++++ .../Writer/Ods/ContentTest.php | 2 +- .../Writer/Xlsx/ArrayFunctionsInlineTest.php | 46 +++++++++ .../Writer/Xlsx/ArrayFunctionsTest.php | 2 +- tests/data/Calculation/TextData/CONCAT.php | 39 ++++++++ .../data/Calculation/TextData/CONCATENATE.php | 8 +- tests/data/Writer/Ods/content-arrays.xml | 2 + tests/data/Writer/Ods/content-with-data.xml | 2 +- 27 files changed, 636 insertions(+), 71 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatTest.php create mode 100644 tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateRangeTest.php create mode 100644 tests/PhpSpreadsheetTests/Writer/Csv/CsvArrayTest.php create mode 100644 tests/PhpSpreadsheetTests/Writer/Html/HtmlArrayTest.php create mode 100644 tests/PhpSpreadsheetTests/Writer/Ods/ArrayTest.php create mode 100644 tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsInlineTest.php create mode 100644 tests/data/Calculation/TextData/CONCAT.php create mode 100644 tests/data/Writer/Ods/content-arrays.xml diff --git a/docs/references/function-list-by-category.md b/docs/references/function-list-by-category.md index 353458ca40..e69cf6d5b6 100644 --- a/docs/references/function-list-by-category.md +++ b/docs/references/function-list-by-category.md @@ -529,7 +529,7 @@ CHAR | \PhpOffice\PhpSpreadsheet\Calculation\TextData\Charac CLEAN | \PhpOffice\PhpSpreadsheet\Calculation\TextData\Trim::nonPrintable CODE | \PhpOffice\PhpSpreadsheet\Calculation\TextData\CharacterConvert::code CONCAT | \PhpOffice\PhpSpreadsheet\Calculation\TextData\Concatenate::CONCATENATE -CONCATENATE | \PhpOffice\PhpSpreadsheet\Calculation\TextData\Concatenate::CONCATENATE +CONCATENATE | \PhpOffice\PhpSpreadsheet\Calculation\TextData\Concatenate::actualCONCATENATE DBCS | **Not yet Implemented** DOLLAR | \PhpOffice\PhpSpreadsheet\Calculation\TextData\Format::DOLLAR EXACT | \PhpOffice\PhpSpreadsheet\Calculation\TextData\Text::exact diff --git a/docs/references/function-list-by-name.md b/docs/references/function-list-by-name.md index 24c7f823ef..cafd0d6fca 100644 --- a/docs/references/function-list-by-name.md +++ b/docs/references/function-list-by-name.md @@ -89,7 +89,7 @@ COMBIN | CATEGORY_MATH_AND_TRIG | \PhpOffice\PhpSpread COMBINA | CATEGORY_MATH_AND_TRIG | \PhpOffice\PhpSpreadsheet\Calculation\MathTrig\Combinations::withRepetition COMPLEX | CATEGORY_ENGINEERING | \PhpOffice\PhpSpreadsheet\Calculation\Engineering\Complex::COMPLEX CONCAT | CATEGORY_TEXT_AND_DATA | \PhpOffice\PhpSpreadsheet\Calculation\TextData\Concatenate::CONCATENATE -CONCATENATE | CATEGORY_TEXT_AND_DATA | \PhpOffice\PhpSpreadsheet\Calculation\TextData\Concatenate::CONCATENATE +CONCATENATE | CATEGORY_TEXT_AND_DATA | \PhpOffice\PhpSpreadsheet\Calculation\TextData\Concatenate::actualCONCATENATE CONFIDENCE | CATEGORY_STATISTICAL | \PhpOffice\PhpSpreadsheet\Calculation\Statistical\Confidence::CONFIDENCE CONFIDENCE.NORM | CATEGORY_STATISTICAL | \PhpOffice\PhpSpreadsheet\Calculation\Statistical\Confidence::CONFIDENCE CONFIDENCE.T | CATEGORY_STATISTICAL | **Not yet Implemented** diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index 7a2137dc06..8869b23fac 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -598,7 +598,7 @@ public static function getExcelConstants(string $key): bool|null ], 'CONCATENATE' => [ 'category' => Category::CATEGORY_TEXT_AND_DATA, - 'functionCall' => [TextData\Concatenate::class, 'CONCATENATE'], + 'functionCall' => [TextData\Concatenate::class, 'actualCONCATENATE'], 'argumentCount' => '1+', ], 'CONFIDENCE' => [ @@ -3703,7 +3703,7 @@ public function _calculateFormulaValue(string $formula, ?string $cellID = null, * 1 = shrink to fit * 2 = extend to fit */ - private static function checkMatrixOperands(mixed &$operand1, mixed &$operand2, int $resize = 1): array + public static function checkMatrixOperands(mixed &$operand1, mixed &$operand2, int $resize = 1): array { // Examine each of the two operands, and turn them into an array if they aren't one already // Note that this function should only be called if one or both of the operand is already an array @@ -5643,7 +5643,7 @@ public function getSuppressFormulaErrors(): bool return $this->suppressFormulaErrorsNew; } - private static function boolToString(mixed $operand1): mixed + public static function boolToString(mixed $operand1): mixed { if (is_bool($operand1)) { $operand1 = ($operand1) ? self::$localeBoolean['TRUE'] : self::$localeBoolean['FALSE']; diff --git a/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php b/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php index c2281d4364..21f82ca1ea 100644 --- a/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php +++ b/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php @@ -3,6 +3,7 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\TextData; use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled; +use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\Functions; use PhpOffice\PhpSpreadsheet\Calculation\Information\ErrorValue; use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError; @@ -14,7 +15,7 @@ class Concatenate use ArrayEnabled; /** - * CONCATENATE. + * This implements the CONCAT function, *not* CONCATENATE. * * @param array $args */ @@ -43,6 +44,57 @@ public static function CONCATENATE(...$args): string return $returnValue; } + /** + * This implements the CONCATENATE function. + * + * @param array $args data to be concatenated + */ + public static function actualCONCATENATE(...$args): array|string + { + $result = ''; + foreach ($args as $operand2) { + $result = self::concatenate2Args($result, $operand2); + if (ErrorValue::isError($result) === true) { + break; + } + } + + return $result; + } + + private static function concatenate2Args(array|string $operand1, null|array|bool|float|int|string $operand2): array|string + { + if (is_array($operand1) || is_array($operand2)) { + $operand1 = Calculation::boolToString($operand1); + $operand2 = Calculation::boolToString($operand2); + [$rows, $columns] = Calculation::checkMatrixOperands($operand1, $operand2, 2); + $errorFound = false; + for ($row = 0; $row < $rows && !$errorFound; ++$row) { + for ($column = 0; $column < $columns; ++$column) { + if (ErrorValue::isError($operand2[$row][$column])) { + return $operand2[$row][$column]; + } + $operand1[$row][$column] + = Calculation::boolToString($operand1[$row][$column]) + . Calculation::boolToString($operand2[$row][$column]); + if (mb_strlen($operand1[$row][$column]) > DataType::MAX_STRING_LENGTH) { + $operand1 = ExcelError::CALC(); + $errorFound = true; + + break; + } + } + } + } else { + $operand1 .= (string) Calculation::boolToString($operand2); + if (mb_strlen($operand1) > DataType::MAX_STRING_LENGTH) { + $operand1 = ExcelError::CALC(); + } + } + + return $operand1; + } + /** * TEXTJOIN. * diff --git a/src/PhpSpreadsheet/Cell/Cell.php b/src/PhpSpreadsheet/Cell/Cell.php index ffc8641082..ea46231b70 100644 --- a/src/PhpSpreadsheet/Cell/Cell.php +++ b/src/PhpSpreadsheet/Cell/Cell.php @@ -349,6 +349,9 @@ private function convertDateTimeInt(mixed $result): mixed public function getCalculatedValueString(): string { $value = $this->getCalculatedValue(); + while (is_array($value)) { + $value = array_shift($value); + } return ($value === '' || is_scalar($value) || $value instanceof Stringable) ? "$value" : ''; } @@ -362,17 +365,19 @@ public function getCalculatedValueString(): string */ public function getCalculatedValue(bool $resetLog = true): mixed { + $title = 'unknown'; if ($this->dataType === DataType::TYPE_FORMULA) { try { - $index = $this->getWorksheet()->getParentOrThrow()->getActiveSheetIndex(); - $selected = $this->getWorksheet()->getSelectedCells(); + $thisworksheet = $this->getWorksheet(); + $title = $thisworksheet->getTitle(); + $index = $thisworksheet->getParentOrThrow()->getActiveSheetIndex(); + $selected = $thisworksheet->getSelectedCells(); $result = Calculation::getInstance( - $this->getWorksheet()->getParent() + $thisworksheet->getParent() )->calculateCellValue($this, $resetLog); $result = $this->convertDateTimeInt($result); - $this->getWorksheet()->setSelectedCells($selected); - $this->getWorksheet()->getParentOrThrow()->setActiveSheetIndex($index); - // We don't yet handle array returns + $thisworksheet->setSelectedCells($selected); + $thisworksheet->getParentOrThrow()->setActiveSheetIndex($index); if (is_array($result) && Calculation::getArrayReturnType() !== Calculation::RETURN_ARRAY_AS_ARRAY) { while (is_array($result)) { $result = array_shift($result); @@ -390,21 +395,28 @@ public function getCalculatedValue(bool $resetLog = true): mixed } } } + $newColumn = $this->getColumn(); if (is_array($result)) { $newRow = $row = $this->getRow(); $column = $this->getColumn(); foreach ($result as $resultRow) { - $newColumn = $column; - $resultRowx = is_array($resultRow) ? $resultRow : [$resultRow]; - foreach ($resultRowx as $resultValue) { + if (is_array($resultRow)) { + $newColumn = $column; + foreach ($resultRow as $resultValue) { + if ($row !== $newRow || $column !== $newColumn) { + $thisworksheet->getCell($newColumn . $newRow)->setValue($resultValue); + } + ++$newColumn; + } + ++$newRow; + } else { if ($row !== $newRow || $column !== $newColumn) { - $this->getWorksheet()->getCell($newColumn . $newRow)->setValue($resultValue); + $thisworksheet->getCell($newColumn . $newRow)->setValue($resultRow); } ++$newColumn; } - ++$newRow; } - $this->getWorksheet()->getCell($column . $row); + $thisworksheet->getCell($column . $row); } } catch (SpreadsheetException $ex) { if (($ex->getMessage() === 'Unable to access External Workbook') && ($this->calculatedValue !== null)) { @@ -414,7 +426,7 @@ public function getCalculatedValue(bool $resetLog = true): mixed } throw new CalculationException( - $this->getWorksheet()->getTitle() . '!' . $this->getCoordinate() . ' -> ' . $ex->getMessage(), + $title . '!' . $this->getCoordinate() . ' -> ' . $ex->getMessage(), $ex->getCode(), $ex ); diff --git a/src/PhpSpreadsheet/Reader/Xlsx.php b/src/PhpSpreadsheet/Reader/Xlsx.php index e924bfc680..e628a8d2bb 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/src/PhpSpreadsheet/Reader/Xlsx.php @@ -309,7 +309,9 @@ private function castToFormula(?SimpleXMLElement $c, string $r, string &$cellDat } $attr = $c->f->attributes(); $cellDataType = DataType::TYPE_FORMULA; - $value = "={$c->f}"; + $formula = (string) $c->f; + $formula = str_replace(['_xlfn.', '_xlws.'], '', $formula); + $value = "=$formula"; $calculatedValue = self::$castBaseType($c); // Shared formula? diff --git a/src/PhpSpreadsheet/Reader/Xlsx/ConditionalStyles.php b/src/PhpSpreadsheet/Reader/Xlsx/ConditionalStyles.php index cb562ef623..8731d642e5 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx/ConditionalStyles.php +++ b/src/PhpSpreadsheet/Reader/Xlsx/ConditionalStyles.php @@ -220,6 +220,7 @@ private function readStyleRules(array $cfRules, SimpleXMLElement $extLst): array if (count($cfRule->formula) >= 1) { foreach ($cfRule->formula as $formulax) { $formula = (string) $formulax; + $formula = str_replace(['_xlfn.', '_xlws.'], '', $formula); if ($formula === 'TRUE') { $objConditional->addCondition(true); } elseif ($formula === 'FALSE') { diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 8adada4c11..5ea798f3f2 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -3673,4 +3673,14 @@ public function copyCells(string $fromCell, string $toCells, bool $copyStyle = t } } } + + public function calculateArrays(bool $preCalculateFormulas = true): void + { + if ($preCalculateFormulas && Calculation::getArrayReturnType() === Calculation::RETURN_ARRAY_AS_ARRAY) { + $keys = $this->cellCollection->getCoordinates(); + foreach ($keys as $key) { + $this->getCell($key)->getCalculatedValue(); + } + } + } } diff --git a/src/PhpSpreadsheet/Writer/Csv.php b/src/PhpSpreadsheet/Writer/Csv.php index 6ce968d8cb..6e0c20d253 100644 --- a/src/PhpSpreadsheet/Writer/Csv.php +++ b/src/PhpSpreadsheet/Writer/Csv.php @@ -76,8 +76,7 @@ public function save($filename, int $flags = 0): void $saveDebugLog = Calculation::getInstance($this->spreadsheet)->getDebugLog()->getWriteDebugLog(); Calculation::getInstance($this->spreadsheet)->getDebugLog()->setWriteDebugLog(false); - $saveArrayReturnType = Calculation::getArrayReturnType(); - Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_VALUE); + $sheet->calculateArrays($this->preCalculateFormulas); // Open file $this->openFileHandle($filename); @@ -110,7 +109,6 @@ public function save($filename, int $flags = 0): void } $this->maybeCloseFileHandle(); - Calculation::setArrayReturnType($saveArrayReturnType); Calculation::getInstance($this->spreadsheet)->getDebugLog()->setWriteDebugLog($saveDebugLog); } diff --git a/src/PhpSpreadsheet/Writer/Html.php b/src/PhpSpreadsheet/Writer/Html.php index e344e33989..d7a52e65b4 100644 --- a/src/PhpSpreadsheet/Writer/Html.php +++ b/src/PhpSpreadsheet/Writer/Html.php @@ -173,13 +173,15 @@ public function save($filename, int $flags = 0): void */ public function generateHtmlAll(): string { + $sheets = $this->generateSheetPrep(); + foreach ($sheets as $sheet) { + $sheet->calculateArrays($this->preCalculateFormulas); + } // garbage collect $this->spreadsheet->garbageCollect(); $saveDebugLog = Calculation::getInstance($this->spreadsheet)->getDebugLog()->getWriteDebugLog(); Calculation::getInstance($this->spreadsheet)->getDebugLog()->setWriteDebugLog(false); - $saveArrayReturnType = Calculation::getArrayReturnType(); - Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_VALUE); // Build CSS $this->buildCSS(!$this->useInlineCss); @@ -204,7 +206,6 @@ public function generateHtmlAll(): string $html = $callback($html); } - Calculation::setArrayReturnType($saveArrayReturnType); Calculation::getInstance($this->spreadsheet)->getDebugLog()->setWriteDebugLog($saveDebugLog); return $html; @@ -404,11 +405,9 @@ public function generateHTMLHeader(bool $includeStyles = false): string return $html; } + /** @return Worksheet[] */ private function generateSheetPrep(): array { - // Ensure that Spans have been calculated? - $this->calculateSpans(); - // Fetch sheets if ($this->sheetIndex === null) { $sheets = $this->spreadsheet->getAllSheets(); @@ -456,6 +455,8 @@ private function generateSheetTags(int $row, int $theadStart, int $theadEnd, int */ public function generateSheetData(): string { + // Ensure that Spans have been calculated? + $this->calculateSpans(); $sheets = $this->generateSheetPrep(); // Construct HTML @@ -484,7 +485,7 @@ public function generateSheetData(): string $html .= $startTag; // Write row if there are HTML table cells in it - if ($this->shouldGenerateRow($sheet, $row) && !isset($this->isSpannedRow[$sheet->getParent()->getIndex($sheet)][$row])) { + if ($this->shouldGenerateRow($sheet, $row) && !isset($this->isSpannedRow[$sheet->getParentOrThrow()->getIndex($sheet)][$row])) { // Start a new rowData $rowData = []; // Loop through columns diff --git a/src/PhpSpreadsheet/Writer/Ods/Content.php b/src/PhpSpreadsheet/Writer/Ods/Content.php index 7b052bbccb..7cbcc42fd1 100644 --- a/src/PhpSpreadsheet/Writer/Ods/Content.php +++ b/src/PhpSpreadsheet/Writer/Ods/Content.php @@ -120,6 +120,7 @@ private function writeSheets(XMLWriter $objWriter): void $spreadsheet = $this->getParentWriter()->getSpreadsheet(); $sheetCount = $spreadsheet->getSheetCount(); for ($sheetIndex = 0; $sheetIndex < $sheetCount; ++$sheetIndex) { + $spreadsheet->getSheet($sheetIndex)->calculateArrays($this->getParentWriter()->getPreCalculateFormulas()); $objWriter->startElement('table:table'); $objWriter->writeAttribute('table:name', $spreadsheet->getSheet($sheetIndex)->getTitle()); $objWriter->writeAttribute('table:style-name', Style::TABLE_STYLE_PREFIX . (string) ($sheetIndex + 1)); diff --git a/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php b/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php index 6f66eccfbc..b91f02777f 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php @@ -131,25 +131,44 @@ class FunctionPrefix . '|sumifs' . '|textjoin' // functions added with Excel 365 - . '|filter' - . '|randarray' . '|anchorarray' - . '|sequence' - . '|sort' - . '|sortby' - . '|unique' - . '|xlookup' - . '|xmatch' . '|arraytotext' + . '|bycol' + . '|byrow' . '|call' - . '|let' + . '|choosecols' + . '|chooserows' + . '|drop' + . '|expand' + . '|filter' + . '|hstack' + . '|isomitted' . '|lambda' - . '|single' + . '|let' + . '|makearray' + . '|map' + . '|randarray' + . '|reduce' . '|register[.]id' + . '|scan' + . '|sequence' + . '|single' + . '|sort' + . '|sortby' + . '|take' . '|textafter' . '|textbefore' + . '|textjoin' . '|textsplit' + . '|tocol' + . '|torow' + . '|unique' . '|valuetotext' + . '|vstack' + . '|wrapcols' + . '|wraprows' + . '|xlookup' + . '|xmatch' . '))\s*\(/Umui'; const XLWSREGEXP = '/(?useDynamicArrays = $this->getParentWriter()->useDynamicArrays(); $this->explicitStyle0 = $this->getParentWriter()->getExplicitStyle0(); + $worksheet->calculateArrays($this->getParentWriter()->getPreCalculateFormulas()); $this->numberStoredAsText = ''; $this->formula = ''; $this->twoDigitTextYear = ''; @@ -1475,18 +1476,28 @@ private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell } else { $ref = $coordinate; } + $thisRow = $cell->getRow(); + $newColumn = $thisColumn = $cell->getColumn(); if (is_array($calculatedValue)) { $attributes['t'] = 'array'; - $rows = max(1, count($calculatedValue)); - $cols = 1; - foreach ($calculatedValue as $row) { - $cols = max($cols, is_array($row) ? count($row) : 1); + $newRow = $row = $lastRow = $thisRow; + $column = $lastColumn = $thisColumn; + foreach ($calculatedValue as $resultRow) { + if (is_array($resultRow)) { + $newColumn = $column; + foreach ($resultRow as $resultValue) { + $lastColumn = $newColumn; + $lastRow = $newRow; + ++$newColumn; + } + ++$newRow; + } else { + $lastColumn = $newColumn; + $lastRow = $newRow; + ++$newColumn; + } } - $firstCellArray = Coordinate::indexesFromString($coordinate); - $lastRow = $firstCellArray[1] + $rows - 1; - $lastColumn = $firstCellArray[0] + $cols - 1; - $lastColumnString = Coordinate::stringFromColumnIndex($lastColumn); - $ref = "$coordinate:$lastColumnString$lastRow"; + $ref = "$coordinate:$lastColumn$lastRow"; } if (($attributes['t'] ?? null) === 'array') { $objWriter->startElement('f'); diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatTest.php new file mode 100644 index 0000000000..3e0c88f096 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatTest.php @@ -0,0 +1,57 @@ +mightHaveException($expectedResult); + $sheet = $this->getSheet(); + $finalArg = ''; + $row = 0; + foreach ($args as $arg) { + ++$row; + $this->setCell("A$row", $arg); + $finalArg = "A1:A$row"; + } + $this->setCell('B1', "=CONCAT($finalArg)"); + $result = $sheet->getCell('B1')->getCalculatedValue(); + self::assertEquals($expectedResult, $result); + } + + public static function providerCONCAT(): array + { + return require 'tests/data/Calculation/TextData/CONCAT.php'; + } + + public function testConcatWithIndexMatch(): void + { + $spreadsheet = new Spreadsheet(); + $sheet1 = $spreadsheet->getActiveSheet(); + $sheet1->setTitle('Formula'); + $sheet1->fromArray( + [ + ['Number', 'Formula'], + [52101293, '=CONCAT(INDEX(Lookup!B2, MATCH(A2, Lookup!A2, 0)))'], + ] + ); + $sheet2 = $spreadsheet->createSheet(); + $sheet2->setTitle('Lookup'); + $sheet2->fromArray( + [ + ['Lookup', 'Match'], + [52101293, 'PHP'], + ] + ); + self::assertSame('PHP', $sheet1->getCell('B2')->getCalculatedValue()); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateRangeTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateRangeTest.php new file mode 100644 index 0000000000..d4c6188963 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateRangeTest.php @@ -0,0 +1,48 @@ +arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + parent::tearDown(); + Calculation::setArrayReturnType($this->arrayReturnType); + } + + public function testIssue4061(): void + { + $sheet = $this->getSheet(); + $sheet->getCell('A1')->setValue('a'); + $sheet->getCell('A2')->setValue('b'); + $sheet->getCell('A3')->setValue('c'); + $sheet->getCell('C1')->setValue('1'); + $sheet->getCell('C2')->setValue('2'); + $sheet->getCell('C3')->setValue('3'); + $sheet->getCell('B1')->setValue('=CONCATENATE(A1:A3, "-", C1:C3)'); + self::assertSame('a-1', $sheet->getCell('B1')->getCalculatedValue()); + $sheet->getCell('X1')->setValue('=A1:A3&"-"&C1:C3'); + self::assertSame('a-1', $sheet->getCell('X1')->getCalculatedValue()); + $sheet->getCell('D1')->setValue('=CONCAT(A1:A3, "-", C1:C3)'); + self::assertSame('abc-123', $sheet->getCell('D1')->getCalculatedValue()); + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $sheet->getCell('E1')->setValue('=CONCATENATE(A1:A3, "-", C1:C3)'); + self::assertSame([['a-1'], ['b-2'], ['c-3']], $sheet->getCell('E1')->getCalculatedValue()); + $sheet->getCell('Y1')->setValue('=A1:A3&"-"&C1:C3'); + self::assertSame([['a-1'], ['b-2'], ['c-3']], $sheet->getCell('Y1')->getCalculatedValue()); + $sheet->getCell('F1')->setValue('=CONCAT(A1:A3, "-", C1:C3)'); + self::assertSame('abc-123', $sheet->getCell('F1')->getCalculatedValue()); + } +} diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateTest.php index cfe5662634..c70913ab33 100644 --- a/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateTest.php @@ -4,6 +4,7 @@ namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\TextData; +use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Spreadsheet; class ConcatenateTest extends AllSetupTeardown @@ -16,13 +17,24 @@ public function testCONCATENATE(mixed $expectedResult, mixed ...$args): void $this->mightHaveException($expectedResult); $sheet = $this->getSheet(); $finalArg = ''; + $comma = ''; $row = 0; foreach ($args as $arg) { - ++$row; - $this->setCell("A$row", $arg); - $finalArg = "A1:A$row"; + $finalArg .= $comma; + $comma = ','; + if (is_bool($arg)) { + $finalArg .= $arg ? 'true' : 'false'; + } elseif ($arg === 'A2') { + $finalArg .= 'A2'; + $sheet->getCell('A2')->setValue('=2/0'); + } elseif ($arg === 'A3') { + $finalArg .= 'A3'; + $sheet->getCell('A3')->setValue(str_repeat('Ԁ', DataType::MAX_STRING_LENGTH - 5)); + } else { + $finalArg .= '"' . (string) $arg . '"'; + } } - $this->setCell('B1', "=CONCAT($finalArg)"); + $this->setCell('B1', "=CONCATENATE($finalArg)"); $result = $sheet->getCell('B1')->getCalculatedValue(); self::assertEquals($expectedResult, $result); } @@ -40,7 +52,7 @@ public function testConcatenateWithIndexMatch(): void $sheet1->fromArray( [ ['Number', 'Formula'], - [52101293, '=CONCAT(INDEX(Lookup!B2, MATCH(A2, Lookup!A2, 0)))'], + [52101293, '=CONCATENATE(INDEX(Lookup!B2, MATCH(A2, Lookup!A2, 0)))'], ] ); $sheet2 = $spreadsheet->createSheet(); diff --git a/tests/PhpSpreadsheetTests/Calculation/XlfnFunctionsTest.php b/tests/PhpSpreadsheetTests/Calculation/XlfnFunctionsTest.php index fbeb846223..1579cc0aac 100644 --- a/tests/PhpSpreadsheetTests/Calculation/XlfnFunctionsTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/XlfnFunctionsTest.php @@ -16,10 +16,10 @@ 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], + ['2010', 'A1', '=MODE.SNGL({5.6,4,4,3,2,4})', '=MODE.SNGL({5.6,4,4,3,2,4})', 4], + ['2010', 'A2', '=MODE.SNGL({"x","y"})', '=MODE.SNGL({"x","y"})', '#N/A'], + ['2013', 'A1', '=ISOWEEKNUM("2019-12-19")', '=ISOWEEKNUM("2019-12-19")', 51], + ['2013', 'A2', '=SHEET("2019")', '=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], @@ -27,10 +27,11 @@ public function testXlfn(): void ['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.'], + ['2013', 'C3', '=ISOWEEKNUM(A3)+WEEKNUM(A4)+ISOWEEKNUM(A5)', '=ISOWEEKNUM(A3)+WEEKNUM(A4)+ISOWEEKNUM(A5)', 77], + ['2016', 'A1', '=SWITCH(WEEKDAY("2019-12-22",1),1,"Sunday",2,"Monday","No Match")', '=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")', '=SWITCH(WEEKDAY("2019-12-20",1),1,"Sunday",2,"Monday","No Match")', 'No Match'], + ['2019', 'A1', '=CONCAT("The"," ","sun"," ","will"," ","come"," ","up"," ","tomorrow.")', '=CONCAT("The"," ","sun"," ","will"," ","come"," ","up"," ","tomorrow.")', 'The sun will come up tomorrow.'], + ['365', 'A1', '=SORT({7;1;5})', '=SORT({7;1;5})', 1], ]; $workbook = new Spreadsheet(); $sheet = $workbook->getActiveSheet(); @@ -41,6 +42,8 @@ public function testXlfn(): void $sheet->setTitle('2016'); $sheet = $workbook->createSheet(); $sheet->setTitle('2019'); + $sheet = $workbook->createSheet(); + $sheet->setTitle('365'); foreach ($formulas as $values) { $sheet = $workbook->setActiveSheetIndexByName($values[0]); @@ -80,6 +83,32 @@ public function testXlfn(): void $oufil = File::temporaryFilename(); $writer->save($oufil); + $file = "zip://$oufil#xl/worksheets/sheet1.xml"; + $contents = (string) file_get_contents($file); + self::assertStringContainsString('_xlfn.MODE.SNGL({5.6,4,4,3,2,4})4', $contents); + self::assertStringContainsString('_xlfn.MODE.SNGL({"x","y"})#N/A', $contents); + + $file = "zip://$oufil#xl/worksheets/sheet2.xml"; + $contents = (string) file_get_contents($file); + self::assertStringContainsString('_xlfn.ISOWEEKNUM("2019-12-19")51', $contents); + self::assertStringContainsString('_xlfn.SHEET("2019")', $contents); + self::assertStringContainsString('_xlfn.ISOWEEKNUM(A3)+WEEKNUM(A4)+_xlfn.ISOWEEKNUM(A5)77', $contents); + self::assertStringContainsString('ABS(B3)<2ABS(B3)>2', $contents); + self::assertStringContainsString('_xlfn.ISOWEEKNUM(A3)<10_xlfn.ISOWEEKNUM(A3)>40', $contents); + + $file = "zip://$oufil#xl/worksheets/sheet3.xml"; + $contents = (string) file_get_contents($file); + self::assertStringContainsString('_xlfn.SWITCH(WEEKDAY("2019-12-22",1),1,"Sunday",2,"Monday","No Match")Sunday', $contents); + self::assertStringContainsString('_xlfn.SWITCH(WEEKDAY("2019-12-20",1),1,"Sunday",2,"Monday","No Match")No Match', $contents); + + $file = "zip://$oufil#xl/worksheets/sheet4.xml"; + $contents = (string) file_get_contents($file); + self::assertStringContainsString('_xlfn.CONCAT("The"," ","sun"," ","will"," ","come"," ","up"," ","tomorrow.")The sun will come up tomorrow.', $contents); + + $file = "zip://$oufil#xl/worksheets/sheet5.xml"; + $contents = (string) file_get_contents($file); + self::assertStringContainsString('_xlfn._xlws.SORT({7;1;5})1', $contents); + $reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader('Xlsx'); $rdobj = $reader->load($oufil); unlink($oufil); @@ -92,8 +121,8 @@ public function testXlfn(): void } $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]); + self::assertEquals('ISOWEEKNUM(A3)<10', $cond[0]->getConditions()[0]); + self::assertEquals('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]); diff --git a/tests/PhpSpreadsheetTests/Writer/Csv/CsvArrayTest.php b/tests/PhpSpreadsheetTests/Writer/Csv/CsvArrayTest.php new file mode 100644 index 0000000000..834b193977 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Csv/CsvArrayTest.php @@ -0,0 +1,65 @@ +arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + Calculation::setArrayReturnType($this->arrayReturnType); + } + + public function testArray(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->getCell('A1')->setValue(1); + $sheet->getCell('A2')->setValue(1); + $sheet->getCell('A3')->setValue(3); + $sheet->getCell('B1')->setValue('=UNIQUE(A1:A3)'); + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Csv'); + $sheet = $reloadedSpreadsheet->getActiveSheet(); + self::assertEquals('1', $sheet->getCell('A1')->getValue()); + self::assertEquals('1', $sheet->getCell('A2')->getValue()); + self::assertEquals('3', $sheet->getCell('A3')->getValue()); + self::assertEquals('1', $sheet->getCell('B1')->getValue()); + self::assertEquals('3', $sheet->getCell('B2')->getValue()); + self::assertNull($sheet->getCell('B3')->getValue()); + $spreadsheet->disconnectWorksheets(); + $reloadedSpreadsheet->disconnectWorksheets(); + } + + public function testInlineArrays(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->getCell('A1')->setValue('=UNIQUE({1;1;2;1;3;2;4;4;4})'); + $sheet->getCell('D1')->setValue('=UNIQUE({1,1,2,1,3,2,4,4,4},true)'); + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Csv'); + $sheet = $reloadedSpreadsheet->getActiveSheet(); + $expected = [ + ['1', null, null, '1', '2', '3', '4'], + ['2', null, null, null, null, null, null], + ['3', null, null, null, null, null, null], + ['4', null, null, null, null, null, null], + ]; + self::assertSame($expected, $sheet->toArray()); + $spreadsheet->disconnectWorksheets(); + $reloadedSpreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Html/HtmlArrayTest.php b/tests/PhpSpreadsheetTests/Writer/Html/HtmlArrayTest.php new file mode 100644 index 0000000000..665db3bffd --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Html/HtmlArrayTest.php @@ -0,0 +1,65 @@ +arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + Calculation::setArrayReturnType($this->arrayReturnType); + } + + public function testArray(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->getCell('A1')->setValue(1); + $sheet->getCell('A2')->setValue(1); + $sheet->getCell('A3')->setValue(3); + $sheet->getCell('B1')->setValue('=UNIQUE(A1:A3)'); + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Html'); + $sheet = $reloadedSpreadsheet->getActiveSheet(); + self::assertEquals('1', $sheet->getCell('A1')->getValue()); + self::assertEquals('1', $sheet->getCell('A2')->getValue()); + self::assertEquals('3', $sheet->getCell('A3')->getValue()); + self::assertEquals('1', $sheet->getCell('B1')->getValue()); + self::assertEquals('3', $sheet->getCell('B2')->getValue()); + self::assertNull($sheet->getCell('B3')->getValue()); + $spreadsheet->disconnectWorksheets(); + $reloadedSpreadsheet->disconnectWorksheets(); + } + + public function testInlineArrays(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->getCell('A1')->setValue('=UNIQUE({1;1;2;1;3;2;4;4;4})'); + $sheet->getCell('D1')->setValue('=UNIQUE({1,1,2,1,3,2,4,4,4},true)'); + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Csv'); + $sheet = $reloadedSpreadsheet->getActiveSheet(); + $expected = [ + ['1', null, null, '1', '2', '3', '4'], + ['2', null, null, null, null, null, null], + ['3', null, null, null, null, null, null], + ['4', null, null, null, null, null, null], + ]; + self::assertSame($expected, $sheet->toArray()); + $spreadsheet->disconnectWorksheets(); + $reloadedSpreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Ods/ArrayTest.php b/tests/PhpSpreadsheetTests/Writer/Ods/ArrayTest.php new file mode 100644 index 0000000000..9f3271917b --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Ods/ArrayTest.php @@ -0,0 +1,95 @@ +compatibilityMode = Functions::getCompatibilityMode(); + Functions::setCompatibilityMode(Functions::COMPATIBILITY_OPENOFFICE); + $this->arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + parent::tearDown(); + Functions::setCompatibilityMode($this->compatibilityMode); + Calculation::setArrayReturnType($this->arrayReturnType); + } + + public function testArrayXml(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->getCell('A1')->setValue(1); + $sheet->getCell('A2')->setValue(1); + $sheet->getCell('A3')->setValue(3); + $sheet->getCell('B1')->setValue('=UNIQUE(A1:A3)'); + + $content = new Content(new Ods($spreadsheet)); + $xml = $content->write(); + self::assertXmlStringEqualsXmlFile($this->samplesPath . '/content-arrays.xml', $xml); + } + + public function testArray(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->getCell('A1')->setValue(1); + $sheet->getCell('A2')->setValue(1); + $sheet->getCell('A3')->setValue(3); + $sheet->getCell('B1')->setValue('=UNIQUE(A1:A3)'); + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Ods'); + $sheet = $reloadedSpreadsheet->getActiveSheet(); + self::assertEquals('1', $sheet->getCell('A1')->getValue()); + self::assertEquals('1', $sheet->getCell('A2')->getValue()); + self::assertEquals('3', $sheet->getCell('A3')->getValue()); + self::assertEquals('3', $sheet->getCell('B2')->getValue()); + self::assertNull($sheet->getCell('B3')->getValue()); + self::assertEquals('=UNIQUE(A1:A3)', $sheet->getCell('B1')->getValue()); + $spreadsheet->disconnectWorksheets(); + $reloadedSpreadsheet->disconnectWorksheets(); + } + + public function testInlineArrays(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->getCell('A1')->setValue('=UNIQUE({1;1;2;1;3;2;4;4;4})'); + $sheet->getCell('D1')->setValue('=UNIQUE({1,1,2,1,3,2,4,4,4},true)'); + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Ods'); + $spreadsheet->disconnectWorksheets(); + $rsheet = $reloadedSpreadsheet->getActiveSheet(); + $expected = [ + ['=UNIQUE({1,1,2,1,3,2,4,4,4})', null, null, '=UNIQUE({1,1,2,1,3,2,4,4,4},true)', 2, 3, 4], + [2, null, null, null, null, null, null], + [3, null, null, null, null, null, null], + [4, null, null, null, null, null, null], + ]; + self::assertSame($expected, $rsheet->toArray(null, false, false)); + self::assertSame('1', $rsheet->getCell('A1')->getCalculatedValueString()); + self::assertSame('1', $rsheet->getCell('D1')->getCalculatedValueString()); + $reloadedSpreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Ods/ContentTest.php b/tests/PhpSpreadsheetTests/Writer/Ods/ContentTest.php index 50e7bc7d58..a90e8ab73d 100644 --- a/tests/PhpSpreadsheetTests/Writer/Ods/ContentTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Ods/ContentTest.php @@ -65,7 +65,7 @@ public function testWriteSpreadsheet(): void $worksheet1->setCellValueExplicit( 'C2', - '=IF(A3, CONCATENATE(A1, " ", A2), CONCATENATE(A2, " ", A1))', + '=IF(A3, CONCAT(A1, " ", A2), CONCAT(A2, " ", A1))', DataType::TYPE_FORMULA ); // Formula diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsInlineTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsInlineTest.php new file mode 100644 index 0000000000..e0b37921a6 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsInlineTest.php @@ -0,0 +1,46 @@ +arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + Calculation::setArrayReturnType($this->arrayReturnType); + } + + public function testInlineArrays(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->getCell('A1')->setValue('=UNIQUE({1;1;2;1;3;2;4;4;4})'); + $sheet->getCell('D1')->setValue('=UNIQUE({1,1,2,1,3,2,4,4,4},true)'); + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Xlsx'); + $spreadsheet->disconnectWorksheets(); + $rsheet = $reloadedSpreadsheet->getActiveSheet(); + $expected = [ + ['=UNIQUE({1;1;2;1;3;2;4;4;4})', null, null, '=UNIQUE({1,1,2,1,3,2,4,4,4},true)', 2, 3, 4], + [2, null, null, null, null, null, null], + [3, null, null, null, null, null, null], + [4, null, null, null, null, null, null], + ]; + self::assertSame($expected, $rsheet->toArray(null, false, false)); + self::assertSame('1', $rsheet->getCell('A1')->getCalculatedValueString()); + self::assertSame('1', $rsheet->getCell('D1')->getCalculatedValueString()); + $reloadedSpreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php index bacd972a2e..0e70544691 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php @@ -275,7 +275,7 @@ public function testUnimplementedArrayOutput(): void $reader = new XlsxReader(); $spreadsheet2 = $reader->load($this->outputFile); $sheet2 = $spreadsheet2->getActiveSheet(); - self::assertSame('=_xlfn.CHOOSECOLS(A1:C5,3,1)', $sheet2->getCell('F1')->getValue()); + self::assertSame('=CHOOSECOLS(A1:C5,3,1)', $sheet2->getCell('F1')->getValue()); $expectedFG = [ ['11', '1'], ['12', '2'], diff --git a/tests/data/Calculation/TextData/CONCAT.php b/tests/data/Calculation/TextData/CONCAT.php new file mode 100644 index 0000000000..c37c2ffcdb --- /dev/null +++ b/tests/data/Calculation/TextData/CONCAT.php @@ -0,0 +1,39 @@ + ['exception'], + 'result just fits' => [ + // Note use Armenian character below to make sure chars, not bytes + str_repeat('Ԁ', DataType::MAX_STRING_LENGTH - 5) . 'ABCDE', + str_repeat('Ԁ', DataType::MAX_STRING_LENGTH - 5), + 'ABCDE', + ], + 'result too long' => [ + '#CALC!', + str_repeat('Ԁ', DataType::MAX_STRING_LENGTH - 5), + 'abc', + '=A2', + ], + 'propagate DIV0' => ['#DIV/0!', '1', '=2/0', '3'], +]; diff --git a/tests/data/Calculation/TextData/CONCATENATE.php b/tests/data/Calculation/TextData/CONCATENATE.php index c37c2ffcdb..4358fbeb51 100644 --- a/tests/data/Calculation/TextData/CONCATENATE.php +++ b/tests/data/Calculation/TextData/CONCATENATE.php @@ -26,14 +26,14 @@ 'result just fits' => [ // Note use Armenian character below to make sure chars, not bytes str_repeat('Ԁ', DataType::MAX_STRING_LENGTH - 5) . 'ABCDE', - str_repeat('Ԁ', DataType::MAX_STRING_LENGTH - 5), + 'A3', 'ABCDE', ], 'result too long' => [ '#CALC!', - str_repeat('Ԁ', DataType::MAX_STRING_LENGTH - 5), + 'A3', 'abc', - '=A2', + 'def', ], - 'propagate DIV0' => ['#DIV/0!', '1', '=2/0', '3'], + 'propagate DIV0' => ['#DIV/0!', '1', 'A2', '3'], ]; diff --git a/tests/data/Writer/Ods/content-arrays.xml b/tests/data/Writer/Ods/content-arrays.xml new file mode 100644 index 0000000000..4bfe8dc619 --- /dev/null +++ b/tests/data/Writer/Ods/content-arrays.xml @@ -0,0 +1,2 @@ + +11133 \ No newline at end of file diff --git a/tests/data/Writer/Ods/content-with-data.xml b/tests/data/Writer/Ods/content-with-data.xml index 12140fa926..82c67e7468 100644 --- a/tests/data/Writer/Ods/content-with-data.xml +++ b/tests/data/Writer/Ods/content-with-data.xml @@ -101,7 +101,7 @@ - + 1 1 From 784e8a0288998413531cb041af5a620c1c3645c3 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Fri, 14 Jun 2024 09:26:08 -0700 Subject: [PATCH 13/31] Drop Some Dead Code --- src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 2 +- .../Calculation/Functions/TextData/ConcatenateTest.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index 2afd0999b5..0c09ade0aa 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -1480,7 +1480,7 @@ private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell $newColumn = $thisColumn = $cell->getColumn(); if (is_array($calculatedValue)) { $attributes['t'] = 'array'; - $newRow = $row = $lastRow = $thisRow; + $newRow = $lastRow = $thisRow; $column = $lastColumn = $thisColumn; foreach ($calculatedValue as $resultRow) { if (is_array($resultRow)) { diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateTest.php index c70913ab33..be3cbd1ed6 100644 --- a/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateTest.php @@ -18,7 +18,6 @@ public function testCONCATENATE(mixed $expectedResult, mixed ...$args): void $sheet = $this->getSheet(); $finalArg = ''; $comma = ''; - $row = 0; foreach ($args as $arg) { $finalArg .= $comma; $comma = ','; From d7700125faa36b055673dd50062c8214884d9aaf Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Mon, 17 Jun 2024 14:22:52 -0700 Subject: [PATCH 14/31] Spills Implement SPILL for dynamic arrays. Calculating a dynamic array function will result in a SPILL error if it attempts to overlay a non-null cell which was not part of its previous calculation. Furthermore, it will set to null all cells which were part of its previous calculation but which are not part of the current one (i.e. one or both of the dimensions of the calculation is smaller than it had been); this should also apply for spills (whose result is reduced to 1*1). Excel will stop you from changing the value in any cell in a dynamic array except the formula cell itself. I have not built this particular aspect into PhpSpreadsheet. As usual, MS has taken some unusual steps here. If the result of a dynamic array calculation is #SPILL!, it will nevertheless be written to the xml as #VALUE!. It recognizes this situation by adding a new `vm` attribute to the cell, and expanding metadata.xml to recognize this. A new optional parameter `$reduceArrays` is added to `toArray` and related functions. This will reduce a dynamic array to its first cell, which seems more useful than outputing it as an array (default). --- .../Calculation/Information/ExcelError.php | 10 ++ .../Calculation/TextData/Concatenate.php | 3 + src/PhpSpreadsheet/Cell/Cell.php | 91 +++++++++++++++++++ src/PhpSpreadsheet/Reader/Ods.php | 19 ++++ src/PhpSpreadsheet/Reader/Xlsx.php | 7 ++ src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php | 2 + .../ConditionalFormatting/CellMatcher.php | 2 + src/PhpSpreadsheet/Worksheet/Worksheet.php | 27 ++++-- src/PhpSpreadsheet/Writer/Ods/Content.php | 18 ++++ src/PhpSpreadsheet/Writer/Xlsx/Metadata.php | 49 +++++++++- src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 40 ++++---- .../TextData/ConcatenateGnumericTest.php | 61 +++++++++++++ .../Functional/ArrayFunctionsSpillTest.php | 71 +++++++++++++++ .../Reader/Ods/ArrayTest.php | 49 ++++++++++ .../Writer/Ods/ArrayTest.php | 5 + .../Writer/Xlsx/ArrayFunctionsTest.php | 22 +++++ tests/data/Writer/Ods/content-arrays.xml | 2 +- 17 files changed, 445 insertions(+), 33 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateGnumericTest.php create mode 100644 tests/PhpSpreadsheetTests/Functional/ArrayFunctionsSpillTest.php create mode 100644 tests/PhpSpreadsheetTests/Reader/Ods/ArrayTest.php diff --git a/src/PhpSpreadsheet/Calculation/Information/ExcelError.php b/src/PhpSpreadsheet/Calculation/Information/ExcelError.php index d9aabfd0a1..f4ea42d96c 100644 --- a/src/PhpSpreadsheet/Calculation/Information/ExcelError.php +++ b/src/PhpSpreadsheet/Calculation/Information/ExcelError.php @@ -152,4 +152,14 @@ public static function CALC(): string { return self::ERROR_CODES['calculation']; } + + /** + * SPILL. + * + * @return string #SPILL! + */ + public static function SPILL(): string + { + return self::ERROR_CODES['spill']; + } } diff --git a/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php b/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php index 21f82ca1ea..62e9c38956 100644 --- a/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php +++ b/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php @@ -51,6 +51,9 @@ public static function CONCATENATE(...$args): string */ public static function actualCONCATENATE(...$args): array|string { + if (Functions::getCompatibilityMode() === Functions::COMPATIBILITY_GNUMERIC) { + return self::CONCATENATE(...$args); + } $result = ''; foreach ($args as $operand2) { $result = self::concatenate2Args($result, $operand2); diff --git a/src/PhpSpreadsheet/Cell/Cell.php b/src/PhpSpreadsheet/Cell/Cell.php index ea46231b70..1368b656ab 100644 --- a/src/PhpSpreadsheet/Cell/Cell.php +++ b/src/PhpSpreadsheet/Cell/Cell.php @@ -61,6 +61,8 @@ class Cell implements Stringable /** * Attributes of the formula. + * + * @var null|array */ private mixed $formulaAttributes = null; @@ -366,6 +368,18 @@ public function getCalculatedValueString(): string public function getCalculatedValue(bool $resetLog = true): mixed { $title = 'unknown'; + $oldAttributes = $this->formulaAttributes; + $oldAttributesT = $oldAttributes['t'] ?? ''; + $coordinate = $this->getCoordinate(); + $oldAttributesRef = $oldAttributes['ref'] ?? $coordinate; + if (!str_contains($oldAttributesRef, ':')) { + $oldAttributesRef .= ":$oldAttributesRef"; + } + $originalValue = $this->value; + $originalDataType = $this->dataType; + $this->formulaAttributes = []; + $spill = false; + if ($this->dataType === DataType::TYPE_FORMULA) { try { $thisworksheet = $this->getWorksheet(); @@ -397,8 +411,79 @@ public function getCalculatedValue(bool $resetLog = true): mixed } $newColumn = $this->getColumn(); if (is_array($result)) { + $this->formulaAttributes['t'] = 'array'; + $this->formulaAttributes['ref'] = $maxCoordinate = $coordinate; $newRow = $row = $this->getRow(); $column = $this->getColumn(); + foreach ($result as $resultRow) { + if (is_array($resultRow)) { + $newColumn = $column; + foreach ($resultRow as $resultValue) { + if ($row !== $newRow || $column !== $newColumn) { + $maxCoordinate = $newColumn . $newRow; + if ($thisworksheet->getCell($newColumn . $newRow)->getValue() !== null) { + if (!Coordinate::coordinateIsInsideRange($oldAttributesRef, $newColumn . $newRow)) { + $spill = true; + + break; + } + } + } + ++$newColumn; + } + ++$newRow; + } else { + if ($row !== $newRow || $column !== $newColumn) { + $maxCoordinate = $newColumn . $newRow; + if ($thisworksheet->getCell($newColumn . $newRow)->getValue() !== null) { + if (!Coordinate::coordinateIsInsideRange($oldAttributesRef, $newColumn . $newRow)) { + $spill = true; + } + } + } + ++$newColumn; + } + if ($spill) { + break; + } + } + if (!$spill) { + $this->formulaAttributes['ref'] .= ":$maxCoordinate"; + } + $thisworksheet->getCell($column . $row); + } + if (is_array($result)) { + if ($oldAttributes !== null && Calculation::getArrayReturnType() === Calculation::RETURN_ARRAY_AS_ARRAY) { + if (($oldAttributesT) === 'array') { + $thisworksheet = $this->getWorksheet(); + $coordinate = $this->getCoordinate(); + $ref = $oldAttributesRef; + if (preg_match('/^([A-Z]{1,3})([0-9]{1,7})(:([A-Z]{1,3})([0-9]{1,7}))?$/', $ref, $matches) === 1) { + if (isset($matches[3])) { + $minCol = $matches[1]; + $minRow = (int) $matches[2]; + $maxCol = $matches[4]; + ++$maxCol; + $maxRow = (int) $matches[5]; + for ($row = $minRow; $row <= $maxRow; ++$row) { + for ($col = $minCol; $col !== $maxCol; ++$col) { + if ("$col$row" !== $coordinate) { + $thisworksheet->getCell("$col$row")->setValue(null); + } + } + } + } + } + $thisworksheet->getCell($coordinate); + } + } + } + if ($spill) { + $result = ExcelError::SPILL(); + } + if (is_array($result)) { + $newRow = $row = $this->getRow(); + $newColumn = $column = $this->getColumn(); foreach ($result as $resultRow) { if (is_array($resultRow)) { $newColumn = $column; @@ -417,6 +502,8 @@ public function getCalculatedValue(bool $resetLog = true): mixed } } $thisworksheet->getCell($column . $row); + $this->value = $originalValue; + $this->dataType = $originalDataType; } } catch (SpreadsheetException $ex) { if (($ex->getMessage() === 'Unable to access External Workbook') && ($this->calculatedValue !== null)) { @@ -808,6 +895,8 @@ public function setXfIndex(int $indexValue): self /** * Set the formula attributes. * + * @param $attributes null|array + * * @return $this */ public function setFormulaAttributes(mixed $attributes): self @@ -819,6 +908,8 @@ public function setFormulaAttributes(mixed $attributes): self /** * Get the formula attributes. + * + * @return null|array */ public function getFormulaAttributes(): mixed { diff --git a/src/PhpSpreadsheet/Reader/Ods.php b/src/PhpSpreadsheet/Reader/Ods.php index ceb345dc3f..2214369bc8 100644 --- a/src/PhpSpreadsheet/Reader/Ods.php +++ b/src/PhpSpreadsheet/Reader/Ods.php @@ -425,11 +425,27 @@ public function loadIntoExisting(string $filename, Spreadsheet $spreadsheet): Sp $formatting = $hyperlink = null; $hasCalculatedValue = false; $cellDataFormula = ''; + $cellDataType = ''; + $cellDataRef = ''; if ($cellData->hasAttributeNS($tableNs, 'formula')) { $cellDataFormula = $cellData->getAttributeNS($tableNs, 'formula'); $hasCalculatedValue = true; } + if ($cellData->hasAttributeNS($tableNs, 'number-matrix-columns-spanned')) { + if ($cellData->hasAttributeNS($tableNs, 'number-matrix-rows-spanned')) { + $cellDataType = 'array'; + $arrayRow = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-rows-spanned'); + $arrayCol = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-columns-spanned'); + $lastRow = $rowID + $arrayRow - 1; + $lastCol = $columnID; + while ($arrayCol > 1) { + ++$lastCol; + --$arrayCol; + } + $cellDataRef = "$columnID$rowID:$lastCol$lastRow"; + } + } // Annotations $annotation = $cellData->getElementsByTagNameNS($officeNs, 'annotation'); @@ -590,6 +606,9 @@ public function loadIntoExisting(string $filename, Spreadsheet $spreadsheet): Sp // Set value if ($hasCalculatedValue) { $cell->setValueExplicit($cellDataFormula, $type); + if ($cellDataType === 'array') { + $cell->setFormulaAttributes(['t' => 'array', 'ref' => $cellDataRef]); + } } else { $cell->setValueExplicit($dataValue, $type); } diff --git a/src/PhpSpreadsheet/Reader/Xlsx.php b/src/PhpSpreadsheet/Reader/Xlsx.php index e628a8d2bb..e976fd8360 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/src/PhpSpreadsheet/Reader/Xlsx.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Reader; +use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Cell\Hyperlink; @@ -884,6 +885,12 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet } else { // Formula $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToError'); + $eattr = $c->attributes(); + if (isset($eattr['vm'])) { + if ($calculatedValue === ExcelError::VALUE()) { + $calculatedValue = ExcelError::SPILL(); + } + } } break; diff --git a/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php b/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php index 114fbe8004..7356cd8829 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php +++ b/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php @@ -119,4 +119,6 @@ class Namespaces const PURL_WORKSHEET = 'http://purl.oclc.org/ooxml/officeDocument/relationships/worksheet'; const DYNAMIC_ARRAY = 'http://schemas.microsoft.com/office/spreadsheetml/2017/dynamicarray'; + + const DYNAMIC_ARRAY_RICHDATA = 'http://schemas.microsoft.com/office/spreadsheetml/2017/richdata'; } diff --git a/src/PhpSpreadsheet/Style/ConditionalFormatting/CellMatcher.php b/src/PhpSpreadsheet/Style/ConditionalFormatting/CellMatcher.php index 2a279075ed..e14ceacdca 100644 --- a/src/PhpSpreadsheet/Style/ConditionalFormatting/CellMatcher.php +++ b/src/PhpSpreadsheet/Style/ConditionalFormatting/CellMatcher.php @@ -131,6 +131,8 @@ protected function wrapValue(mixed $value): float|int|string protected function wrapCellValue(): float|int|string { + $this->cell = $this->worksheet->getCell([$this->cellColumn, $this->cellRow]); + return $this->wrapValue($this->cell->getCalculatedValue()); } diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 5ea798f3f2..08fc46be7d 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -2848,12 +2848,13 @@ public function rangeToArray( bool $calculateFormulas = true, bool $formatData = true, bool $returnCellRef = false, - bool $ignoreHidden = false + bool $ignoreHidden = false, + bool $reduceArrays = false ): array { $returnValue = []; // Loop through rows - foreach ($this->rangeToArrayYieldRows($range, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden) as $rowRef => $rowArray) { + foreach ($this->rangeToArrayYieldRows($range, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays) as $rowRef => $rowArray) { $returnValue[$rowRef] = $rowArray; } @@ -2880,7 +2881,8 @@ public function rangeToArrayYieldRows( bool $calculateFormulas = true, bool $formatData = true, bool $returnCellRef = false, - bool $ignoreHidden = false + bool $ignoreHidden = false, + bool $reduceArrays = false ) { $range = Validations::validateCellOrCellRange($range); @@ -2926,6 +2928,11 @@ public function rangeToArrayYieldRows( $cell = $this->cellCollection->get("{$col}{$thisRow}"); if ($cell !== null) { $value = $this->cellToArray($cell, $calculateFormulas, $formatData, $nullValue); + if ($reduceArrays) { + while (is_array($value)) { + $value = array_shift($value); + } + } if ($value !== $nullValue) { $returnValue[$columnRef] = $value; } @@ -3026,7 +3033,8 @@ public function namedRangeToArray( bool $calculateFormulas = true, bool $formatData = true, bool $returnCellRef = false, - bool $ignoreHidden = false + bool $ignoreHidden = false, + bool $reduceArrays = false ): array { $retVal = []; $namedRange = $this->validateNamedRange($definedName); @@ -3035,7 +3043,7 @@ public function namedRangeToArray( $cellRange = str_replace('$', '', $cellRange); $workSheet = $namedRange->getWorksheet(); if ($workSheet !== null) { - $retVal = $workSheet->rangeToArray($cellRange, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden); + $retVal = $workSheet->rangeToArray($cellRange, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays); } } @@ -3058,7 +3066,8 @@ public function toArray( bool $calculateFormulas = true, bool $formatData = true, bool $returnCellRef = false, - bool $ignoreHidden = false + bool $ignoreHidden = false, + bool $reduceArrays = false ): array { // Garbage collect... $this->garbageCollect(); @@ -3068,7 +3077,7 @@ public function toArray( $maxRow = $this->getHighestRow(); // Return - return $this->rangeToArray("A1:{$maxCol}{$maxRow}", $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden); + return $this->rangeToArray("A1:{$maxCol}{$maxRow}", $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays); } /** @@ -3679,7 +3688,9 @@ public function calculateArrays(bool $preCalculateFormulas = true): void if ($preCalculateFormulas && Calculation::getArrayReturnType() === Calculation::RETURN_ARRAY_AS_ARRAY) { $keys = $this->cellCollection->getCoordinates(); foreach ($keys as $key) { - $this->getCell($key)->getCalculatedValue(); + if ($this->getCell($key)->getDataType() === DataType::TYPE_FORMULA) { + $this->getCell($key)->getCalculatedValue(); + } } } } diff --git a/src/PhpSpreadsheet/Writer/Ods/Content.php b/src/PhpSpreadsheet/Writer/Ods/Content.php index 7cbcc42fd1..a8c606fc56 100644 --- a/src/PhpSpreadsheet/Writer/Ods/Content.php +++ b/src/PhpSpreadsheet/Writer/Ods/Content.php @@ -196,6 +196,8 @@ private function writeCells(XMLWriter $objWriter, RowCellIterator $cells): void foreach ($cells as $cell) { /** @var Cell $cell */ $column = Coordinate::columnIndexFromString($cell->getColumn()) - 1; + $attributes = $cell->getFormulaAttributes() ?? []; + $coordinate = $cell->getCoordinate(); $this->writeCellSpan($objWriter, $column, $prevColumn); $objWriter->startElement('table:table-cell'); @@ -230,6 +232,22 @@ private function writeCells(XMLWriter $objWriter, RowCellIterator $cells): void // don't do anything } } + if (isset($attributes['ref'])) { + if (preg_match('/^([A-Z]{1,3})([0-9]{1,7})(:([A-Z]{1,3})([0-9]{1,7}))?$/', (string) $attributes['ref'], $matches) == 1) { + $matrixRowSpan = 1; + $matrixColSpan = 1; + if (isset($matches[3])) { + $minRow = (int) $matches[2]; + $maxRow = (int) $matches[5]; + $matrixRowSpan = $maxRow - $minRow + 1; + $minCol = Coordinate::columnIndexFromString($matches[1]); + $maxCol = Coordinate::columnIndexFromString($matches[4]); + $matrixColSpan = $maxCol - $minCol + 1; + } + $objWriter->writeAttribute('table:number-matrix-columns-spanned', "$matrixColSpan"); + $objWriter->writeAttribute('table:number-matrix-rows-spanned', "$matrixRowSpan"); + } + } $objWriter->writeAttribute('table:formula', $this->formulaConvertor->convertFormula($cell->getValueString())); if (is_numeric($formulaValue)) { $objWriter->writeAttribute('office:value-type', 'float'); diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Metadata.php b/src/PhpSpreadsheet/Writer/Xlsx/Metadata.php index 67b5a840a6..00c15f0003 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Metadata.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Metadata.php @@ -31,10 +31,12 @@ public function writeMetadata(): string // Types $objWriter->startElement('metadata'); $objWriter->writeAttribute('xmlns', Namespaces::MAIN); + $objWriter->writeAttribute('xmlns:xlrd', Namespaces::DYNAMIC_ARRAY_RICHDATA); $objWriter->writeAttribute('xmlns:xda', Namespaces::DYNAMIC_ARRAY); $objWriter->startElement('metadataTypes'); - $objWriter->writeAttribute('count', '1'); + $objWriter->writeAttribute('count', '2'); + $objWriter->startElement('metadataType'); $objWriter->writeAttribute('name', 'XLDAPR'); $objWriter->writeAttribute('minSupportedVersion', '120000'); @@ -49,7 +51,23 @@ public function writeMetadata(): string $objWriter->writeAttribute('assign', '1'); $objWriter->writeAttribute('coerce', '1'); $objWriter->writeAttribute('cellMeta', '1'); - $objWriter->endElement(); // metadataType + $objWriter->endElement(); // metadataType XLDAPR + + $objWriter->startElement('metadataType'); + $objWriter->writeAttribute('name', 'XLRICHVALUE'); + $objWriter->writeAttribute('minSupportedVersion', '120000'); + $objWriter->writeAttribute('copy', '1'); + $objWriter->writeAttribute('pasteAll', '1'); + $objWriter->writeAttribute('pasteValues', '1'); + $objWriter->writeAttribute('merge', '1'); + $objWriter->writeAttribute('splitFirst', '1'); + $objWriter->writeAttribute('rowColShift', '1'); + $objWriter->writeAttribute('clearFormats', '1'); + $objWriter->writeAttribute('clearComments', '1'); + $objWriter->writeAttribute('assign', '1'); + $objWriter->writeAttribute('coerce', '1'); + $objWriter->endElement(); // metadataType XLRICHVALUE + $objWriter->endElement(); // metadataTypes $objWriter->startElement('futureMetadata'); @@ -66,7 +84,22 @@ public function writeMetadata(): string $objWriter->endElement(); // ext $objWriter->endElement(); // extLst $objWriter->endElement(); // bk - $objWriter->endElement(); // futureMetadata + $objWriter->endElement(); // futureMetadata XLDAPR + + $objWriter->startElement('futureMetadata'); + $objWriter->writeAttribute('name', 'XLRICHVALUE'); + $objWriter->writeAttribute('count', '1'); + $objWriter->startElement('bk'); + $objWriter->startElement('extLst'); + $objWriter->startElement('ext'); + $objWriter->writeAttribute('uri', '{3e2802c4-a4d2-4d8b-9148-e3be6c30e623}'); + $objWriter->startElement('xlrd:rvb'); + $objWriter->writeAttribute('i', '0'); + $objWriter->endElement(); // xlrd:rvb + $objWriter->endElement(); // ext + $objWriter->endElement(); // extLst + $objWriter->endElement(); // bk + $objWriter->endElement(); // futureMetadata XLRICHVALUE $objWriter->startElement('cellMetadata'); $objWriter->writeAttribute('count', '1'); @@ -78,6 +111,16 @@ public function writeMetadata(): string $objWriter->endElement(); // bk $objWriter->endElement(); // cellMetadata + $objWriter->startElement('valueMetadata'); + $objWriter->writeAttribute('count', '1'); + $objWriter->startElement('bk'); + $objWriter->startElement('rc'); + $objWriter->writeAttribute('t', '2'); + $objWriter->writeAttribute('v', '0'); + $objWriter->endElement(); // rc + $objWriter->endElement(); // bk + $objWriter->endElement(); // valueMetadata + $objWriter->endElement(); // metadata // Return diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index 0c09ade0aa..5972f3a4d7 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -3,6 +3,7 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Xlsx; use PhpOffice\PhpSpreadsheet\Calculation\Information\ErrorValue; +use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError; use PhpOffice\PhpSpreadsheet\Cell\Cell; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; @@ -1452,7 +1453,24 @@ private function writeCellError(XMLWriter $objWriter, string $mappedType, string private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell $cell): void { + $attributes = $cell->getFormulaAttributes() ?? []; + $coordinate = $cell->getCoordinate(); $calculatedValue = $this->getParentWriter()->getPreCalculateFormulas() ? $cell->getCalculatedValue() : $cellValue; + if ($calculatedValue === ExcelError::SPILL()) { + $objWriter->writeAttribute('t', 'e'); + //$objWriter->writeAttribute('cm', '1'); // already added + $objWriter->writeAttribute('vm', '1'); + $objWriter->startElement('f'); + $objWriter->writeAttribute('t', 'array'); + $objWriter->writeAttribute('aca', '1'); + $objWriter->writeAttribute('ref', $coordinate); + $objWriter->writeAttribute('ca', '1'); + $objWriter->text(FunctionPrefix::addFunctionPrefixStripEquals($cellValue)); + $objWriter->endElement(); // f + $objWriter->writeElement('v', ExcelError::VALUE()); // note #VALUE! in xml even though error is #SPILL! + + return; + } $calculatedValueString = $this->getParentWriter()->getPreCalculateFormulas() ? $cell->getCalculatedValueString() : $cellValue; if (is_string($calculatedValue)) { if (ErrorValue::isError($calculatedValue)) { @@ -1469,8 +1487,6 @@ private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell $calculatedValueString = (string) $calculatedValue; } - $attributes = $cell->getFormulaAttributes() ?? []; - $coordinate = $cell->getCoordinate(); if (isset($attributes['ref'])) { $ref = $this->parseRef($coordinate, $attributes['ref']); } else { @@ -1480,24 +1496,6 @@ private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell $newColumn = $thisColumn = $cell->getColumn(); if (is_array($calculatedValue)) { $attributes['t'] = 'array'; - $newRow = $lastRow = $thisRow; - $column = $lastColumn = $thisColumn; - foreach ($calculatedValue as $resultRow) { - if (is_array($resultRow)) { - $newColumn = $column; - foreach ($resultRow as $resultValue) { - $lastColumn = $newColumn; - $lastRow = $newRow; - ++$newColumn; - } - ++$newRow; - } else { - $lastColumn = $newColumn; - $lastRow = $newRow; - ++$newColumn; - } - } - $ref = "$coordinate:$lastColumn$lastRow"; } if (($attributes['t'] ?? null) === 'array') { $objWriter->startElement('f'); @@ -1622,7 +1620,7 @@ private function writeCell(XMLWriter $objWriter, PhpspreadsheetWorksheet $worksh } } - $objWriter->endElement(); + $objWriter->endElement(); // c } /** diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateGnumericTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateGnumericTest.php new file mode 100644 index 0000000000..2b4f12a2f7 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ConcatenateGnumericTest.php @@ -0,0 +1,61 @@ +mightHaveException($expectedResult); + $sheet = $this->getSheet(); + $finalArg = ''; + $row = 0; + foreach ($args as $arg) { + ++$row; + $this->setCell("A$row", $arg); + $finalArg = "A1:A$row"; + } + $this->setCell('B1', "=CONCATENATE($finalArg)"); + $result = $sheet->getCell('B1')->getCalculatedValue(); + self::assertEquals($expectedResult, $result); + } + + public static function providerCONCAT(): array + { + return require 'tests/data/Calculation/TextData/CONCAT.php'; + } + + public function testConcatWithIndexMatch(): void + { + self::setGnumeric(); + $spreadsheet = new Spreadsheet(); + $sheet1 = $spreadsheet->getActiveSheet(); + $sheet1->setTitle('Formula'); + $sheet1->fromArray( + [ + ['Number', 'Formula'], + [52101293, '=CONCATENATE(INDEX(Lookup!B2, MATCH(A2, Lookup!A2, 0)))'], + ] + ); + $sheet2 = $spreadsheet->createSheet(); + $sheet2->setTitle('Lookup'); + $sheet2->fromArray( + [ + ['Lookup', 'Match'], + [52101293, 'PHP'], + ] + ); + self::assertSame('PHP', $sheet1->getCell('B2')->getCalculatedValue()); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Functional/ArrayFunctionsSpillTest.php b/tests/PhpSpreadsheetTests/Functional/ArrayFunctionsSpillTest.php new file mode 100644 index 0000000000..ca1a56e535 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Functional/ArrayFunctionsSpillTest.php @@ -0,0 +1,71 @@ +arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + Calculation::setArrayReturnType($this->arrayReturnType); + } + + public function testArrayOutput(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $calculation = Calculation::getInstance($spreadsheet); + + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setCellValue('B5', 'OCCUPIED'); + + $columnArray = [[1], [2], [2], [2], [3], [3], [3], [3], [4], [4], [4], [5]]; + $sheet->fromArray($columnArray, 'A1'); + $sheet->setCellValue('B1', '=UNIQUE(A1:A12)'); + $expected = [['#SPILL!'], [null], [null], [null], ['OCCUPIED']]; + self::assertSame($expected, $sheet->rangeToArray('B1:B5', calculateFormulas: true, formatData: false, reduceArrays: true), 'spill with B5 unchanged'); + $calculation->clearCalculationCache(); + + $columnArray = [[1], [2], [2], [2], [3], [3], [3], [3], [4], [4], [4], [4]]; + $sheet->fromArray($columnArray, 'A1'); + $sheet->setCellValue('B1', '=UNIQUE(A1:A12)'); + $expected = [[1], [2], [3], [4], ['OCCUPIED']]; + self::assertSame($expected, $sheet->rangeToArray('B1:B5', calculateFormulas: true, formatData: false, reduceArrays: true), 'fill B1:B4 with B5 unchanged'); + $calculation->clearCalculationCache(); + + $columnArray = [[1], [3], [3], [3], [3], [3], [3], [3], [3], [3], [3], [3]]; + $sheet->fromArray($columnArray, 'A1'); + $sheet->setCellValue('B1', '=UNIQUE(A1:A12)'); + $expected = [[1], [3], [null], [null], ['OCCUPIED']]; + self::assertSame($expected, $sheet->rangeToArray('B1:B5', calculateFormulas: true, formatData: false, reduceArrays: true), 'fill B1:B2(changed from prior) set B3:B4 to null B5 unchanged'); + $calculation->clearCalculationCache(); + + $columnArray = [[1], [2], [3], [3], [3], [3], [3], [3], [3], [3], [3], [3]]; + $sheet->fromArray($columnArray, 'A1'); + $sheet->setCellValue('B1', '=UNIQUE(A1:A12)'); + $expected = [[1], [2], [3], [null], ['OCCUPIED']]; + self::assertSame($expected, $sheet->rangeToArray('B1:B5', calculateFormulas: true, formatData: false, reduceArrays: true), 'fill B1:B3(B2 changed from prior) set B4 to null B5 unchanged'); + $calculation->clearCalculationCache(); + + $columnArray = [[1], [2], [2], [2], [3], [3], [3], [3], [4], [4], [4], [5]]; + $sheet->fromArray($columnArray, 'A1'); + $sheet->setCellValue('B1', '=UNIQUE(A1:A12)'); + $expected = [['#SPILL!'], [null], [null], [null], ['OCCUPIED']]; + self::assertSame($expected, $sheet->rangeToArray('B1:B5', calculateFormulas: true, formatData: false, reduceArrays: true), 'spill clears B2:B4 with B5 unchanged'); + $calculation->clearCalculationCache(); + + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Reader/Ods/ArrayTest.php b/tests/PhpSpreadsheetTests/Reader/Ods/ArrayTest.php new file mode 100644 index 0000000000..1d53845b4b --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Ods/ArrayTest.php @@ -0,0 +1,49 @@ +arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + Calculation::setArrayReturnType($this->arrayReturnType); + } + + public function testSaveAndLoadHyperlinks(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheetOld = new Spreadsheet(); + $sheet = $spreadsheetOld->getActiveSheet(); + $sheet->getCell('A1')->setValue('a'); + $sheet->getCell('A2')->setValue('b'); + $sheet->getCell('A3')->setValue('c'); + $sheet->getCell('C1')->setValue(1); + $sheet->getCell('C2')->setValue(2); + $sheet->getCell('C3')->setValue(3); + $sheet->getCell('B1')->setValue('=CONCATENATE(A1:A3,"-",C1:C3)'); + $spreadsheet = $this->writeAndReload($spreadsheetOld, 'Ods'); + $spreadsheetOld->disconnectWorksheets(); + + $newSheet = $spreadsheet->getActiveSheet(); + self::assertSame(['t' => 'array', 'ref' => 'B1:B3'], $newSheet->getCell('B1')->getFormulaAttributes()); + self::assertSame('a-1', $newSheet->getCell('B1')->getOldCalculatedValue()); + self::assertSame('b-2', $newSheet->getCell('B2')->getValue()); + self::assertSame('c-3', $newSheet->getCell('B3')->getValue()); + self::assertSame('=CONCATENATE(A1:A3,"-",C1:C3)', $newSheet->getCell('B1')->getValue()); + + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Ods/ArrayTest.php b/tests/PhpSpreadsheetTests/Writer/Ods/ArrayTest.php index 9f3271917b..07f945477a 100644 --- a/tests/PhpSpreadsheetTests/Writer/Ods/ArrayTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Ods/ArrayTest.php @@ -19,6 +19,8 @@ class ArrayTest extends AbstractFunctional private string $compatibilityMode; + private bool $skipInline = true; + protected function setUp(): void { parent::setUp(); @@ -73,6 +75,9 @@ public function testArray(): void public function testInlineArrays(): void { + if ($this->skipInline) { + self::markTestIncomplete('Ods Reader/Writer alter commas and semi-colons within formulas, interfering with inline arrays'); + } Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php index 0e70544691..d7265651d8 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php @@ -352,4 +352,26 @@ public function testMetadataWritten(): void self::assertSame('', $writerMetadata2->writeMetaData()); $spreadsheet->disconnectWorksheets(); } + + public function testSpill(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->getCell('A3')->setValue('x'); + $sheet->getCell('A1')->setValue('=UNIQUE({1;2;3})'); + $writer = new XlsxWriter($spreadsheet); + $this->outputFile = File::temporaryFilename(); + $writer->save($this->outputFile); + $spreadsheet->disconnectWorksheets(); + + $reader = new XlsxReader(); + $spreadsheet2 = $reader->load($this->outputFile); + $sheet2 = $spreadsheet2->getActiveSheet(); + self::assertSame('#SPILL!', $sheet2->getCell('A1')->getOldCalculatedValue()); + self::assertSame('=UNIQUE({1;2;3})', $sheet2->getCell('A1')->getValue()); + self::assertNull($sheet2->getCell('A2')->getValue()); + self::assertSame('x', $sheet2->getCell('A3')->getValue()); + $spreadsheet2->disconnectWorksheets(); + } } diff --git a/tests/data/Writer/Ods/content-arrays.xml b/tests/data/Writer/Ods/content-arrays.xml index 4bfe8dc619..a33b7dbfca 100644 --- a/tests/data/Writer/Ods/content-arrays.xml +++ b/tests/data/Writer/Ods/content-arrays.xml @@ -1,2 +1,2 @@ -11133 \ No newline at end of file +11133 \ No newline at end of file From 8609b78e53af482a71e0e161555bceffbda2b2e6 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Mon, 17 Jun 2024 15:19:55 -0700 Subject: [PATCH 15/31] Eliminate Dead Code --- src/PhpSpreadsheet/Writer/Ods/Content.php | 1 - src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/PhpSpreadsheet/Writer/Ods/Content.php b/src/PhpSpreadsheet/Writer/Ods/Content.php index a8c606fc56..a03d3f039c 100644 --- a/src/PhpSpreadsheet/Writer/Ods/Content.php +++ b/src/PhpSpreadsheet/Writer/Ods/Content.php @@ -197,7 +197,6 @@ private function writeCells(XMLWriter $objWriter, RowCellIterator $cells): void /** @var Cell $cell */ $column = Coordinate::columnIndexFromString($cell->getColumn()) - 1; $attributes = $cell->getFormulaAttributes() ?? []; - $coordinate = $cell->getCoordinate(); $this->writeCellSpan($objWriter, $column, $prevColumn); $objWriter->startElement('table:table-cell'); diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index 5972f3a4d7..8daf0d7f50 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -1492,8 +1492,6 @@ private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell } else { $ref = $coordinate; } - $thisRow = $cell->getRow(); - $newColumn = $thisColumn = $cell->getColumn(); if (is_array($calculatedValue)) { $attributes['t'] = 'array'; } From 2c9e2e2b43d41c0706469c336e4f55a0963dc9be Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 20 Jun 2024 00:43:03 -0700 Subject: [PATCH 16/31] Spill Operator Spill operator now works both as trailing `#` and ARRAYANCHOR function. `#` is converted to ARRAYANCHOR when writing. I do not think it is important to convert the other way when reading. Documentation updates have started, but are a work in progress. SINGLE function is implemented. I believe it works correctly when referring to a cell, but not when referring to a cell range. No attempt is yet made to convert leading `@` to and from SINGLE; I haven't figured out how to do so without interfering with `@` in structured references. ISREF has problems. At least one of its tests was wrong, and many of those that were right were so accidentally. The code is changed, quite kludgily, so that almost all the tests are now deliberately correct. One very complicated test is incorrect; for now, I will skip it, and will open an issue when this PR is merged. --- docs/references/function-list-by-category.md | 9 +- docs/references/function-list-by-name.md | 4 +- docs/topics/calculation-engine.md | 22 ++- .../12-CalculationEngine-Array-Formula-2.png | Bin 0 -> 35123 bytes .../12-CalculationEngine-Array-Formula-3.png | Bin 0 -> 19300 bytes .../12-CalculationEngine-Array-Formula.png | Bin 0 -> 20934 bytes .../12-CalculationEngine-Basic-Formula-2.png | Bin 0 -> 20314 bytes .../12-CalculationEngine-Basic-Formula.png | Bin 0 -> 17581 bytes ...2-CalculationEngine-Spillage-Formula-2.png | Bin 0 -> 11750 bytes .../12-CalculationEngine-Spillage-Formula.png | Bin 0 -> 11564 bytes ...12-CalculationEngine-Spillage-Operator.png | Bin 0 -> 9381 bytes docs/topics/reading-files.md | 88 +++++++++- docs/topics/recipes.md | 155 +++++++++++++++++- .../Calculation/Calculation.php | 41 ++++- src/PhpSpreadsheet/Calculation/Category.php | 1 + .../Calculation/Information/Value.php | 2 +- .../Internal/ExcelArrayPseudoFunctions.php | 91 ++++++++++ src/PhpSpreadsheet/Worksheet/Worksheet.php | 1 + .../Writer/Xlsx/FunctionPrefix.php | 8 + src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 29 ++-- .../Functions/Information/IsRefTest.php | 5 + .../DocumentGeneratorTest.php | 5 + .../Functional/ArrayFunctionsSpillTest.php | 37 +++++ 23 files changed, 464 insertions(+), 34 deletions(-) create mode 100644 docs/topics/images/12-CalculationEngine-Array-Formula-2.png create mode 100644 docs/topics/images/12-CalculationEngine-Array-Formula-3.png create mode 100644 docs/topics/images/12-CalculationEngine-Array-Formula.png create mode 100644 docs/topics/images/12-CalculationEngine-Basic-Formula-2.png create mode 100644 docs/topics/images/12-CalculationEngine-Basic-Formula.png create mode 100644 docs/topics/images/12-CalculationEngine-Spillage-Formula-2.png create mode 100644 docs/topics/images/12-CalculationEngine-Spillage-Formula.png create mode 100644 docs/topics/images/12-CalculationEngine-Spillage-Operator.png create mode 100644 src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php diff --git a/docs/references/function-list-by-category.md b/docs/references/function-list-by-category.md index e69cf6d5b6..458a59b39c 100644 --- a/docs/references/function-list-by-category.md +++ b/docs/references/function-list-by-category.md @@ -586,5 +586,10 @@ WEBSERVICE | \PhpOffice\PhpSpreadsheet\Calculation\Web\Service::we Excel Function | PhpSpreadsheet Function -------------------------|-------------------------------------- -ANCHORARRAY | **Not yet Implemented** -SINGLE | **Not yet Implemented** + +## CATEGORY_MICROSOFT_INTERNAL + +Excel Function | PhpSpreadsheet Function +-------------------------|-------------------------------------- +ANCHORARRAY | \PhpOffice\PhpSpreadsheet\Calculation\Internal\ExcelArrayPseudoFunctions::anchorArray +SINGLE | \PhpOffice\PhpSpreadsheet\Calculation\Internal\ExcelArrayPseudoFunctions::single diff --git a/docs/references/function-list-by-name.md b/docs/references/function-list-by-name.md index cafd0d6fca..addc2e3e97 100644 --- a/docs/references/function-list-by-name.md +++ b/docs/references/function-list-by-name.md @@ -15,7 +15,7 @@ ADDRESS | CATEGORY_LOOKUP_AND_REFERENCE | \PhpOffice\PhpSpread AGGREGATE | CATEGORY_MATH_AND_TRIG | **Not yet Implemented** AMORDEGRC | CATEGORY_FINANCIAL | \PhpOffice\PhpSpreadsheet\Calculation\Financial\Amortization::AMORDEGRC AMORLINC | CATEGORY_FINANCIAL | \PhpOffice\PhpSpreadsheet\Calculation\Financial\Amortization::AMORLINC -ANCHORARRAY | CATEGORY_UNCATEGORISED | **Not yet Implemented** +ANCHORARRAY | CATEGORY_MICROSOFT_INTERNAL | \PhpOffice\PhpSpreadsheet\Calculation\Internal\ExcelArrayPseudoFunctions::anchorArray AND | CATEGORY_LOGICAL | \PhpOffice\PhpSpreadsheet\Calculation\Logical\Operations::logicalAnd ARABIC | CATEGORY_MATH_AND_TRIG | \PhpOffice\PhpSpreadsheet\Calculation\MathTrig\Arabic::evaluate AREAS | CATEGORY_LOOKUP_AND_REFERENCE | **Not yet Implemented** @@ -510,7 +510,7 @@ SHEET | CATEGORY_INFORMATION | **Not yet Implemente SHEETS | CATEGORY_INFORMATION | **Not yet Implemented** SIGN | CATEGORY_MATH_AND_TRIG | \PhpOffice\PhpSpreadsheet\Calculation\MathTrig\Sign::evaluate SIN | CATEGORY_MATH_AND_TRIG | \PhpOffice\PhpSpreadsheet\Calculation\MathTrig\Trig\Sine::sin -SINGLE | CATEGORY_UNCATEGORISED | **Not yet Implemented** +SINGLE | CATEGORY_MICROSOFT_INTERNAL | \PhpOffice\PhpSpreadsheet\Calculation\Internal\ExcelArrayPseudoFunctions::single SINH | CATEGORY_MATH_AND_TRIG | \PhpOffice\PhpSpreadsheet\Calculation\MathTrig\Trig\Sine::sinh SKEW | CATEGORY_STATISTICAL | \PhpOffice\PhpSpreadsheet\Calculation\Statistical\Deviations::skew SKEW.P | CATEGORY_STATISTICAL | **Not yet Implemented** diff --git a/docs/topics/calculation-engine.md b/docs/topics/calculation-engine.md index 94863b67eb..5c91fcda7a 100644 --- a/docs/topics/calculation-engine.md +++ b/docs/topics/calculation-engine.md @@ -10,6 +10,8 @@ formula calculation capabilities. A cell can be of a value type which can be evaluated). For example, the formula `=SUM(A1:A10)` evaluates to the sum of values in A1, A2, ..., A10. +Calling `getValue()` on a cell that contains a formula will return the formula itself. + To calculate a formula, you can call the cell containing the formula’s method `getCalculatedValue()`, for example: @@ -22,7 +24,18 @@ with PhpSpreadsheet, it evaluates to the value "64": ![09-command-line-calculation.png](./images/09-command-line-calculation.png) -When writing a formula to a cell, formulae should always be set as they would appear in an English version of Microsoft Office Excel, and PhpSpreadsheet handles all formulae internally in this format. This means that the following rules hold: +Calling `getCalculatedValue()` on a cell that doesn't contain a formula will simply return the value of that cell; but if the cell does contain a formula, then PhpSpreadsheet will evaluate that formula to calculate the result. + +There are a few useful mehods to help identify whether a cell contains a formula or a simple value; and if a formula, to provide further information about it: + +```php +$spreadsheet->getActiveSheet()->getCell('E11')->isFormula(); +``` +will return a boolean true/false, telling you whether that cell contains a formula or not, so you can determine if a call to `getCalculatedVaue()` will need to perform an evaluation. + +For more details on working with array formulas, see the [the recipes documentationn](./recipes.md/#array-formulas). + +When writing a formula to a cell, formulas should always be set as they would appear in an English version of Microsoft Office Excel, and PhpSpreadsheet handles all formulas internally in this format. This means that the following rules hold: - Decimal separator is `.` (period) - Function argument separator is `,` (comma) @@ -91,6 +104,11 @@ formula calculation is subject to PHP's language characteristics. Not all functions are supported, for a comprehensive list, read the [function list by name](../references/function-list-by-name.md). +#### Array arguments for Function Calls in Formulas + +While most of the Excel function implementations now support array arguments, there are a few that should accept arrays as arguments but don't do so. +In these cases, the result may be a single value rather than an array; or it may be a `#VALUE!` error. + #### Operator precedence In Excel `+` wins over `&`, just like `*` wins over `+` in ordinary @@ -161,7 +179,7 @@ number of seconds from the PHP/Unix base date. The PHP/Unix base date (0) is 00:00 UST on 1st January 1970. This value can be positive or negative: so a value of -3600 would be 23:00 hrs on 31st December 1969; while a value of +3600 would be 01:00 hrs on 1st January 1970. This -gives PHP a date range of between 14th December 1901 and 19th January +gives 32-bit PHP a date range of between 14th December 1901 and 19th January 2038. #### PHP `DateTime` Objects diff --git a/docs/topics/images/12-CalculationEngine-Array-Formula-2.png b/docs/topics/images/12-CalculationEngine-Array-Formula-2.png new file mode 100644 index 0000000000000000000000000000000000000000..45fc3ce5e8f1254e20971fbe1641fa2f0b72f363 GIT binary patch literal 35123 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

NUGAh>fPf~ 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 literal 0 HcmV?d00001 diff --git a/docs/topics/images/12-CalculationEngine-Array-Formula.png b/docs/topics/images/12-CalculationEngine-Array-Formula.png new file mode 100644 index 0000000000000000000000000000000000000000..77987b27070b0e709f77cb455277d7885cd1f57c GIT binary patch literal 20934 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 (c$FEFcJv**$!G(mTi3V2!pN|&_RMoBu&@1 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 literal 0 HcmV?d00001 diff --git a/docs/topics/images/12-CalculationEngine-Spillage-Formula-2.png b/docs/topics/images/12-CalculationEngine-Spillage-Formula-2.png new file mode 100644 index 0000000000000000000000000000000000000000..8fc397d1d6dbc56b29fad3ff7d9afd4e71330307 GIT binary patch literal 11750 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 PA%L8WvUH84Y0&=xym|29 literal 0 HcmV?d00001 diff --git a/docs/topics/images/12-CalculationEngine-Spillage-Formula.png b/docs/topics/images/12-CalculationEngine-Spillage-Formula.png new file mode 100644 index 0000000000000000000000000000000000000000..4189cb47fae559be478be25fd06a62bbdf257e54 GIT binary patch literal 11564 zcmaia1z1#F*Y+SPiqZl~i6C7fAp=8~bayETLwC%8fP!?1bPe4l(hXA5Al=Q-Ff>E` z$LIOp_j$kfzpnr1+He@yd!KdoTIXK(iXdf0X&h`aY!C>9BlA{56$H8y4E#R-;12MU zC$@?RxL`P`N{fL?1}HXx4|k!W3ZfuTS;V8O_xFI$5AEOTIDtUV#{Ya{JRR=Y1%X&w zWh6w^VTQYNc=p6=jc*Q!P|UjX85?=r_;=oPH%J!DtE#@`A5K3}`Z+`Ls(&`owV}~B z=xj~)duLXhW8>MbhF#F{w|3V~iFqy0{p=%L8kDQQ#QUrXd7^mj23>+&na7{)ds>J` zNmQQk?Ax7P?3xXN>pf2Qom`cgQs3j&d$fyZj6UqISd|1pX803%d^j1yZzgfj}aw)Cdd^h^A}URAwMWz-{TL zUxe^Yye*t?wNg$!&d`0cNT(d88a|{eErj04#1p>y${=dbUcd7rfeZQ@O97_Jd+*-8 zV~3%fPeKGA89__kgoDSqZ@Gq2my^Lx8-wSEy&OmZ>e3WpZv<~EbP$pKwdt^jMfh@u zt4W+j_iWztY)(n&;=|r-?Gq|4v%WEQ5ca^2nP=^=KWnW+!h|K;DK*nx~x=cfEy@Mm+cMbmgh&9*Pv@Ps9V zZ3b~E(Yt5OMz_62d)j3g%O&+&N-6v&zi9D*ucNuDyd6!X?69*nKIp?wQyI`9mg!NB zg0~xR%+GION4|JfR}4B6~R6@=hUkktFBv=sW zzCu1!n7RT~go}fNf{7qJU6Fv6%oj`Qn;3GGneFD#nxS_@La7lio^(Y#_ZAgz zgYZyK7`V+Lh0bd6gw9*wVjgdD&Mtbu~45k}Fu)dWnQ~;#LZe z=aFNNCGr%{oaJ&$N?f)_TV$?n3}08QCAdqt{|fCIrJS>^4tS&UM!@St`;uIIw~mTSbZp_8)+>Y-Ff6Q^`Sqc59)f{KEIHE9w?vHAicJjcSar7bZRa@!r%U(~Q~i4>KZ2TftE8q6 z%_H)Jve5CLcRxFyx`$hq4@cEo=rYYH{v~ime1lgP4;cNMYRy6Qx6VU1_HRGFyzb80 zp@(q9`}sDc6>)o&aQh(=f{kzccsV97>e5&f3b@w_agj$WqD6-LIbLd2*2lfAIXx%}rC2 zP+j(WM8S_*xgSA6LEc)!ASLY4LGK%wWQQYqV!XyW$c8zFGJGwI zGp~O?YZfZFY0veOi&+m5_ZTk^lI0bFN_e6}rQgspnoZF}#W`bHhSQfkv#e4>V`29` z^i2VJ7T3u4hFNuiS3!U7@Iwz#U{~*HAcC}FiIL-&*mSGbLQo}lO31165ki+Zd3>Oo zoJ&suf*N)oKm9ga-?2XPb9h%cYoA$eylRhfvsMc{hWP>Pi9W`}o4wsB7l6-_RJQFk zR)4mILxhT^hc4!96*GB@pG+8eH$DxdE#tD4*={c4s*oZnnf*Od-f``qYTBFm#8hV6 zP#2O7FSlQ-Lo^m!hFJCDVOoj_RBj2K1f^6KpH}k*2WK_<`8}GjNq75R72QS2WhF4u zR-B1lDhKy*Ib)hRmN16(?U}k84t*#kP;KKF_)s+krD3t+8IAJ&O%09G~4TieOu!hHN}8tBM}7m6(dkxbDqjL6|gO z6S?b}a$U^r8q;9x6>^REehSRA{7HgrIR?w>mbHR#v5l#B_m-gH^-rx(*2!jmd0SbXFAp zH~?PdHNS3r=d{du&CrB|DPB;S#SOBkH5q*7HZ?UBzMQH8?0<@(0)Q0Q@vwK(#d=bQ z#I2ZM8MB(MSU_YyavvnsdtC0Z~JtIoObY@ZFyOSSm1nfFs;w~JP54Y~z#uF_)9vxJ+XsQfFo({WK1%4< z^Geto)dH0Pu#CCIlsw~N`zKTjmuK3Wg^x$qXSd3sHyhGyqwYC)=nm2|!IM$0g$u=$ zc=nE;E`qeKIlOD!$ocWgtyqQKfWlb%dhm^%*kvb(HyT8)k^@I=NH+3Zelzcdl#cuGZ@nl~=qx(xDRVrQFbl-w1yyQb^4 zJDSpi?0bvBm6(k98g|dH9?BE(?^ws$8*7lQpv=TDxn+qiY!~w8bW~K9UZ$Ixah35O z$oKU0lmJ|WjL&6jw9e%$5RZR);)$BOVA#$s;fvyAKM6LAa{uFLc3d|DTdvl%?e4k& zJz;`gKi1OLx+xhcK1WzI_U$=QcsOT`(W@iv09Tm!kNiDf#ggHSk8ZQQE*d*?GjvTd zs=oZ!da-e?4G)!2*6!w17ZmR;98qDLr;8EBSo4O)1eq%=)f6+D_LXw}w6Yruvg1YV*O)70q z{1CiKNuWHn3dL8^_a!#Dzx}wSJkskpr70>1W3z~(`lYsQcca~HvNNHh<41n^>2%i+ z!C5af_#$&1Hx<5ac~)P3Pa)g|Hi>$FI@a4kg{8|d7BxPOo+TPARwcb0nLEHAOdj~s zj5dc^E_SI=Q@e|e*i%8vN*H@Z)Q$M$VgMlP9)v&##Ccvb{)JOV&1x>G$dTG9bfC zm-j46N&-~nZo1n1|#{P8`zc?CryKWiMrWT8wZfylQHt><&W_|HqOFi#?nRN+N z7EcN1E^s*7u1qzN*HwwVNTm%W)D^4O`5B~fI>q`+#@u~1`Gb=khuvs1Don5A6E*Hz zfNl>UQ}-<_>u&wYZPSL!%Bu4@uK}{SKU1xh!eV(^ZRR{GRA;o^uyWi$n&L%}-x-Q9 z?V(GlYzyD?;5`u<3$s!+#BL;;n;|Z9b{V2t6)&vT#rFD;Fc%gbv^a?sM0YqNTWRd z5M{>od#Z{Q;dF0#S~Ve@Qh@%ZK<%Ku+{@`1*N^~A|I#?G*hj9t;}=okUit0v<6%_& z!nJLy&ppU;%f`kAOKAg*e;k{hrm8B9f66PPGgWZ6SxRAo0T`gWi;L8PhoR57UP8Ix zJ7v7s@5o?V`OxE=$=mY?yxp1V2;IJ1#gw}35;VX_V;$=N3O6T7lz#{C6TBmeAAH8J zc2~tw0ae+?Ceot{U#N7pFvM&;GHu?U^aqMgldNr=YOaw*Lg?3(x?Aar4LTo*zrKF< zB)_!%Lof2+?K3zIOdyUt&%~pI6A*koY8-WZhcRQg<<8yN#IL4#E>w-RaI})o*m;%v=!a7J5%keviu9E$4Tsdlv-q zF3)ypgKbzc@8umE{D9G!6%!&$yYBEDkCAW-%1JrN?OMm28p|7oluxwiAuHzVS*j1} zHPpg~xKU5mC-^6fvXJ+7`FbC%y<{*^8D$vIR*UaGDu{?1I5=NEsB#e16}V^&_KSg5 z=rc&HfO`#HvGl7D@9P|A+)rt@>)!D4@@(wEg40Q*#W$ zViJ3g?}r7vGG7OLVSjh6My-{goSXCQDFfAn?nILwe;M`kr*RKq8@S=*L^&G&5_#fi zOe|una;n^3H>bchJybTZ9%tmGvp-3P`=V4^B@&+P9K2Mqckj4l#d*G*VryzF!=+#C z5V66D%y+`S6-ytp3)MbqNK>kHH{4@iZ>pD|2jV9}$)ZAQ67$G&vm=v;N}K89%*#DJ zrM3Xv>(jXAS%=-qiK>cQJXqH7l;@@7?A>BhOHe@rg`K zW>wBMb5o})Huo8mysuXJRRq62CZG!!t{;HXImN;@EB4@`sg5TN$pfvB^0hK#6oV28 zF)`dcP2>jbeRI{`-tIDC(6#bHz!S|~=>YAEZ(T{tAaU}za-#trrhhGxy&EVeQ2gQh0FXzBhU#b%Ut&uc$a>CQje+p-nXWZq6HBqSdRd zX#36VI0OX#Ok->R=?0axexKL>WwcXDO*!PH~%WeZJ}}l`2$>dFWF)|j{*09kU9%Ua{R1yLT=!F8!~#W2PeFX{L#yv8J#_tc#vhyn7+A^R*W%s9|l(ky1QPVk@~(2tS2dOIz!606o)5PE&W|KRQxE7re8fIf_SSdsMP7g@9BImCvj60mpYv^ndRnt-hS_ zQ5{1_t|?b*7YEkXV-UW;w`DL_(nDi$+)MiPH-Aaul< z{``DRB;}v+J>O{~j;_0J9YA!|#|3*@k zrj(RjkHjAdkDrsN-$sPQk0tOLi_vfj%`c3P&PJ_z(6DQ|D4sg=PViRlD*s5>HYghP zke;c^n-EZRS??*xYmoI!O0--llkKvu7p)mmGj=GJNa7Eebg$|$YdpDHPqG_GdZYAM zdZC>~_~Pbs(2v4(ywn6nJyg`5snbRHF9C<;_LB*tLE2$D-9Zhudfw=xC%bwMtWB5< zgn$hL`Hf!q%ik4snq#@uF)^pkKR$|Q6;cN9hhXOx^%bltBde;sveV<>Mx*UEp5b1S z{ZpghqVaOidY!hdVeHlm>R!{!1*O{rbv(uDa-_T@7+ZMB-5lHeQi)IiBgFuM!Y z)@aj_^{>eY1MMdI=_&c;dLfpRx$l*kk~;a9GyDPV6?wruP}dWnL982k-aE@?PAwQ%HcaY7jg zB~cr)L?NZ3;)6_{<^@~E6a(;~0v01G-;V>n{9Qf@DVX6LhvRS zmYf06&hkRw@h;983@^qW1xe^kDICFnDrlHGdwjpO;Use@LvwB06`Cs8MECb#{H~FZ zq1Z}JPOEb2!y}>QT@BPr$>pU?oILxA$BLLU;|l8T;^6n|H@ZNo8C0_LOsI-9+kfXE zp=<{zdnZ!jtAz1KX*C-sq%y0!{|nxiqA6>EMk(<6^pFm?hN}xMJwosUS5Q}%_i5#| zZ0>iv>+H#Lw)O&F?V_8$0^O=DAf0HQ %8Sc)Yr9rV(qn@^R&IA~y&HOl- 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 literal 0 HcmV?d00001 diff --git a/docs/topics/images/12-CalculationEngine-Spillage-Operator.png b/docs/topics/images/12-CalculationEngine-Spillage-Operator.png new file mode 100644 index 0000000000000000000000000000000000000000..c096875e949b2cc4f95b35be3cdc30f0f0fef039 GIT binary patch literal 9381 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 G_`d)V?pERe literal 0 HcmV?d00001 diff --git a/docs/topics/reading-files.md b/docs/topics/reading-files.md index 27b6bb60c6..8f74978d34 100644 --- a/docs/topics/reading-files.md +++ b/docs/topics/reading-files.md @@ -168,6 +168,87 @@ Once you have created a reader object for the workbook that you want to load, you have the opportunity to set additional options before executing the `load()` method. +All of these options can be set by calling the appropriate methods against the Reader (as described below), but some options (those with only two possible values) can also be set through flags, either by calling the Reader's `setFlags()` method, or passing the flags as an argument in the call to `load()`. +Those options that can be set through flags are: + +Option | Flag | Default +-------------------|-------------------------------------|------------------------ +Empty Cells | IReader::IGNORE_EMPTY_CELLS | Load empty cells +Rows with no Cells | IReader::IGNORE_ROWS_WITH_NO_CELLS | Load rows with no cells +Data Only | IReader::READ_DATA_ONLY | Read data, structure and style +Charts | IReader::LOAD_WITH_CHARTS | Don't read charts + +Several flags can be combined in a single call: +```php +$inputFileType = 'Xlsx'; +$inputFileName = './sampleData/example1.xlsx'; + +/** Create a new Reader of the type defined in $inputFileType **/ +$reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader($inputFileType); +/** Set additional flags before the call to load() */ +$reader->setFlags(IReader::IGNORE_EMPTY_CELLS | IReader::LOAD_WITH_CHARTS); +$reader->load($inputFileName); +``` +or +```php +$inputFileType = 'Xlsx'; +$inputFileName = './sampleData/example1.xlsx'; + +/** Create a new Reader of the type defined in $inputFileType **/ +$reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader($inputFileType); +/** Set additional flags in the call to load() */ +$reader->load($inputFileName, IReader::IGNORE_EMPTY_CELLS | IReader::LOAD_WITH_CHARTS); +``` + +### Ignoring Empty Cells + +Many Excel files have empty rows or columns at the end of a worksheet, which can't easily be seen when looking at the file in Excel (Try using Ctrl-End to see the last cell in a worksheet). +By default, PhpSpreadsheet will load these cells, because they are valid Excel values; but you may find that an apparently small spreadsheet requires a lot of memory for all those empty cells. +If you are running into memory issues with seemingly small files, you can tell PhpSpreadsheet not to load those empty cells using the `setReadEmptyCells()` method. + +```php +$inputFileType = 'Xls'; +$inputFileName = './sampleData/example1.xls'; + +/** Create a new Reader of the type defined in $inputFileType **/ +$reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader($inputFileType); +/** Advise the Reader that we only want to load cell's that contain actual content **/ +$reader->setReadEmptyCells(false); +/** Load $inputFileName to a Spreadsheet Object **/ +$spreadsheet = $reader->load($inputFileName); +``` + +Note that cells containing formulae will still be loaded, even if that formula evaluates to a NULL or an empty string. +Similarly, Conditional Styling might also hide the value of a cell; but cells that contain Conditional Styling or Data Validation will always be loaded regardless of their value. + +This option is available for the following formats: + +Reader | Y/N |Reader | Y/N |Reader | Y/N | +----------|:---:|--------|:---:|--------------|:---:| +Xlsx | YES | Xls | YES | Xml | NO | +Ods | NO | SYLK | NO | Gnumeric | NO | +CSV | NO | HTML | NO + +This option is also available through flags. + +### Ignoring Rows With No Cells + +Similar to the previous item, you can choose to ignore rows which contain no cells. +This can also help with memory issues. +```php +$inputFileType = 'Xlsx'; +$inputFileName = './sampleData/example1.xlsx'; + +/** Create a new Reader of the type defined in $inputFileType **/ +$reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader($inputFileType); +/** Advise the Reader that we do not want rows with no cells **/ +$reader->setIgnoreRowsWithNoCells(true); +/** Load $inputFileName to a Spreadsheet Object **/ +$spreadsheet = $reader->load($inputFileName); +``` + +This option is available only for Xlsx. It is also available through flags. + ### Reading Only Data from a Spreadsheet File If you're only interested in the cell values in a workbook, but don't @@ -210,6 +291,8 @@ Xlsx | YES | Xls | YES | Xml | YES | Ods | YES | SYLK | NO | Gnumeric | YES | CSV | NO | HTML | NO +This option is also available through flags. + ### Reading Only Named WorkSheets from a File If your workbook contains a number of worksheets, but you are only @@ -642,7 +725,7 @@ Xlsx | NO | Xls | NO | Xml | NO | Ods | NO | SYLK | NO | Gnumeric | NO | CSV | YES | HTML | NO -### A Brief Word about the Advanced Value Binder +## A Brief Word about the Advanced Value Binder When loading data from a file that contains no formatting information, such as a CSV file, then data is read either as strings or numbers @@ -694,6 +777,9 @@ Xlsx | NO | Xls | NO | Xml | NO Ods | NO | SYLK | NO | Gnumeric | NO CSV | YES | HTML | YES +Note that you can also use the Binder to determine how PhpSpreadsheet identified datatypes for values when you set a cell value without explicitly setting a datatype. +Value Binders can also be used to set formatting for a cell appropriate to the value. + ## Error Handling Of course, you should always apply some error handling to your scripts diff --git a/docs/topics/recipes.md b/docs/topics/recipes.md index ede0f34e1e..a1cfebc5e2 100644 --- a/docs/topics/recipes.md +++ b/docs/topics/recipes.md @@ -324,7 +324,7 @@ $spreadsheet->getActiveSheet()->getStyle('A3') Inside the Excel file, formulas are always stored as they would appear in an English version of Microsoft Office Excel, and PhpSpreadsheet -handles all formulae internally in this format. This means that the +handles all formulas internally in this format. This means that the following rules hold: - Decimal separator is `.` (period) @@ -373,7 +373,150 @@ is further explained in [the calculation engine](./calculation-engine.md). $value = $spreadsheet->getActiveSheet()->getCell('B8')->getCalculatedValue(); ``` -## Locale Settings for Formulae +### Array Formulas + +With version 2.0.3 of PhpSpreadsheet, we've introduced support for Excel "array formulas". +It is an opt-in feature. You need to enable it with the following code: +```php +\PhpOffice\PhpSpreadsheet\Calculation\Calculation::setArrayReturnType( + \PhpOffice\PhpSpreadsheet\Calculation\Calculation::RETURN_ARRAY_AS_ARRAY); +``` +This is not a new function or constant, but it has till now not had much effect. + +As a basic example, let's look at a receipt for buying some fruit: + +![12-CalculationEngine-Basic-Formula.png](./images/12-CalculationEngine-Basic-Formula.png) + +We can provide a "Cost" formula for each row of the receipt by multiplying the "Quantity" (column `B`) by the "Price" (column `C`); so for the "Apples" in row `2` we enter the formula `=$B2*$C2`. In PhpSpreadsheet, we would set this formula in cell `D2` using: +```php +$spreadsheet->getActiveSheet()->setCellValue('D2','=$B2*$C2'); +``` +and then do the equivalent for rows `3` to `6`. + +To calculate the "Total", we would use a different formula, telling it to calculate the sum value of rows 2 to 6 in the "Cost" column: + +![12-CalculationEngine-Basic-Formula-2.png](./images/12-CalculationEngine-Basic-Formula-2.png) + +I'd imagine that most developers are familiar with this: we're setting a formula that uses an Excel function (the `SUM()` function) and specifying a range of cells to include in the sum (`$D$2:$D6`) +```php +$spreadsheet->getActiveSheet()->setCellValue('D7','=SUM($D$2:$D6'); +``` +However, we could have specified an alternative formula to calculate that result, using the arrays of the "Quantity" and "Cost" columns multiplied directly, and then summed together: + +![12-CalculationEngine-Array-Formula.png](./images/12-CalculationEngine-Array-Formula.png) + +Entering the formula `=SUM(B2:B6*C2:C6)` will calculate the same result; but because it's using arrays, we need to enter it as an "array formula". In MS Excel itself, we'd do this by using `Shift-Ctrl-Enter` rather than simply `Enter` when we define the formula in the formula edit box. MS Excel then shows that this is an array formula in the formula edit box by wrapping it in the `{}` braces (you don't enter these in the formula yourself; MS Excel does it). +In recent releases of Excel, Shift-Ctrl-Enter is not required, and Excel does not add the braces. +PhpSpreadsheet will attempt to behave like the recent releases. + +Or to identify the biggest increase in like-for-like sales from one month to the next: + +![12-CalculationEngine-Array-Formula-3.png](./images/12-CalculationEngine-Array-Formula-3.png) +```php +$spreadsheet->getActiveSheet()->setCellValue('F1','=MAX(B2:B6-C2:C6)', true); +``` +Which tells us that the biggest increase in sales between December and January was 30 more (in this case, 30 more Lemons). + +--- + +These are examples of array formula where the results are displayed in a single cell; but other array formulas might be displayed across several cells. +As an example, consider transposing a grid of data: MS Excel provides the `TRANSPOSE()` function for that purpose. Let's transpose our shopping list for the fruit: + +![12-CalculationEngine-Array-Formula-2.png](./images/12-CalculationEngine-Array-Formula-2.png) + +When we do this in MS Excel, we need to indicate ___all___ the cells that will contain the transposed data from cells `A1` to `D7`. We do this by selecting the cells where we want to display our transposed data either by holding the left mouse button down while we move with the mouse, or pressing `Shift` and using the arrow keys. +Once we've selected all the cells to hold our data, then we enter the formula `TRANSPOSE(A1:D7)` in the formula edit box, remembering to use `Shift-Ctrl-Enter` to tell MS Excel that this is an array formula. + +Note also that we still set this as the formula for the top-left cell of that range, cell `A10`. + +Simply setting an array formula in a cell and specifying the range won't populate the spillage area for that formula. +```php +$spreadsheet->getActiveSheet() + ->setCellValue( + 'A10', + '=SEQUENCE(3,3)', + true, + 'A1:C3' + ); + +// Will return a null, because the formula for A1 hasn't been calculated to populate the spillage area +$result = $spreadsheet->getActiveSheet()->getCell('C3')->getValue(); +``` +To do that, we need to retrieve the calculated value for the cell. +```php +$spreadsheet->getActiveSheet() + ->setCellValue( + 'A10', + '=SEQUENCE(3,3)', + true, + 'A1:C3' + ); + +$spreadsheet->getActiveSheet()->getCell('A1')->getCalculatedValue(); + +// Will return 9, because the formula for A1 has now been calculated, and the spillage area is populated +$result = $spreadsheet->getActiveSheet()->getCell('C3')->getValue(); +``` +When we call `getCalculatedValue()` for a cell that contains an array formula, PhpSpreadsheet returns the single value that would appear in that cell in MS Excel. +```php +// Will return integer 1, the value for that cell within the array +$a1result = $spreadsheet->getActiveSheet()->getCell('A1')->getCalculatedValue(); +``` + +If we want to return the full array, then we need to call `getCalculatedValue()` with an additional argument, a boolean `true` to return the value as an array. +```php +// Will return an array [[1, 2, 3], [4, 5, 6], [7, 8, 9]] +$a1result = $spreadsheet->getActiveSheet()->getCell('A1')->getCalculatedValue(true); +``` + +--- + +Excel365 introduced a number of new functions that return arrays of results. +These include the `UNIQUE()`, `SORT()`, `SORTBY()`, `FILTER()`, `SEQUENCE()` and `RANDARRAY()` functions. +While not all of these have been implemented by the Calculation Engine in PhpSpreadsheet, so they cannot all be calculated within your PHP applications, they can still be read from and written to Xlsx files. + +The way these functions are presented in MS Excel itself is slightly different to that of other array functions. + +The `SEQUENCE()` function generates a series of values (in this case, starting with `-10` and increasing in steps of `2.5`); and here we're telling the formula to populate a 3x3 grid with these values. + +![12-CalculationEngine-Spillage-Formula.png](./images/12-CalculationEngine-Spillage-Formula.png) + +Note that this is visually different to the multi-cell array formulas like `TRANSPOSE()`. When we are positioned in the "spill" range for the grid, MS Excel highlights the area with a blue border; and the formula displayed in the formula editing field isn't wrapped in braces (`{}`). + +And if we select any other cell inside the "spill" area other than the top-left cell, the formula in the formula edit field is greyed rather than displayed in black. + +![12-CalculationEngine-Spillage-Formula-2.png](./images/12-CalculationEngine-Spillage-Formula-2.png) + +When we enter this formula in MS Excel, we don't need to select the range of cells that it should occupy; nor do we need to enter it using `Ctrl-Shift-Enter`. MS Excel identifies that it is a multi-cell array formula because of the function that it uses, the `SEQUENCE()` function (and if there are nested function calls in the formula, then it must be the outermost functionin the tree). + +However, PhpSpreadsheet isn't quite as intelligent (yet) and doesn't parse the formula to identify if it should be treated as an array formula or not; a formula is just a string of characters until it is actually evaluated. If we want to use this function through code, we still need to specify that it is an "array" function with the `$isArrayFormula` argument, and the range of cells that it should cover. + +```php +$spreadsheet->getActiveSheet()->setCellValue('A1','=SEQUENCE(3,3,-10,2.5)', true, 'A1:C3'); +``` + +### The Spill Operator + +If you want to reference the entire spillage range of an array formula within another formula, you could do so using the standard Excel range operator (`:`, e.g. `A1:C3`); but you may not always know the range, especially for array functions that spill across as many cells as they need, like `UNIQUE()` and `FILTER()`. +To simplify this, MS Excel has introduced the "Spill" Operator (`#`). + +![12-CalculationEngine-Spillage-Operator.png](./images/12-CalculationEngine-Spillage-Operator.png) + +Using our `SEQUENCE()"`example, where the formula cell is `A1` and the result spills across the range `A1:C3`, we can use the Spill operator `A1#` to reference all the cells in that spillage range. +In this case, we're taking the absolute value of each cell in that range, and adding them together using the `SUM()` function to give us a result of 50. + +PhpSpreadsheet doesn't currently support entry of a formula like this directly; but interally MS Excel implements the Spill Operator as a function (`ANCHORARRAY()`). MS Excel itself doesn't allow you to use this function in a formula, you have to use the "Spill" operator; but PhpSpreadsheet does allow you to use this internal Excel function. + +To create this same function in PhpSpreadsheet, use: +```php +$spreadsheet->getActiveSheet()->setCellValue('D1','=SUM(ABS(ANCHORARRAY(A1)))', true); +``` +Note that this does need to be flagged as an array function with the `$isArrayFormula` argument. + +When the file is saved, and opened in MS Excel, it will be rendered correctly. + + +## Locale Settings for Formulas Some localisation elements have been included in PhpSpreadsheet. You can set a locale by changing the settings. To set the locale to Russian you @@ -1655,7 +1798,7 @@ The second alternative, available in both OpenOffice and LibreOffice is to merge $spreadsheet->getActiveSheet()->mergeCells('A1:C3', Worksheet::MERGE_CELL_CONTENT_MERGE); ``` -Particularly when the merged cells contain formulae, the logic for this merge seems strange: +Particularly when the merged cells contain formulas, the logic for this merge seems strange: walking through the merge range, each cell is calculated in turn, and appended to the "master" cell, then it is emptied, so any subsequent calculations that reference the cell see an empty cell, not the pre-merge value. For example, suppose our spreadsheet contains @@ -1689,7 +1832,7 @@ Equivalent methods exist for inserting/removing columns: $spreadsheet->getActiveSheet()->removeColumn('C', 2); ``` -All subsequent rows (or columns) will be moved to allow the insertion (or removal) with all formulae referencing thise cells adjusted accordingly. +All subsequent rows (or columns) will be moved to allow the insertion (or removal) with all formulas referencing thise cells adjusted accordingly. Note that this is a fairly intensive process, particularly with large worksheets, and especially if you are inserting/removing rows/columns from near beginning of the worksheet. @@ -1875,7 +2018,7 @@ global by default. ## Define a named formula -In addition to named ranges, PhpSpreadsheet also supports the definition of named formulae. These can be +In addition to named ranges, PhpSpreadsheet also supports the definition of named formulas. These can be defined using the following code: ```php @@ -1929,7 +2072,7 @@ $spreadsheet->getActiveSheet() ``` As with named ranges, an optional fourth parameter can be passed defining the named formula -scope as local (i.e. only usable on the specified worksheet). Otherwise, named formulae are +scope as local (i.e. only usable on the specified worksheet). Otherwise, named formulas are global by default. ## Redirect output to a client's web browser diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index 8869b23fac..2ca34f0a20 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -39,6 +39,8 @@ class Calculation const CALCULATION_REGEXP_STRIP_XLFN_XLWS = '/(_xlfn[.])?(_xlws[.])?(?=[\p{L}][\p{L}\p{N}\.]*[\s]*[(])/'; // Cell reference (cell or range of cells, with or without a sheet reference) const CALCULATION_REGEXP_CELLREF = '((([^\s,!&%^\/\*\+<>=:`-]*)|(\'(?:[^\']|\'[^!])+?\')|(\"(?:[^\"]|\"[^!])+?\"))!)?\$?\b([a-z]{1,3})\$?(\d{1,7})(?![\w.])'; + // Used only to detect spill operator # + const CALCULATION_REGEXP_CELLREF_SPILL = '/' . self::CALCULATION_REGEXP_CELLREF . '#/i'; // Cell reference (with or without a sheet reference) ensuring absolute/relative const CALCULATION_REGEXP_CELLREF_RELATIVE = '((([^\s\(,!&%^\/\*\+<>=:`-]*)|(\'(?:[^\']|\'[^!])+?\')|(\"(?:[^\"]|\"[^!])+?\"))!)?(\$?\b[a-z]{1,3})(\$?\d{1,7})(?![\w.])'; const CALCULATION_REGEXP_COLUMN_RANGE = '(((([^\s\(,!&%^\/\*\+<>=:`-]*)|(\'(?:[^\']|\'[^!])+?\')|(\".(?:[^\"]|\"[^!])?\"))!)?(\$?[a-z]{1,3})):(?![.*])'; @@ -275,9 +277,11 @@ public static function getExcelConstants(string $key): bool|null 'argumentCount' => '6,7', ], 'ANCHORARRAY' => [ - 'category' => Category::CATEGORY_UNCATEGORISED, - 'functionCall' => [Functions::class, 'DUMMY'], - 'argumentCount' => '*', + 'category' => Category::CATEGORY_MICROSOFT_INTERNAL, + 'functionCall' => [Internal\ExcelArrayPseudoFunctions::class, 'anchorArray'], + 'argumentCount' => '1', + 'passCellReference' => true, + 'passByReference' => [true], ], 'AND' => [ 'category' => Category::CATEGORY_LOGICAL, @@ -2314,9 +2318,11 @@ public static function getExcelConstants(string $key): bool|null 'argumentCount' => '1', ], 'SINGLE' => [ - 'category' => Category::CATEGORY_UNCATEGORISED, - 'functionCall' => [Functions::class, 'DUMMY'], - 'argumentCount' => '*', + 'category' => Category::CATEGORY_MICROSOFT_INTERNAL, + 'functionCall' => [Internal\ExcelArrayPseudoFunctions::class, 'single'], + 'argumentCount' => '1', + 'passCellReference' => true, + 'passByReference' => [true], ], 'SINH' => [ 'category' => Category::CATEGORY_MATH_AND_TRIG, @@ -3465,7 +3471,15 @@ public function calculateCellValue(?Cell $cell = null, bool $resetLog = true): m $cellAddress = null; try { - $result = self::unwrapResult($this->_calculateFormulaValue($cell->getValue(), $cell->getCoordinate(), $cell)); + $value = $cell->getValue(); + if ($cell->getDataType() === DataType::TYPE_FORMULA) { + $value = preg_replace_callback( + self::CALCULATION_REGEXP_CELLREF_SPILL, + fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')', + $value + ); + } + $result = self::unwrapResult($this->_calculateFormulaValue($value, $cell->getCoordinate(), $cell)); if ($this->spreadsheet === null) { throw new Exception('null spreadsheet in calculateCellValue'); } @@ -4990,7 +5004,18 @@ private function processTokenStack(mixed $tokens, ?string $cellID = null, ?Cell && (self::$phpSpreadsheetFunctions[$functionName]['passByReference'][$a]) ) { if ($arg['reference'] === null) { - $args[] = $cellID; + $nextArg = $cellID; + if ($functionName === 'ISREF' && is_array($arg) && ($arg['type'] ?? '') === 'Value') { + if (array_key_exists('value', $arg)) { + $argValue = $arg['value']; + if (is_scalar($argValue)) { + $nextArg = $argValue; + } elseif (empty($argValue)) { + $nextArg = ''; + } + } + } + $args[] = $nextArg; if ($functionName !== 'MKMATRIX') { $argArrayVals[] = $this->showValue($cellID); } diff --git a/src/PhpSpreadsheet/Calculation/Category.php b/src/PhpSpreadsheet/Calculation/Category.php index b661fafec0..38c19b30b8 100644 --- a/src/PhpSpreadsheet/Calculation/Category.php +++ b/src/PhpSpreadsheet/Calculation/Category.php @@ -18,4 +18,5 @@ abstract class Category const CATEGORY_TEXT_AND_DATA = 'Text and Data'; const CATEGORY_WEB = 'Web'; const CATEGORY_UNCATEGORISED = 'Uncategorised'; + const CATEGORY_MICROSOFT_INTERNAL = 'MS Internal'; } diff --git a/src/PhpSpreadsheet/Calculation/Information/Value.php b/src/PhpSpreadsheet/Calculation/Information/Value.php index c9a7a0af3e..a99c2343fb 100644 --- a/src/PhpSpreadsheet/Calculation/Information/Value.php +++ b/src/PhpSpreadsheet/Calculation/Information/Value.php @@ -39,7 +39,7 @@ public static function isBlank(mixed $value = null): array|bool */ public static function isRef(mixed $value, ?Cell $cell = null): bool { - if ($cell === null || $value === $cell->getCoordinate()) { + if ($cell === null) { return false; } diff --git a/src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php b/src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php new file mode 100644 index 0000000000..04032edf06 --- /dev/null +++ b/src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php @@ -0,0 +1,91 @@ +getWorksheet(); + + [$referenceWorksheetName, $referenceCellCoordinate] = Worksheet::extractSheetTitle($cellReference, true); + $referenceCell = ($referenceWorksheetName === '') + ? $worksheet->getCell((string) $referenceCellCoordinate) + : $worksheet->getParentOrThrow() + ->getSheetByNameOrThrow((string) $referenceWorksheetName) + ->getCell((string) $referenceCellCoordinate); + + $result = $referenceCell->getCalculatedValue(); + + return [[$result]]; + } + + public static function anchorArray(string $cellReference, Cell $cell): array|string + { + $coordinate = $cell->getCoordinate(); + $worksheet = $cell->getWorksheet(); + + [$referenceWorksheetName, $referenceCellCoordinate] = Worksheet::extractSheetTitle($cellReference, true); + $referenceCell = ($referenceWorksheetName === '') + ? $worksheet->getCell((string) $referenceCellCoordinate) + : $worksheet->getParentOrThrow() + ->getSheetByNameOrThrow((string) $referenceWorksheetName) + ->getCell((string) $referenceCellCoordinate); + + // We should always use the sizing for the array formula range from the referenced cell formula + $referenceRange = null; + /*if ($referenceCell->isFormula() && $referenceCell->isArrayFormula()) { + $referenceRange = $referenceCell->arrayFormulaRange(); + }*/ + + $calcEngine = Calculation::getInstance($worksheet->getParent()); + $result = $calcEngine->calculateCellValue($referenceCell, false); + if (!is_array($result)) { + $result = ExcelError::REF(); + } + + // Ensure that our array result dimensions match the specified array formula range dimensions, + // from the referenced cell, expanding or shrinking it as necessary. + /*$result = Functions::resizeMatrix( + $result, + ...Coordinate::rangeDimension($referenceRange ?? $coordinate) + );*/ + + // Set the result for our target cell (with spillage) + // But if we do write it, we get problems with #SPILL! Errors if the spreadsheet is saved + // TODO How are we going to identify and handle a #SPILL! or a #CALC! error? +// IOFactory::setLoading(true); +// $worksheet->fromArray( +// $result, +// null, +// $coordinate, +// true +// ); +// IOFactory::setLoading(true); + + // Calculate the array formula range that we should set for our target, based on our target cell coordinate +// [$col, $row] = Coordinate::indexesFromString($coordinate); +// $row += count($result) - 1; +// $col = Coordinate::stringFromColumnIndex($col + count($result[0]) - 1); +// $arrayFormulaRange = "{$coordinate}:{$col}{$row}"; +// $formulaAttributes = ['t' => 'array', 'ref' => $arrayFormulaRange]; + + // Using fromArray() would reset the value for this cell with the calculation result + // as well as updating the spillage cells, + // so we need to restore this cell to its formula value, attributes, and datatype +// $cell = $worksheet->getCell($coordinate); +// $cell->setValueExplicit($value, DataType::TYPE_FORMULA, true, $arrayFormulaRange); +// $cell->setFormulaAttributes($formulaAttributes); + +// $cell->updateInCollection(); + + return $result; + } +} diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 08fc46be7d..5e337625d5 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -3071,6 +3071,7 @@ public function toArray( ): array { // Garbage collect... $this->garbageCollect(); + self::calculateArrays($calculateFormulas); // Identify the range that we need to extract from the worksheet $maxCol = $this->getHighestColumn(); diff --git a/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php b/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php index b91f02777f..9ac7893ddc 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php @@ -2,6 +2,8 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Xlsx; +use PhpOffice\PhpSpreadsheet\Calculation\Calculation; + class FunctionPrefix { const XLFNREGEXP = '/(?:_xlfn\.)?((?:_xlws\.)?\b(' @@ -198,6 +200,12 @@ protected static function addXlwsPrefix(string $functionString): string */ public static function addFunctionPrefix(string $functionString): string { + $functionString = (string) preg_replace_callback( + Calculation::CALCULATION_REGEXP_CELLREF_SPILL, + fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')', + $functionString + ); + return self::addXlwsPrefix(self::addXlfnPrefix($functionString)); } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index 8daf0d7f50..59e4ad36f0 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -1472,19 +1472,28 @@ private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell return; } $calculatedValueString = $this->getParentWriter()->getPreCalculateFormulas() ? $cell->getCalculatedValueString() : $cellValue; - if (is_string($calculatedValue)) { - if (ErrorValue::isError($calculatedValue)) { - $this->writeCellError($objWriter, 'e', $cellValue, $calculatedValue); + $result = $calculatedValue; + while (is_array($result)) { + $result = array_shift($result); + } + if (is_string($result)) { + if (ErrorValue::isError($result)) { + $this->writeCellError($objWriter, 'e', $cellValue, $result); return; } $objWriter->writeAttribute('t', 'str'); - $calculatedValue = StringHelper::controlCharacterPHP2OOXML($calculatedValue); - $calculatedValueString = $calculatedValue; - } elseif (is_bool($calculatedValue)) { + $result = $calculatedValueString = StringHelper::controlCharacterPHP2OOXML($result); + if (is_string($calculatedValue)) { + $calculatedValue = $calculatedValueString; + } + } elseif (is_bool($result)) { $objWriter->writeAttribute('t', 'b'); - $calculatedValue = (int) $calculatedValue; - $calculatedValueString = (string) $calculatedValue; + if (is_bool($calculatedValue)) { + $calculatedValue = $result; + } + $result = (int) $result; + $calculatedValueString = (string) $result; } if (isset($attributes['ref'])) { @@ -1503,10 +1512,6 @@ private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell $objWriter->writeAttribute('ca', '1'); $objWriter->text(FunctionPrefix::addFunctionPrefixStripEquals($cellValue)); $objWriter->endElement(); - $result = $calculatedValue; - while (is_array($result)) { - $result = array_shift($result); - } if ( is_scalar($result) && $this->getParentWriter()->getOffice2003Compatibility() === false diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/Information/IsRefTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/Information/IsRefTest.php index 962e71fa99..da977cf917 100644 --- a/tests/PhpSpreadsheetTests/Calculation/Functions/Information/IsRefTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/Information/IsRefTest.php @@ -9,6 +9,8 @@ class IsRefTest extends AllSetupTeardown { + private bool $skipA13 = true; + public function testIsRef(): void { $sheet = $this->getSheet(); @@ -41,6 +43,9 @@ public function testIsRef(): void self::assertFalse($sheet->getCell('A10')->getCalculatedValue()); // Indirect to an Invalid Worksheet/Cell Reference self::assertFalse($sheet->getCell('A11')->getCalculatedValue()); // Indirect to an Invalid Worksheet/Cell Reference self::assertFalse($sheet->getCell('A12')->getCalculatedValue()); // Invalid Cell Reference + if ($this->skipA13) { + self::markTestIncomplete('Calculation for A13 is too complicated'); + } self::assertTrue($sheet->getCell('A13')->getCalculatedValue()); // returned Cell Reference } } diff --git a/tests/PhpSpreadsheetTests/DocumentGeneratorTest.php b/tests/PhpSpreadsheetTests/DocumentGeneratorTest.php index 883a964cfc..a14e2c5acc 100644 --- a/tests/PhpSpreadsheetTests/DocumentGeneratorTest.php +++ b/tests/PhpSpreadsheetTests/DocumentGeneratorTest.php @@ -147,6 +147,11 @@ public static function providerGenerateFunctionListByCategory(): array Excel Function | PhpSpreadsheet Function -------------------------|-------------------------------------- + ## CATEGORY_MICROSOFT_INTERNAL + + Excel Function | PhpSpreadsheet Function + -------------------------|-------------------------------------- + EXPECTED ], diff --git a/tests/PhpSpreadsheetTests/Functional/ArrayFunctionsSpillTest.php b/tests/PhpSpreadsheetTests/Functional/ArrayFunctionsSpillTest.php index ca1a56e535..52d4c75a64 100644 --- a/tests/PhpSpreadsheetTests/Functional/ArrayFunctionsSpillTest.php +++ b/tests/PhpSpreadsheetTests/Functional/ArrayFunctionsSpillTest.php @@ -6,6 +6,7 @@ use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat; use PHPUnit\Framework\TestCase; class ArrayFunctionsSpillTest extends TestCase @@ -68,4 +69,40 @@ public function testArrayOutput(): void $spreadsheet->disconnectWorksheets(); } + + public function testSpillOperator(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->fromArray([ + ['Product', 'Quantity', 'Price', 'Cost'], + ['Apple', 20, 0.75], + ['Kiwi', 8, 0.80], + ['Lemon', 12, 0.70], + ['Mango', 5, 1.75], + ['Pineapple', 2, 2.00], + ['Total'], + ]); + $sheet->getCell('D2')->setValue('=B2:B6*C2:C6'); + $sheet->getCell('D7')->setValue('=SUM(D2#)'); + $sheet->getStyle('A1:D1')->getFont()->setBold(true); + $sheet->getStyle('C2:D6')->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_CURRENCY_USD); + self::assertEqualsWithDelta( + [ + ['Cost'], + [15.0], + [6.4], + [8.4], + [8.75], + [4.0], + [42.55], + ], + $sheet->rangeToArray('D1:D7', calculateFormulas: true, formatData: false, reduceArrays: true), + 1.0e-10 + ); + $sheet->getCell('G2')->setValue('=B2#'); + self::assertSame('#REF!', $sheet->getCell('G2')->getCalculatedValue()); + $spreadsheet->disconnectWorksheets(); + } } From 0b471ef77272110b16432b3ad6e0e89efe1c4fec Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 20 Jun 2024 01:21:43 -0700 Subject: [PATCH 17/31] Dead Code, and 1 Static Call to Non-Static --- .../Calculation/Internal/ExcelArrayPseudoFunctions.php | 4 ++-- src/PhpSpreadsheet/Worksheet/Worksheet.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php b/src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php index 04032edf06..5555639a97 100644 --- a/src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php +++ b/src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php @@ -29,7 +29,7 @@ public static function single(string $cellReference, Cell $cell): array|string public static function anchorArray(string $cellReference, Cell $cell): array|string { - $coordinate = $cell->getCoordinate(); + //$coordinate = $cell->getCoordinate(); $worksheet = $cell->getWorksheet(); [$referenceWorksheetName, $referenceCellCoordinate] = Worksheet::extractSheetTitle($cellReference, true); @@ -40,7 +40,7 @@ public static function anchorArray(string $cellReference, Cell $cell): array|str ->getCell((string) $referenceCellCoordinate); // We should always use the sizing for the array formula range from the referenced cell formula - $referenceRange = null; + //$referenceRange = null; /*if ($referenceCell->isFormula() && $referenceCell->isArrayFormula()) { $referenceRange = $referenceCell->arrayFormulaRange(); }*/ diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 5e337625d5..24d0c6365a 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -3071,7 +3071,7 @@ public function toArray( ): array { // Garbage collect... $this->garbageCollect(); - self::calculateArrays($calculateFormulas); + $this->calculateArrays($calculateFormulas); // Identify the range that we need to extract from the worksheet $maxCol = $this->getHighestColumn(); From 3a690a755c259ef317013526fdef5f948069e98b Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Fri, 21 Jun 2024 07:08:22 -0700 Subject: [PATCH 18/31] SINGLE Function, and Gnumeric SINGLE function can be used to return first value from a dynamic array result, or to return the value of the cell which matches the current row (VALUE error if not match) for a range. Excel allows you to specify an at-sign unary operator rather than SINGLE function; this PR does not permit that. Add support for reading CSE array functions for Gnumeric. Throw an exception if setValueExplicit Formula is invalid (not a string, or doesn't begin with equal sign. This is equivalent to what happens when setValueExplicit Numeric specifies a non-numeric value. Added a number of tests from PR #2787. --- .../Calculation/Calculation.php | 10 +- .../Internal/ExcelArrayPseudoFunctions.php | 16 ++- src/PhpSpreadsheet/Cell/Cell.php | 3 + src/PhpSpreadsheet/Reader/Gnumeric.php | 28 +++- .../Calculation/CalculationTest.php | 11 +- .../Calculation/InternalFunctionsTest.php | 118 +++++++++++++++ .../Cell/CellArrayFormulaTest.php | 114 +++++++++++++++ .../Cell/CellFormulaTest.php | 135 ++++++++++++++++++ .../Reader/Gnumeric/ArrayFormula2Test.php | 73 ++++++++++ .../Reader/Gnumeric/ArrayFormulaTest.php | 91 ++++++++++++ .../Reader/Gnumeric/ArrayFormulaTest.gnumeric | Bin 0 -> 1941 bytes .../Gnumeric/ArrayFormulaTest2.gnumeric | Bin 0 -> 1949 bytes 12 files changed, 591 insertions(+), 8 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Calculation/InternalFunctionsTest.php create mode 100644 tests/PhpSpreadsheetTests/Cell/CellArrayFormulaTest.php create mode 100644 tests/PhpSpreadsheetTests/Cell/CellFormulaTest.php create mode 100644 tests/PhpSpreadsheetTests/Reader/Gnumeric/ArrayFormula2Test.php create mode 100644 tests/PhpSpreadsheetTests/Reader/Gnumeric/ArrayFormulaTest.php create mode 100644 tests/data/Reader/Gnumeric/ArrayFormulaTest.gnumeric create mode 100644 tests/data/Reader/Gnumeric/ArrayFormulaTest2.gnumeric diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index 2ca34f0a20..f2523b8a2c 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -3733,7 +3733,9 @@ public static function checkMatrixOperands(mixed &$operand1, mixed &$operand2, i [$matrix1Rows, $matrix1Columns] = self::getMatrixDimensions($operand1); [$matrix2Rows, $matrix2Columns] = self::getMatrixDimensions($operand2); - if (($matrix1Rows == $matrix2Columns) && ($matrix2Rows == $matrix1Columns)) { + if ($resize === 3) { + $resize = 2; + } elseif (($matrix1Rows == $matrix2Columns) && ($matrix2Rows == $matrix1Columns)) { $resize = 1; } @@ -4560,6 +4562,7 @@ private function processTokenStack(mixed $tokens, ?string $cellID = null, ?Cell // If we're using cell caching, then $pCell may well be flushed back to the cache (which detaches the parent cell collection), // so we store the parent cell collection so that we can re-attach it when necessary $pCellWorksheet = ($cell !== null) ? $cell->getWorksheet() : null; + $originalCoordinate = $cell?->getCoordinate(); $pCellParent = ($cell !== null) ? $cell->getParent() : null; $stack = new Stack($this->branchPruner); @@ -5061,6 +5064,9 @@ private function processTokenStack(mixed $tokens, ?string $cellID = null, ?Cell } // Process the argument with the appropriate function call + if ($pCellWorksheet !== null && $originalCoordinate !== null) { + $pCellWorksheet->getCell($originalCoordinate); + } $args = $this->addCellReference($args, $passCellReference, $functionCall, $cell); if (!is_array($functionCall)) { @@ -5268,7 +5274,7 @@ private function executeNumericBinaryOperation(mixed $operand1, mixed $operand2, $operand2[$key] = Functions::flattenArray($value); } } - [$rows, $columns] = self::checkMatrixOperands($operand1, $operand2, 2); + [$rows, $columns] = self::checkMatrixOperands($operand1, $operand2, 3); for ($row = 0; $row < $rows; ++$row) { for ($column = 0; $column < $columns; ++$column) { diff --git a/src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php b/src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php index 5555639a97..df7ccc5efc 100644 --- a/src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php +++ b/src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php @@ -11,11 +11,20 @@ class ExcelArrayPseudoFunctions { - public static function single(string $cellReference, Cell $cell): array|string + public static function single(string $cellReference, Cell $cell): mixed { $worksheet = $cell->getWorksheet(); [$referenceWorksheetName, $referenceCellCoordinate] = Worksheet::extractSheetTitle($cellReference, true); + if (preg_match('/^([$]?[a-z]{1,3})([$]?([0-9]{1,7})):([$]?[a-z]{1,3})([$]?([0-9]{1,7}))$/i', "$referenceCellCoordinate", $matches) === 1) { + $ourRow = $cell->getRow(); + $firstRow = (int) $matches[3]; + $lastRow = (int) $matches[6]; + if ($ourRow < $firstRow || $ourRow > $lastRow) { + return ExcelError::VALUE(); + } + $referenceCellCoordinate = $matches[1] . $ourRow; + } $referenceCell = ($referenceWorksheetName === '') ? $worksheet->getCell((string) $referenceCellCoordinate) : $worksheet->getParentOrThrow() @@ -23,8 +32,11 @@ public static function single(string $cellReference, Cell $cell): array|string ->getCell((string) $referenceCellCoordinate); $result = $referenceCell->getCalculatedValue(); + while (is_array($result)) { + $result = array_shift($result); + } - return [[$result]]; + return $result; } public static function anchorArray(string $cellReference, Cell $cell): array|string diff --git a/src/PhpSpreadsheet/Cell/Cell.php b/src/PhpSpreadsheet/Cell/Cell.php index 1368b656ab..2a5fff9fcf 100644 --- a/src/PhpSpreadsheet/Cell/Cell.php +++ b/src/PhpSpreadsheet/Cell/Cell.php @@ -275,6 +275,9 @@ public function setValueExplicit(mixed $value, string $dataType = DataType::TYPE break; case DataType::TYPE_FORMULA: + if (!is_string($value) || $value[0] !== '=') { + throw new SpreadsheetException('Invalid value for datatype Formula'); + } $this->value = (string) $value; break; diff --git a/src/PhpSpreadsheet/Reader/Gnumeric.php b/src/PhpSpreadsheet/Reader/Gnumeric.php index 723e23bb1a..898e32db5c 100644 --- a/src/PhpSpreadsheet/Reader/Gnumeric.php +++ b/src/PhpSpreadsheet/Reader/Gnumeric.php @@ -545,15 +545,21 @@ private function loadCell( ): void { $ValueType = $cellAttributes->ValueType; $ExprID = (string) $cellAttributes->ExprID; + $rows = (int) ($cellAttributes->Rows ?? 0); + $cols = (int) ($cellAttributes->Cols ?? 0); $type = DataType::TYPE_FORMULA; + $isArrayFormula = ($rows > 0 && $cols > 0); + $arrayFormulaRange = $isArrayFormula ? $this->getArrayFormulaRange($column, $row, $cols, $rows) : null; if ($ExprID > '') { if (((string) $cell) > '') { + // Formula $this->expressions[$ExprID] = [ 'column' => $cellAttributes->Col, 'row' => $cellAttributes->Row, 'formula' => (string) $cell, ]; } else { + // Shared Formula $expression = $this->expressions[$ExprID]; $cell = $this->referenceHelper->updateFormulaReferences( @@ -565,21 +571,39 @@ private function loadCell( ); } $type = DataType::TYPE_FORMULA; - } else { + } elseif ($isArrayFormula === false) { $vtype = (string) $ValueType; if (array_key_exists($vtype, self::$mappings['dataType'])) { $type = self::$mappings['dataType'][$vtype]; } - if ($vtype === '20') { // Boolean + if ($vtype === '20') { // Boolean $cell = $cell == 'TRUE'; } } $this->spreadsheet->getActiveSheet()->getCell($column . $row)->setValueExplicit((string) $cell, $type); + if ($arrayFormulaRange === null) { + $this->spreadsheet->getActiveSheet()->getCell($column . $row)->setFormulaAttributes(null); + } else { + $this->spreadsheet->getActiveSheet()->getCell($column . $row)->setFormulaAttributes(['t' => 'array', 'ref' => $arrayFormulaRange]); + } if (isset($cellAttributes->ValueFormat)) { $this->spreadsheet->getActiveSheet()->getCell($column . $row) ->getStyle()->getNumberFormat() ->setFormatCode((string) $cellAttributes->ValueFormat); } } + + private function getArrayFormulaRange(string $column, int $row, int $cols, int $rows): string + { + $arrayFormulaRange = $column . $row; + $arrayFormulaRange .= ':' + . Coordinate::stringFromColumnIndex( + Coordinate::columnIndexFromString($column) + + $cols - 1 + ) + . (string) ($row + $rows - 1); + + return $arrayFormulaRange; + } } diff --git a/tests/PhpSpreadsheetTests/Calculation/CalculationTest.php b/tests/PhpSpreadsheetTests/Calculation/CalculationTest.php index 79685d2678..76621d4813 100644 --- a/tests/PhpSpreadsheetTests/Calculation/CalculationTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/CalculationTest.php @@ -7,6 +7,7 @@ use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\Functions; use PhpOffice\PhpSpreadsheet\Cell\DataType; +use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PHPUnit\Framework\TestCase; @@ -102,8 +103,14 @@ public function testCellSetAsQuotedText(): void self::assertEquals("=cmd|'/C calc'!A0", $cell->getCalculatedValue()); $cell2 = $workSheet->getCell('A2'); - $cell2->setValueExplicit('ABC', DataType::TYPE_FORMULA); - self::assertEquals('ABC', $cell2->getCalculatedValue()); + + try { + $cell2->setValueExplicit('ABC', DataType::TYPE_FORMULA); + self::assertEquals('ABC', $cell2->getCalculatedValue()); + self::fail('setValueExplicit with invalid formula should have thrown exception'); + } catch (SpreadsheetException $e) { + self::assertStringContainsString('Invalid value for datatype Formula', $e->getMessage()); + } $cell3 = $workSheet->getCell('A3'); $cell3->setValueExplicit('=', DataType::TYPE_FORMULA); diff --git a/tests/PhpSpreadsheetTests/Calculation/InternalFunctionsTest.php b/tests/PhpSpreadsheetTests/Calculation/InternalFunctionsTest.php new file mode 100644 index 0000000000..9914d7302c --- /dev/null +++ b/tests/PhpSpreadsheetTests/Calculation/InternalFunctionsTest.php @@ -0,0 +1,118 @@ +arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + Calculation::setArrayReturnType($this->arrayReturnType); + } + + /** + * @dataProvider anchorArrayDataProvider + */ + public function testAnchorArrayFormula(string $reference, string $range, array $expectedResult): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet1 = $spreadsheet->getActiveSheet(); + $sheet1->setTitle('SheetOne'); // no space in sheet title + $sheet2 = $spreadsheet->createSheet(); + $sheet2->setTitle('Sheet Two'); // space in sheet title + + $sheet1->setCellValue('C3', '=SEQUENCE(3,3,-4)'); + $sheet2->setCellValue('C3', '=SEQUENCE(3,3, 9, -1)'); + $sheet1->calculateArrays(); + $sheet2->calculateArrays(); + $sheet1->setCellValue('A8', "=ANCHORARRAY({$reference})"); + + $result1 = $sheet1->getCell('A8')->getCalculatedValue(); + self::assertSame($expectedResult, $result1); + $attributes1 = $sheet1->getCell('A8')->getFormulaAttributes(); + self::assertSame(['t' => 'array', 'ref' => $range], $attributes1); + $spreadsheet->disconnectWorksheets(); + } + + public static function anchorArrayDataProvider(): array + { + return [ + [ + 'C3', + 'A8:C10', + [[-4, -3, -2], [-1, 0, 1], [2, 3, 4]], + ], + [ + "'Sheet Two'!C3", + 'A8:C10', + [[9, 8, 7], [6, 5, 4], [3, 2, 1]], + ], + ]; + } + + /** + * @dataProvider singleDataProvider + */ + public function testSingleArrayFormula(string $reference, mixed $expectedResult): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet1 = $spreadsheet->getActiveSheet(); + $sheet1->setTitle('SheetOne'); // no space in sheet title + $sheet2 = $spreadsheet->createSheet(); + $sheet2->setTitle('Sheet Two'); // space in sheet title + + $sheet1->setCellValue('C3', '=SEQUENCE(3,3,-4)'); + $sheet2->setCellValue('C3', '=SEQUENCE(3,3, 9, -1)'); + + $sheet1->setCellValue('A8', "=SINGLE({$reference})"); + $sheet1->setCellValue('G3', 'three'); + $sheet1->setCellValue('G4', 'four'); + $sheet1->setCellValue('G5', 'five'); + $sheet1->setCellValue('G7', 'seven'); + $sheet1->setCellValue('G8', 'eight'); + $sheet1->setCellValue('G9', 'nine'); + + $sheet1->calculateArrays(); + $sheet2->calculateArrays(); + + $result1 = $sheet1->getCell('A8')->getCalculatedValue(); + self::assertSame($expectedResult, $result1); + $spreadsheet->disconnectWorksheets(); + } + + public static function singleDataProvider(): array + { + return [ + 'array cell on same sheet' => [ + 'C3', + -4, + ], + 'array cell on different sheet' => [ + "'Sheet Two'!C3", + 9, + ], + 'range which includes current row' => [ + 'G7:G9', + 'eight', + ], + 'range which does not include current row' => [ + 'G3:G5', + '#VALUE!', + ], + ]; + } +} diff --git a/tests/PhpSpreadsheetTests/Cell/CellArrayFormulaTest.php b/tests/PhpSpreadsheetTests/Cell/CellArrayFormulaTest.php new file mode 100644 index 0000000000..fd0d819bc6 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Cell/CellArrayFormulaTest.php @@ -0,0 +1,114 @@ +arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + Calculation::setArrayReturnType($this->arrayReturnType); + } + + public function testSetValueArrayFormulaNoSpillage(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $cell = $spreadsheet->getActiveSheet()->getCell('A1'); + $cell->setValue('=MAX(ABS({5, -3; 1, -12}))'); + + self::assertSame(12, $cell->getCalculatedValue()); + + $spreadsheet->disconnectWorksheets(); + } + + public function testSetValueArrayFormulaWithSpillage(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $cell = $spreadsheet->getActiveSheet()->getCell('A1'); + $cell->setValue('=SEQUENCE(3, 3, 1, 1)'); + + self::assertSame([[1, 2, 3], [4, 5, 6], [7, 8, 9]], $cell->getCalculatedValue()); + + $spreadsheet->disconnectWorksheets(); + } + + public function testSetValueInSpillageRangeCell(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $cell = $sheet->getCell('A1'); + $cell->setValue('=SEQUENCE(3, 3, 1, 1)'); + + $cellAddress = 'C3'; + $sheet->getCell($cellAddress)->setValue('x'); + + self::assertSame('#SPILL!', $sheet->getCell('A1')->getCalculatedValue()); + self::assertSame('x', $sheet->getCell('C3')->getCalculatedValue()); + + $spreadsheet->disconnectWorksheets(); + } + + public function testUpdateValueInSpillageRangeCell(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->getCell('A1')->setValue('=SEQUENCE(3, 3, 1, 1)'); + $sheet->getCell('A1')->getCalculatedValue(); + $attributes = $sheet->getCell('A1')->getFormulaAttributes(); + if (!isset($attributes, $attributes['ref'])) { + self::fail('No formula attributes for cell A1'); + } + $cellRange = $attributes['ref']; + $cellAddress = 'C3'; + self::assertTrue($sheet->getCell($cellAddress)->isInRange($cellRange)); + if ($this->skipUpdateInSpillageRange) { + $spreadsheet->disconnectWorksheets(); + self::markTestIncomplete('Preventing update in spill range not yet implemented'); + } + + $this->expectException(Exception::class); + $this->expectExceptionMessage("Cell {$cellAddress} is within the spillage range of a formula, and cannot be changed"); + $sheet->getCell($cellAddress)->setValue('PHP'); + + $spreadsheet->disconnectWorksheets(); + } + + public function testUpdateArrayFormulaForSpillageRange(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $calculation = Calculation::getInstance($spreadsheet); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->getCell('A1')->setValue('=SEQUENCE(3, 3, 1, 1)'); + $sheet->getCell('A1')->getCalculatedValue(); + self::assertSame([[1, 2, 3], [4, 5, 6], [7, 8, 9]], $sheet->toArray(formatData: false, reduceArrays: true)); + + $sheet->getCell('A1')->setValue('=SEQUENCE(2, 2, 4, -1)'); + $calculation->clearCalculationCache(); + $sheet->getCell('A1')->getCalculatedValue(); + self::assertSame([[4, 3, null], [2, 1, null], [null, null, null]], $sheet->toArray(formatData: false, reduceArrays: true)); + + $cellAddress = 'C3'; + $sheet->getCell($cellAddress)->setValue('PHP'); + self::assertSame('PHP', $sheet->getCell($cellAddress)->getValue(), 'change cell formerly in spill range'); + + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Cell/CellFormulaTest.php b/tests/PhpSpreadsheetTests/Cell/CellFormulaTest.php new file mode 100644 index 0000000000..0852f2b183 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Cell/CellFormulaTest.php @@ -0,0 +1,135 @@ +arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + Calculation::setArrayReturnType($this->arrayReturnType); + } + + public function testSetFormulaExplicit(): void + { + $formula = '=A2+B2'; + + $spreadsheet = new Spreadsheet(); + $cell = $spreadsheet->getActiveSheet()->getCell('A1'); + $cell->setValueExplicit($formula, DataType::TYPE_FORMULA); + + self::assertSame($formula, $cell->getValue()); + self::assertTrue($cell->isFormula()); + + $spreadsheet->disconnectWorksheets(); + } + + public function testSetFormulaDeterminedByBinder(): void + { + $formula = '=A2+B2'; + + $spreadsheet = new Spreadsheet(); + $cell = $spreadsheet->getActiveSheet()->getCell('A1'); + $cell->setValue($formula); + + self::assertSame($formula, $cell->getValue()); + self::assertTrue($cell->isFormula()); + + $spreadsheet->disconnectWorksheets(); + } + + public function testSetFormulaInvalidValue(): void + { + $formula = true; + + $spreadsheet = new Spreadsheet(); + $cell = $spreadsheet->getActiveSheet()->getCell('A1'); + + try { + $cell->setValueExplicit($formula, DataType::TYPE_FORMULA); + self::fail('setValueExplicit should have thrown exception'); + } catch (SpreadsheetException $e) { + self::assertStringContainsString('Invalid value for datatype Formula', $e->getMessage()); + } + + $spreadsheet->disconnectWorksheets(); + } + + public function testSetFormulaInvalidFormulaValue(): void + { + $formula = 'invalid formula'; + + $spreadsheet = new Spreadsheet(); + $cell = $spreadsheet->getActiveSheet()->getCell('A1'); + + try { + $cell->setValueExplicit($formula, DataType::TYPE_FORMULA); + self::fail('setValueExplicit should have thrown exception'); + } catch (SpreadsheetException $e) { + self::assertStringContainsString('Invalid value for datatype Formula', $e->getMessage()); + } + + $spreadsheet->disconnectWorksheets(); + } + + public function testSetArrayFormulaExplicitNoArray(): void + { + $formula = '=SUM(B2:B6*C2:C6)'; + + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->fromArray( + [ + [1, 6], + [2, 7], + [3, 8], + [4, 9], + [5, 10], + ], + null, + 'B2' + ); + $sheet->getCell('A1')->setValueExplicit($formula, DataType::TYPE_FORMULA); + + self::assertSame($formula, $sheet->getCell('A1')->getValue()); + self::assertTrue($sheet->getCell('A1')->isFormula()); + self::assertSame(130, $sheet->getCell('A1')->getCalculatedValue()); + self::assertEmpty($sheet->getCell('A1')->getFormulaAttributes()); + + $spreadsheet->disconnectWorksheets(); + } + + public function testSetArrayFormulaExplicitWithRange(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $formula = '=SEQUENCE(3,3,-10,2.5)'; + + $spreadsheet = new Spreadsheet(); + $cell = $spreadsheet->getActiveSheet()->getCell('A1'); + $cell->setValueExplicit($formula, DataType::TYPE_FORMULA); + + self::assertSame($formula, $cell->getValue()); + self::assertTrue($cell->isFormula()); + $expected = [ + [-10.0, -7.5, -5.0], + [-2.5, 0.0, 2.5], + [5.0, 7.5, 10.0], + ]; + self::assertSame($expected, $cell->getCalculatedValue()); + self::assertSame(['t' => 'array', 'ref' => 'A1:C3'], $cell->getFormulaAttributes()); + + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Reader/Gnumeric/ArrayFormula2Test.php b/tests/PhpSpreadsheetTests/Reader/Gnumeric/ArrayFormula2Test.php new file mode 100644 index 0000000000..ec41c3558c --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Gnumeric/ArrayFormula2Test.php @@ -0,0 +1,73 @@ +arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + Calculation::setArrayReturnType($this->arrayReturnType); + } + + /** + * @dataProvider arrayFormulaReaderProvider + */ + public function testArrayFormulaReader( + string $cellAddress, + string $expectedRange, + string $expectedFormula, + array $expectedValue + ): void { + $filename = 'tests/data/Reader/Gnumeric/ArrayFormulaTest2.gnumeric'; + $reader = new Gnumeric(); + $spreadsheet = $reader->load($filename); + $worksheet = $spreadsheet->getActiveSheet(); + + $cell = $worksheet->getCell($cellAddress); + self::assertSame(DataType::TYPE_FORMULA, $cell->getDataType()); + self::assertSame(['t' => 'array', 'ref' => $expectedRange], $cell->getFormulaAttributes()); + self::assertSame($expectedFormula, strtoupper($cell->getValue())); + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $worksheet->calculateArrays(); + $cell = $worksheet->getCell($cellAddress); + self::assertSame($expectedValue, $cell->getCalculatedValue()); + self::assertSame($expectedValue, $worksheet->rangeToArray($expectedRange, formatData: false, reduceArrays: true)); + $spreadsheet->disconnectWorksheets(); + } + + public static function arrayFormulaReaderProvider(): array + { + return [ + [ + 'D1', + 'D1:E2', + '=MMULT(A1:B2,A4:B5)', + [[21, 26], [37, 46]], + ], + [ + 'G1', + 'G1:J1', + '=SIN({-1,0,1,2})', + [[-0.8414709848078965, 0.0, 0.8414709848078965, 0.9092974268256817]], + ], + [ + 'D4', + 'D4:E5', + '=MMULT(A7:B8,A10:B11)', + [[55, 64], [79, 92]], + ], + ]; + } +} diff --git a/tests/PhpSpreadsheetTests/Reader/Gnumeric/ArrayFormulaTest.php b/tests/PhpSpreadsheetTests/Reader/Gnumeric/ArrayFormulaTest.php new file mode 100644 index 0000000000..cba92a6d3d --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Gnumeric/ArrayFormulaTest.php @@ -0,0 +1,91 @@ +arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + Calculation::setArrayReturnType($this->arrayReturnType); + } + + /** + * @dataProvider arrayFormulaReaderProvider + */ + public function testArrayFormulaReader( + string $cellAddress, + string $expectedRange, + string $expectedFormula, + array $expectedValue + ): void { + $filename = 'tests/data/Reader/Gnumeric/ArrayFormulaTest.gnumeric'; + $reader = new Gnumeric(); + $spreadsheet = $reader->load($filename); + $worksheet = $spreadsheet->getActiveSheet(); + + $cell = $worksheet->getCell($cellAddress); + self::assertSame(DataType::TYPE_FORMULA, $cell->getDataType()); + self::assertSame(['t' => 'array', 'ref' => $expectedRange], $cell->getFormulaAttributes()); + self::assertSame($expectedFormula, strtoupper($cell->getValue())); + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $worksheet->calculateArrays(); + $cell = $worksheet->getCell($cellAddress); + self::assertSame($expectedValue, $cell->getCalculatedValue()); + self::assertSame($expectedValue, $worksheet->rangeToArray($expectedRange, formatData: false, reduceArrays: true)); + $spreadsheet->disconnectWorksheets(); + } + + public static function arrayFormulaReaderProvider(): array + { + return [ + [ + 'D1', + 'D1:E2', + '=A1:B1*A1:A2', + [[4, 6], [8, 12]], + ], + [ + 'G1', + 'G1:J1', + '=SIN({-1,0,1,2})', + [[-0.8414709848078965, 0.0, 0.8414709848078965, 0.9092974268256817]], + ], + [ + 'D4', + 'D4:E5', + '=A4:B4*A4:A5', + [[9, 12], [15, 20]], + ], + [ + 'D7', + 'D7:E8', + '=A7:B7*A7:A8', + [[16, 20], [24, 30]], + ], + [ + 'D10', + 'D10:E11', + '=A10:B10*A10:A11', + [[25, 30], [35, 42]], + ], + [ + 'D13', + 'D13:E14', + '=A13:B13*A13:A14', + [[36, 42], [48, 56]], + ], + ]; + } +} diff --git a/tests/data/Reader/Gnumeric/ArrayFormulaTest.gnumeric b/tests/data/Reader/Gnumeric/ArrayFormulaTest.gnumeric new file mode 100644 index 0000000000000000000000000000000000000000..b4f7c1a2e7e095202773be47a66f49e708195a70 GIT binary patch literal 1941 zcmV;G2Wt2qiwFP!000001I<`%Z{s!+{=UCL)d58tY+_4(i`CeLk~oP0#|dmDd)-4J z&=O;_kwlfE6Q@Q0`wdA+mgQBVGzau{0f!vU3?B}M4~IJWc@qZO6J`ma(Lgtjn!1J~ zkNPB94)id~4N0coa zPe${|=f0lmZW6Mi``nURX0vHD{(U`}dn+8GLlPw%MIHvLgjk6bPN;{t#8x>~|7er= z`tXycNtpL)G1@4m-U^naYuD&E%AjRJlSH!F)3u9r5Qr5U<`HxoxxLLfBa1c1i4c{A zN|s6^6yjkPmV1JqEh|~kXLm&z`GZCr1#aRo8Uzc(P8zvI(Zv%A)_Ca5?nX*M_0`0U zZ_NBR(7^6;;{AVMi73ZH?AF@s=u9Q&^VxUeVl zS_n!anY}0U;emJ<7VW`_iTGiNIZDr@8XD+p7FiS}B#{Ubi+dK0ag>=@MQuwGM0%zJ z5rfD@z%QU{8G_ui5TY~Z!mb7(P zHZ=NHr)zb3VE7HibZ<)_a>33v=7e>I0&g8iFF`fn{7_hWgH;V9rlMn3J#U%~|h;S>k-`5W??!VU6!l8^-n2!9#qm(y!^el;37?)`W^J{?cS z&M&Gt8R?pU28hJv*0kWizC$H4ipAJY%VYyN0p`QL8cus91z6wEXyqEBTh#bUX*f*% z%XS$-l1YlFa#4{hZyAZWYs1;t=f)C0{%bb6_->CM3u-Y(_ZY0?QGAcZ1BDB@o<(*DO5sz4 z0_2M|{t^R)#+Z3P*k}p2&bF;Mw^6ct5~DD`ObbCFWrfvS3QHh?>QVfJBbPk5Gzwna zguethsa6hY1m!aWUai*g8G=2m9OmM1yfOt;Vo(l383f_3){?_6+(^qDiD1Ip*?8)> z_L(!jA1PYgv!o36f9Sh>1?x_0UL`B*5wR`uc*K-(h6vBJwrQCYp2MA8u|kMS@{pJ4&sm^(awD? z>>!MQ6Gu>>1v^X4QwvU#qANo(sKq()E0TjJ=5lHZg9*%%IN!#Xt%;MaZ+l5Xb9$b7ZIS3;s6{ya23H$NMn&A)!E;m@dy zOZy$x`*G>iur{}A8Es-VJ{OLLgAi?gC@rMUN?MgGsk29|%9YfKL+kY&Zd<4A1NgP; zM5SH1j`vvaucKSTy8EGQ=zb_0y06EuXPx#A;MYE`;j34W{p){d z|N3t*O%tf5c_9AUhEeBySJP&lP#D#`S!Wjw^F!O1oBzFi%+0HU*@9rU4#Z#Es7035 z3{@){)fCm=H{VKjZ%O$UpP(1I=9)ueRg2q3`F^Lt6IANg)W?CEMm}rlBEVtZzeXE} zgwXCb^Dk@ieFxnsjiz`hv7VE}Q{RQ`HK+24n7Lar6wV4aeBo19>C0D=f5o~RpCgVm bRRmz)L5tLZ($2m$4?q1EMsj!|;TZq`&|I!J literal 0 HcmV?d00001 diff --git a/tests/data/Reader/Gnumeric/ArrayFormulaTest2.gnumeric b/tests/data/Reader/Gnumeric/ArrayFormulaTest2.gnumeric new file mode 100644 index 0000000000000000000000000000000000000000..fce1a7447c65c534d5b578f835079aed93e24250 GIT binary patch literal 1949 zcmV;O2V(diiwFP!000001I<|fZ{oNT|9$_8ygX^OPbwt|Us`g7Q)p?6L}>+S_imq_ zB9pk_YZCJu)54wZe}7{;2_ZCFdOd01R$9g5neoTtnUBYu{k)Ds?HRL#(73Cc$A+%q z*rx%B=Ux5Yy{w(;KleVJ&Ev@aO4-wl(kBf};=~3~S6^_xwCnYkmzU#tyoxX*{xN0q z`m<>q2RzVI-E~5CbYB`$%Q6hJ{>#m1>MwAFY9vlLihT@L39%C?98n)}iLG*}&ha`4 z^xh{;lQ19DX0*YQ`U}{SuHB&DD1$ExK1nP-n{8dYT!o?7v1uK{u%6pHoHH_8ahwQI zS*T>GG)58bWnp2j!{<2oE`UpC zG(8AGNhGuPj6OaRAH$}7sF;}7BFs@*lWMA~uUKqTl#oOsNNn!gbcy54#4c*vk|5HW z4oUpSPD~jpamO$i!ony=i>l}stDwJM;>f#j&Cm6~dVh({B#Ap`c zA;&DGQOSqvT#$go0KHt`(h$C4kr~D@@G}r)ND-f)fULW^l@2e+oN%yhH44l*U0}?| za9+bW!0SR)o^@el@=Frq_dk>pcvo z!~SqIbbnQ!qk*moXn;suZiWqi`VN)IC>CQoE|U%91eo_aYC0X26kvZnvyp3vVNv57 zrBN^SFTcwOl1x%Wm5Yj8dB;f1JqK!IpBqd3_|M7U@*jKrSWsKigowoMIVrYWutl>M zlQ$8{V0td$A$}9$=l`xK|4D^kegExvw~}Nh>@twU_z}_?m3*jEWOL}c_Aqd|)b1#l zinO&LneP~WypIWFWfZAm+R>j;&S~^6lMp}h+~CA|Q=f^Vc#pwCmg0LX9w}VNhgoDr zPzs+S6d+%$@y!w_bcvY{gpKBK>ukRj=hjPh&tewVmuVp=q^z)bOJNQqP(6#EaqN*t zkH+D~OZXU8nyaT}xSf57wp1va;zNo14MdoqEHucQqEh+A|F*HG6Bn_BZLz?7p)Au1sR81NM5FLFcG(ospKT&gZvVmNvt&B?fQfRd67eV0AeYH76WIOwAM#H^w5{^&(8k?)r~O@`g`KFbmr^hfjrz>$TRyj)riXB*@A>D%o%bm2!$Urk+&d8T* z0I*k9d&Q>lmW@Iv0PWTeyqtX5y=&(ymQW_k0lcY^tcOM!Y;IJjtVXf28r#ZhZtJR9 zMzdXf_b_o7mdjblw5)2gQhpF5?#kP-30Y>PrvR#NTAO>;jdxf#M9qq~h9w2^qt}g~ z-{)T()9zbGPSftU{`C%*?aZZ_xom>Vgv4L|sF_E`k$GhOd5Ax=GCa+9Snr3Yb%3>% z?*)Q^)?Z5K4}ssPgn!8SG%DdAviZg#r+Z@ePmdhau=}QY$cCDg8#*MS&B_h653p{3 zC>Pov%7v54wGTmlQn~iY0oI)l#k}*On48u7bO>~_nxIY(F}FSx_T2oh#oer~-h;qb zmv3j^d@Cv4XyS1^LL0i`noF0eId;s#-6OSV_zbP{4GnOpmXYsFIty`>k8jZ0B@ukL zjr^xB`QU=#n8sr~msl@I;;SEOoE4|?&6c^FGZal!H-uyH_I@UOGbuby3O`1bew#}2 j-d!POXd|LW&gBbt-wV2K; literal 0 HcmV?d00001 From ef2b5b9e001bc239efedf4578b5f71c14b0ff2ae Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Fri, 21 Jun 2024 21:55:05 -0700 Subject: [PATCH 19/31] Mostly Docs and Tests Also support for Xml format. --- docs/topics/recipes.md | 76 ++++++------- .../Internal/ExcelArrayPseudoFunctions.php | 2 +- src/PhpSpreadsheet/Reader/Xml.php | 8 ++ src/PhpSpreadsheet/Worksheet/Worksheet.php | 20 ++++ .../Calculation/InternalFunctionsTest.php | 4 + .../Functional/ArrayFunctionsSpillTest.php | 48 +++++++++ .../Reader/Gnumeric/ArrayFormulaTest.php | 20 +++- .../Reader/Ods/ArrayFormulaTest.php | 101 ++++++++++++++++++ .../Reader/Xml/ArrayFormulaTest.php | 26 +++++ .../Writer/Ods/ArrayTest.php | 16 ++- .../Writer/Xlsx/ArrayFunctionsTest.php | 66 +++++++++++- tests/data/Reader/Ods/ArrayFormulaTest.ods | Bin 0 -> 14534 bytes tests/data/Reader/Xml/ArrayFormula.xml | 75 +++++++++++++ 13 files changed, 410 insertions(+), 52 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Reader/Ods/ArrayFormulaTest.php create mode 100644 tests/PhpSpreadsheetTests/Reader/Xml/ArrayFormulaTest.php create mode 100644 tests/data/Reader/Ods/ArrayFormulaTest.ods create mode 100644 tests/data/Reader/Xml/ArrayFormula.xml diff --git a/docs/topics/recipes.md b/docs/topics/recipes.md index a1cfebc5e2..5344d95a73 100644 --- a/docs/topics/recipes.md +++ b/docs/topics/recipes.md @@ -376,7 +376,7 @@ $value = $spreadsheet->getActiveSheet()->getCell('B8')->getCalculatedValue(); ### Array Formulas With version 2.0.3 of PhpSpreadsheet, we've introduced support for Excel "array formulas". -It is an opt-in feature. You need to enable it with the following code: +**It is an opt-in feature.** You need to enable it with the following code: ```php \PhpOffice\PhpSpreadsheet\Calculation\Calculation::setArrayReturnType( \PhpOffice\PhpSpreadsheet\Calculation\Calculation::RETURN_ARRAY_AS_ARRAY); @@ -405,15 +405,16 @@ However, we could have specified an alternative formula to calculate that result ![12-CalculationEngine-Array-Formula.png](./images/12-CalculationEngine-Array-Formula.png) -Entering the formula `=SUM(B2:B6*C2:C6)` will calculate the same result; but because it's using arrays, we need to enter it as an "array formula". In MS Excel itself, we'd do this by using `Shift-Ctrl-Enter` rather than simply `Enter` when we define the formula in the formula edit box. MS Excel then shows that this is an array formula in the formula edit box by wrapping it in the `{}` braces (you don't enter these in the formula yourself; MS Excel does it). -In recent releases of Excel, Shift-Ctrl-Enter is not required, and Excel does not add the braces. -PhpSpreadsheet will attempt to behave like the recent releases. +Entering the formula `=SUM(B2:B6*C2:C6)` will calculate the same result; but because it's using arrays, we need to enter it as an "array formula". In MS Excel itself, we'd do this by using `Ctrl-Shift-Enter` rather than simply `Enter` when we define the formula in the formula edit box. MS Excel then shows that this is an array formula in the formula edit box by wrapping it in the `{}` braces (you don't enter these in the formula yourself; MS Excel does it). + +**In recent releases of Excel, Ctrl-Shift-Enter is not required, and Excel does not add the braces. +PhpSpreadsheet will attempt to behave like the recent releases.** Or to identify the biggest increase in like-for-like sales from one month to the next: ![12-CalculationEngine-Array-Formula-3.png](./images/12-CalculationEngine-Array-Formula-3.png) ```php -$spreadsheet->getActiveSheet()->setCellValue('F1','=MAX(B2:B6-C2:C6)', true); +$spreadsheet->getActiveSheet()->setCellValue('F1','=MAX(B2:B6-C2:C6)'); ``` Which tells us that the biggest increase in sales between December and January was 30 more (in this case, 30 more Lemons). @@ -424,8 +425,8 @@ As an example, consider transposing a grid of data: MS Excel provides the `TRANS ![12-CalculationEngine-Array-Formula-2.png](./images/12-CalculationEngine-Array-Formula-2.png) -When we do this in MS Excel, we need to indicate ___all___ the cells that will contain the transposed data from cells `A1` to `D7`. We do this by selecting the cells where we want to display our transposed data either by holding the left mouse button down while we move with the mouse, or pressing `Shift` and using the arrow keys. -Once we've selected all the cells to hold our data, then we enter the formula `TRANSPOSE(A1:D7)` in the formula edit box, remembering to use `Shift-Ctrl-Enter` to tell MS Excel that this is an array formula. +When we do this in MS Excel, we used to need to indicate ___all___ the cells that will contain the transposed data from cells `A1` to `D7`. We do this by selecting the cells where we want to display our transposed data either by holding the left mouse button down while we move with the mouse, or pressing `Shift` and using the arrow keys. +Once we've selected all the cells to hold our data, then we enter the formula `TRANSPOSE(A1:D7)` in the formula edit box, remembering to use `Ctrl-Shift-Enter` to tell MS Excel that this is an array formula. In recent Excel, you can just enter `=TRANSPOSE(A1:D7)` into cell A10. Note also that we still set this as the formula for the top-left cell of that range, cell `A10`. @@ -434,66 +435,40 @@ Simply setting an array formula in a cell and specifying the range won't populat $spreadsheet->getActiveSheet() ->setCellValue( 'A10', - '=SEQUENCE(3,3)', - true, - 'A1:C3' + '=SEQUENCE(3,3)' ); - // Will return a null, because the formula for A1 hasn't been calculated to populate the spillage area $result = $spreadsheet->getActiveSheet()->getCell('C3')->getValue(); ``` To do that, we need to retrieve the calculated value for the cell. ```php -$spreadsheet->getActiveSheet() - ->setCellValue( - 'A10', - '=SEQUENCE(3,3)', - true, - 'A1:C3' - ); - $spreadsheet->getActiveSheet()->getCell('A1')->getCalculatedValue(); - // Will return 9, because the formula for A1 has now been calculated, and the spillage area is populated $result = $spreadsheet->getActiveSheet()->getCell('C3')->getValue(); ``` -When we call `getCalculatedValue()` for a cell that contains an array formula, PhpSpreadsheet returns the single value that would appear in that cell in MS Excel. +If returning arrays has been enabled, `getCalculatedValue` will return an array when appropriate, and will populate the spill range. If returning arrays has not been enabled, when we call `getCalculatedValue()` for a cell that contains an array formula, PhpSpreadsheet will return the single value from the topmost leftmost cell, and will leave other cells unchanged. ```php // Will return integer 1, the value for that cell within the array $a1result = $spreadsheet->getActiveSheet()->getCell('A1')->getCalculatedValue(); ``` -If we want to return the full array, then we need to call `getCalculatedValue()` with an additional argument, a boolean `true` to return the value as an array. -```php -// Will return an array [[1, 2, 3], [4, 5, 6], [7, 8, 9]] -$a1result = $spreadsheet->getActiveSheet()->getCell('A1')->getCalculatedValue(true); -``` - --- Excel365 introduced a number of new functions that return arrays of results. These include the `UNIQUE()`, `SORT()`, `SORTBY()`, `FILTER()`, `SEQUENCE()` and `RANDARRAY()` functions. While not all of these have been implemented by the Calculation Engine in PhpSpreadsheet, so they cannot all be calculated within your PHP applications, they can still be read from and written to Xlsx files. -The way these functions are presented in MS Excel itself is slightly different to that of other array functions. - The `SEQUENCE()` function generates a series of values (in this case, starting with `-10` and increasing in steps of `2.5`); and here we're telling the formula to populate a 3x3 grid with these values. ![12-CalculationEngine-Spillage-Formula.png](./images/12-CalculationEngine-Spillage-Formula.png) -Note that this is visually different to the multi-cell array formulas like `TRANSPOSE()`. When we are positioned in the "spill" range for the grid, MS Excel highlights the area with a blue border; and the formula displayed in the formula editing field isn't wrapped in braces (`{}`). +Note that this is visually different from using `Ctrl-Shift-Enter` for the formula. When we are positioned in the "spill" range for the grid, MS Excel highlights the area with a blue border; and the formula displayed in the formula editing field isn't wrapped in braces (`{}`). And if we select any other cell inside the "spill" area other than the top-left cell, the formula in the formula edit field is greyed rather than displayed in black. ![12-CalculationEngine-Spillage-Formula-2.png](./images/12-CalculationEngine-Spillage-Formula-2.png) -When we enter this formula in MS Excel, we don't need to select the range of cells that it should occupy; nor do we need to enter it using `Ctrl-Shift-Enter`. MS Excel identifies that it is a multi-cell array formula because of the function that it uses, the `SEQUENCE()` function (and if there are nested function calls in the formula, then it must be the outermost functionin the tree). - -However, PhpSpreadsheet isn't quite as intelligent (yet) and doesn't parse the formula to identify if it should be treated as an array formula or not; a formula is just a string of characters until it is actually evaluated. If we want to use this function through code, we still need to specify that it is an "array" function with the `$isArrayFormula` argument, and the range of cells that it should cover. - -```php -$spreadsheet->getActiveSheet()->setCellValue('A1','=SEQUENCE(3,3,-10,2.5)', true, 'A1:C3'); -``` +When we enter this formula in MS Excel, we don't need to select the range of cells that it should occupy; nor do we need to enter it using `Ctrl-Shift-Enter`. ### The Spill Operator @@ -502,19 +477,34 @@ To simplify this, MS Excel has introduced the "Spill" Operator (`#`). ![12-CalculationEngine-Spillage-Operator.png](./images/12-CalculationEngine-Spillage-Operator.png) -Using our `SEQUENCE()"`example, where the formula cell is `A1` and the result spills across the range `A1:C3`, we can use the Spill operator `A1#` to reference all the cells in that spillage range. +Using our `SEQUENCE()`example, where the formula cell is `A1` and the result spills across the range `A1:C3`, we can use the Spill operator `A1#` to reference all the cells in that spillage range. In this case, we're taking the absolute value of each cell in that range, and adding them together using the `SUM()` function to give us a result of 50. -PhpSpreadsheet doesn't currently support entry of a formula like this directly; but interally MS Excel implements the Spill Operator as a function (`ANCHORARRAY()`). MS Excel itself doesn't allow you to use this function in a formula, you have to use the "Spill" operator; but PhpSpreadsheet does allow you to use this internal Excel function. +PhpSpreadsheet supports entry of a formula like this using the Spill operator. Alternatively, MS Excel internally implements the Spill Operator as a function (`ANCHORARRAY()`). MS Excel itself doesn't allow you to use this function in a formula, you have to use the "Spill" operator; but PhpSpreadsheet does allow you to use this internal Excel function. PhpSpreadsheet will convert the spill operator to ANCHORARRAY on write (so it may appear that your formula has changed, but it hasn't really); it is not necessary to convert it back on read. To create this same function in PhpSpreadsheet, use: ```php -$spreadsheet->getActiveSheet()->setCellValue('D1','=SUM(ABS(ANCHORARRAY(A1)))', true); +$spreadsheet->getActiveSheet()->setCellValue('D1','=SUM(ABS(ANCHORARRAY(A1)))'); ``` -Note that this does need to be flagged as an array function with the `$isArrayFormula` argument. When the file is saved, and opened in MS Excel, it will be rendered correctly. +### The At-sign Operator + +If you want to reference just the first cell of an array formula within another formula, you could do so by prefixing it with an at-sign. You can also select the entry in a range which matches the current row in this way; so, if you enter `=@A1:A5` in cell G3, the result will be the value from A3. MS Excel again implements this under the covers by converting to a function SINGLE. PhpSpreadsheet allows the use of the SINGLE function. It does not yet support the at-sign operator, which can have a different meaning in other contexts. + +### Updating Cell in Spill Area + +Excel prevents you from updating a cell in the spill area. PhpSpreadsheet does not - it seems like it might be quite expensive, needing to reevaluate the entire worksheet with each `setValue`. PhpSpreadsheet does provide a method to be used prior to calling `setValue` if desired. +```php +$sheet->setCellValue('A1', '=SORT{7;5;1}'); +$sheet->getCell('A1')->getCalculatedValue(); // populates A1-A3 +$sheet->isCellInSpillRange('A2'); // true +$sheet->isCellInSpillRange('A3'); // true +$sheet->isCellInSpillRange('A4'); // false +$sheet->isCellInSpillRange('A1'); // false +``` +The last result might be surprising. Excel allows you to alter the formula cell itself, so `isCellInSpillRange` treats the formula cell as not in range. It should also be noted that, if array returns are not enabled, `isCellInSpillRange` will always return `false`. ## Locale Settings for Formulas @@ -552,7 +542,7 @@ $spreadsheet->getActiveSheet()->setCellValue('B8',$internalFormula); ``` Currently, formula translation only translates the function names, the -constants TRUE and FALSE, and the function argument separators. Cell addressing using R1C1 formatting is not supported. +constants TRUE and FALSE (and NULL), Excel error messages, and the function argument separators. Cell addressing using R1C1 formatting is not supported. At present, the following locale settings are supported: @@ -576,6 +566,8 @@ Russian | русский язык | ru Swedish | Svenska | sv Turkish | Türkçe | tr +If anybody can provide translations for additional languages, particularly Basque (Euskara), Catalan (Català), Croatian (Hrvatski jezik), Galician (Galego), Greek (Ελληνικά), Slovak (Slovenčina) or Slovenian (Slovenščina); please feel free to volunteer your services, and we'll happily show you what is needed to contribute a new language. + ## Write a newline character "\n" in a cell (ALT+"Enter") In Microsoft Office Excel you get a line break in a cell by hitting diff --git a/src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php b/src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php index df7ccc5efc..2bccab8da6 100644 --- a/src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php +++ b/src/PhpSpreadsheet/Calculation/Internal/ExcelArrayPseudoFunctions.php @@ -20,7 +20,7 @@ public static function single(string $cellReference, Cell $cell): mixed $ourRow = $cell->getRow(); $firstRow = (int) $matches[3]; $lastRow = (int) $matches[6]; - if ($ourRow < $firstRow || $ourRow > $lastRow) { + if ($ourRow < $firstRow || $ourRow > $lastRow || $matches[1] !== $matches[4]) { return ExcelError::VALUE(); } $referenceCellCoordinate = $matches[1] . $ourRow; diff --git a/src/PhpSpreadsheet/Reader/Xml.php b/src/PhpSpreadsheet/Reader/Xml.php index 979e74c5ef..e86fcd4c7a 100644 --- a/src/PhpSpreadsheet/Reader/Xml.php +++ b/src/PhpSpreadsheet/Reader/Xml.php @@ -403,11 +403,16 @@ public function loadIntoExisting(string $filename, Spreadsheet $spreadsheet, boo $columnID = 'A'; foreach ($rowData->Cell as $cell) { + $arrayRef = ''; $cell_ss = self::getAttributes($cell, self::NAMESPACES_SS); if (isset($cell_ss['Index'])) { $columnID = Coordinate::stringFromColumnIndex((int) $cell_ss['Index']); } $cellRange = $columnID . $rowID; + if (isset($cell_ss['ArrayRange'])) { + $arrayRange = (string) $cell_ss['ArrayRange']; + $arrayRef = AddressHelper::convertFormulaToA1($arrayRange, $rowID, Coordinate::columnIndexFromString($columnID)); + } if ($this->getReadFilter() !== null) { if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) { @@ -440,6 +445,9 @@ public function loadIntoExisting(string $filename, Spreadsheet $spreadsheet, boo if (isset($cell_ss['Formula'])) { $cellDataFormula = $cell_ss['Formula']; $hasCalculatedValue = true; + if ($arrayRef !== '') { + $spreadsheet->getActiveSheet()->getCell($columnID . $rowID)->setFormulaAttributes(['t' => 'array', 'ref' => $arrayRef]); + } } if (isset($cell->Data)) { $cellData = $cell->Data; diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 24d0c6365a..8119547d37 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -3695,4 +3695,24 @@ public function calculateArrays(bool $preCalculateFormulas = true): void } } } + + public function isCellInSpillRange(string $coordinate): bool + { + if (Calculation::getArrayReturnType() !== Calculation::RETURN_ARRAY_AS_ARRAY) { + return false; + } + $this->calculateArrays(); + $keys = $this->cellCollection->getCoordinates(); + foreach ($keys as $key) { + $attributes = $this->getCell($key)->getFormulaAttributes(); + if (isset($attributes['ref'])) { + if (Coordinate::coordinateIsInsideRange($attributes['ref'], $coordinate)) { + // false for first cell in range, true otherwise + return $coordinate !== $key; + } + } + } + + return false; + } } diff --git a/tests/PhpSpreadsheetTests/Calculation/InternalFunctionsTest.php b/tests/PhpSpreadsheetTests/Calculation/InternalFunctionsTest.php index 9914d7302c..d51d9a5fa7 100644 --- a/tests/PhpSpreadsheetTests/Calculation/InternalFunctionsTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/InternalFunctionsTest.php @@ -113,6 +113,10 @@ public static function singleDataProvider(): array 'G3:G5', '#VALUE!', ], + 'range which includes current row but spans columns' => [ + 'F7:G9', + '#VALUE!', + ], ]; } } diff --git a/tests/PhpSpreadsheetTests/Functional/ArrayFunctionsSpillTest.php b/tests/PhpSpreadsheetTests/Functional/ArrayFunctionsSpillTest.php index 52d4c75a64..408ab9904b 100644 --- a/tests/PhpSpreadsheetTests/Functional/ArrayFunctionsSpillTest.php +++ b/tests/PhpSpreadsheetTests/Functional/ArrayFunctionsSpillTest.php @@ -37,6 +37,12 @@ public function testArrayOutput(): void $sheet->setCellValue('B1', '=UNIQUE(A1:A12)'); $expected = [['#SPILL!'], [null], [null], [null], ['OCCUPIED']]; self::assertSame($expected, $sheet->rangeToArray('B1:B5', calculateFormulas: true, formatData: false, reduceArrays: true), 'spill with B5 unchanged'); + self::assertFalse($sheet->isCellInSpillRange('B1')); + self::assertFalse($sheet->isCellInSpillRange('B2')); + self::assertFalse($sheet->isCellInSpillRange('B3')); + self::assertFalse($sheet->isCellInSpillRange('B4')); + self::assertFalse($sheet->isCellInSpillRange('B5')); + self::assertFalse($sheet->isCellInSpillRange('Z9')); $calculation->clearCalculationCache(); $columnArray = [[1], [2], [2], [2], [3], [3], [3], [3], [4], [4], [4], [4]]; @@ -44,6 +50,12 @@ public function testArrayOutput(): void $sheet->setCellValue('B1', '=UNIQUE(A1:A12)'); $expected = [[1], [2], [3], [4], ['OCCUPIED']]; self::assertSame($expected, $sheet->rangeToArray('B1:B5', calculateFormulas: true, formatData: false, reduceArrays: true), 'fill B1:B4 with B5 unchanged'); + self::assertFalse($sheet->isCellInSpillRange('B1')); + self::assertTrue($sheet->isCellInSpillRange('B2')); + self::assertTrue($sheet->isCellInSpillRange('B3')); + self::assertTrue($sheet->isCellInSpillRange('B4')); + self::assertFalse($sheet->isCellInSpillRange('B5')); + self::assertFalse($sheet->isCellInSpillRange('Z9')); $calculation->clearCalculationCache(); $columnArray = [[1], [3], [3], [3], [3], [3], [3], [3], [3], [3], [3], [3]]; @@ -51,6 +63,12 @@ public function testArrayOutput(): void $sheet->setCellValue('B1', '=UNIQUE(A1:A12)'); $expected = [[1], [3], [null], [null], ['OCCUPIED']]; self::assertSame($expected, $sheet->rangeToArray('B1:B5', calculateFormulas: true, formatData: false, reduceArrays: true), 'fill B1:B2(changed from prior) set B3:B4 to null B5 unchanged'); + self::assertFalse($sheet->isCellInSpillRange('B1')); + self::assertTrue($sheet->isCellInSpillRange('B2')); + self::assertFalse($sheet->isCellInSpillRange('B3')); + self::assertFalse($sheet->isCellInSpillRange('B4')); + self::assertFalse($sheet->isCellInSpillRange('B5')); + self::assertFalse($sheet->isCellInSpillRange('Z9')); $calculation->clearCalculationCache(); $columnArray = [[1], [2], [3], [3], [3], [3], [3], [3], [3], [3], [3], [3]]; @@ -67,6 +85,36 @@ public function testArrayOutput(): void self::assertSame($expected, $sheet->rangeToArray('B1:B5', calculateFormulas: true, formatData: false, reduceArrays: true), 'spill clears B2:B4 with B5 unchanged'); $calculation->clearCalculationCache(); + $sheet->setCellValue('Z1', '=SORT({7;5;1})'); + $sheet->getCell('Z1')->getCalculatedValue(); // populates Z1-Z3 + self::assertTrue($sheet->isCellInSpillRange('Z2')); + self::assertTrue($sheet->isCellInSpillRange('Z3')); + self::assertFalse($sheet->isCellInSpillRange('Z4')); + self::assertFalse($sheet->isCellInSpillRange('Z1')); + + $spreadsheet->disconnectWorksheets(); + } + + public function testNonArrayOutput(): void + { + $spreadsheet = new Spreadsheet(); + $calculation = Calculation::getInstance($spreadsheet); + + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setCellValue('B5', 'OCCUPIED'); + + $columnArray = [[1], [2], [2], [2], [3], [3], [3], [3], [4], [4], [4], [4]]; + $sheet->fromArray($columnArray, 'A1'); + $sheet->setCellValue('B1', '=UNIQUE(A1:A12)'); + $expected = [[1], [null], [null], [null], ['OCCUPIED']]; + self::assertSame($expected, $sheet->rangeToArray('B1:B5', calculateFormulas: true, formatData: false, reduceArrays: true), 'only fill B1'); + self::assertFalse($sheet->isCellInSpillRange('B1')); + self::assertFalse($sheet->isCellInSpillRange('B2')); + self::assertFalse($sheet->isCellInSpillRange('B3')); + self::assertFalse($sheet->isCellInSpillRange('B4')); + self::assertFalse($sheet->isCellInSpillRange('B5')); + self::assertFalse($sheet->isCellInSpillRange('Z9')); + $spreadsheet->disconnectWorksheets(); } diff --git a/tests/PhpSpreadsheetTests/Reader/Gnumeric/ArrayFormulaTest.php b/tests/PhpSpreadsheetTests/Reader/Gnumeric/ArrayFormulaTest.php index cba92a6d3d..861a2e05e2 100644 --- a/tests/PhpSpreadsheetTests/Reader/Gnumeric/ArrayFormulaTest.php +++ b/tests/PhpSpreadsheetTests/Reader/Gnumeric/ArrayFormulaTest.php @@ -28,7 +28,7 @@ public function testArrayFormulaReader( string $cellAddress, string $expectedRange, string $expectedFormula, - array $expectedValue + array|float $expectedValue ): void { $filename = 'tests/data/Reader/Gnumeric/ArrayFormulaTest.gnumeric'; $reader = new Gnumeric(); @@ -37,13 +37,21 @@ public function testArrayFormulaReader( $cell = $worksheet->getCell($cellAddress); self::assertSame(DataType::TYPE_FORMULA, $cell->getDataType()); - self::assertSame(['t' => 'array', 'ref' => $expectedRange], $cell->getFormulaAttributes()); + if (is_array($expectedValue)) { + self::assertSame(['t' => 'array', 'ref' => $expectedRange], $cell->getFormulaAttributes()); + } else { + self::assertEmpty($cell->getFormulaAttributes()); + } self::assertSame($expectedFormula, strtoupper($cell->getValue())); Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); $worksheet->calculateArrays(); $cell = $worksheet->getCell($cellAddress); self::assertSame($expectedValue, $cell->getCalculatedValue()); - self::assertSame($expectedValue, $worksheet->rangeToArray($expectedRange, formatData: false, reduceArrays: true)); + if (is_array($expectedValue)) { + self::assertSame($expectedValue, $worksheet->rangeToArray($expectedRange, formatData: false, reduceArrays: true)); + } else { + self::assertSame([[$expectedValue]], $worksheet->rangeToArray($expectedRange, formatData: false, reduceArrays: true)); + } $spreadsheet->disconnectWorksheets(); } @@ -62,6 +70,12 @@ public static function arrayFormulaReaderProvider(): array '=SIN({-1,0,1,2})', [[-0.8414709848078965, 0.0, 0.8414709848078965, 0.9092974268256817]], ], + [ + 'G3', + 'G3:G3', + '=MAX(SIN({-1,0,1,2}))', + 0.9092974268256817, + ], [ 'D4', 'D4:E5', diff --git a/tests/PhpSpreadsheetTests/Reader/Ods/ArrayFormulaTest.php b/tests/PhpSpreadsheetTests/Reader/Ods/ArrayFormulaTest.php new file mode 100644 index 0000000000..2b37d33146 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Ods/ArrayFormulaTest.php @@ -0,0 +1,101 @@ +arrayReturnType = Calculation::getArrayReturnType(); + } + + protected function tearDown(): void + { + Calculation::setArrayReturnType($this->arrayReturnType); + } + + /** + * @dataProvider arrayFormulaReaderProvider + */ + public function testArrayFormulaReader( + string $cellAddress, + string $expectedRange, + string $expectedFormula, + array|float $expectedValue + ): void { + $filename = 'tests/data/Reader/Ods/ArrayFormulaTest.ods'; + $reader = new Ods(); + $spreadsheet = $reader->load($filename); + $worksheet = $spreadsheet->getActiveSheet(); + + $cell = $worksheet->getCell($cellAddress); + self::assertSame(DataType::TYPE_FORMULA, $cell->getDataType()); + self::assertSame(['t' => 'array', 'ref' => $expectedRange], $cell->getFormulaAttributes()); + self::assertSame($expectedFormula, strtoupper($cell->getValue())); + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $worksheet->calculateArrays(); + $cell = $worksheet->getCell($cellAddress); + self::assertSame($expectedValue, $cell->getCalculatedValue()); + if (is_array($expectedValue)) { + self::assertSame($expectedValue, $worksheet->rangeToArray($expectedRange, formatData: false, reduceArrays: true)); + } else { + self::assertSame([[$expectedValue]], $worksheet->rangeToArray($expectedRange, formatData: false, reduceArrays: true)); + } + $spreadsheet->disconnectWorksheets(); + } + + public static function arrayFormulaReaderProvider(): array + { + return [ + [ + 'B2', + 'B2:C3', + '={2,3}*{4;5}', + [[8, 12], [10, 15]], + ], + [ + 'E1', + 'E1:H1', + '=SIN({-1,0,1,2})', + [[-0.8414709848078965, 0.0, 0.8414709848078965, 0.9092974268256817]], + ], + [ + 'E3', + 'E3:E3', + '=MAX(SIN({-1,0,1,2}))', + 0.9092974268256817, + ], + [ + 'D5', + 'D5:E6', + '=A5:B5*A5:A6', + [[4, 6], [8, 12]], + ], + [ + 'D8', + 'D8:E9', + '=A8:B8*A8:A9', + [[9, 12], [15, 20]], + ], + [ + 'D11', + 'D11:E12', + '=A11:B11*A11:A12', + [[16, 20], [24, 30]], + ], + [ + 'D14', + 'D14:E15', + '=A14:B14*A14:A15', + [[25, 30], [35, 42]], + ], + ]; + } +} diff --git a/tests/PhpSpreadsheetTests/Reader/Xml/ArrayFormulaTest.php b/tests/PhpSpreadsheetTests/Reader/Xml/ArrayFormulaTest.php new file mode 100644 index 0000000000..6ba32aa334 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xml/ArrayFormulaTest.php @@ -0,0 +1,26 @@ +load($filename); + $sheet = $spreadsheet->getActiveSheet(); + + self::assertSame(DataType::TYPE_FORMULA, $sheet->getCell('B1')->getDataType()); + self::assertSame(['t' => 'array', 'ref' => 'B1:B3'], $sheet->getCell('B1')->getFormulaAttributes()); + self::assertSame('=CONCATENATE(A1:A3,"-",C1:C3)', strtoupper($sheet->getCell('B1')->getValue())); + self::assertSame('a-1', $sheet->getCell('B1')->getOldCalculatedValue()); + self::assertSame('b-2', $sheet->getCell('B2')->getValue()); + self::assertSame('c-3', $sheet->getCell('B3')->getValue()); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Ods/ArrayTest.php b/tests/PhpSpreadsheetTests/Writer/Ods/ArrayTest.php index 07f945477a..281b1c8656 100644 --- a/tests/PhpSpreadsheetTests/Writer/Ods/ArrayTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Ods/ArrayTest.php @@ -63,12 +63,20 @@ public function testArray(): void $sheet->getCell('B1')->setValue('=UNIQUE(A1:A3)'); $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Ods'); $sheet = $reloadedSpreadsheet->getActiveSheet(); - self::assertEquals('1', $sheet->getCell('A1')->getValue()); - self::assertEquals('1', $sheet->getCell('A2')->getValue()); - self::assertEquals('3', $sheet->getCell('A3')->getValue()); - self::assertEquals('3', $sheet->getCell('B2')->getValue()); + self::assertSame(1, $sheet->getCell('A1')->getValue()); + self::assertSame(1, $sheet->getCell('A2')->getValue()); + self::assertSame(3, $sheet->getCell('A3')->getValue()); + self::assertSame(3, $sheet->getCell('B2')->getValue()); + self::assertTrue($sheet->getCell('B1')->isFormula()); + self::assertSame(1, $sheet->getCell('B1')->getOldCalculatedValue()); self::assertNull($sheet->getCell('B3')->getValue()); self::assertEquals('=UNIQUE(A1:A3)', $sheet->getCell('B1')->getValue()); + $cellFormulaAttributes = $sheet->getCell('B1')->getFormulaAttributes(); + self::assertArrayHasKey('t', $cellFormulaAttributes); + self::assertSame('array', $cellFormulaAttributes['t']); + self::assertArrayHasKey('ref', $cellFormulaAttributes); + self::assertSame('B1:B2', $cellFormulaAttributes['ref']); + self::assertSame([[1], [3]], $sheet->getCell('B1')->getCalculatedValue()); $spreadsheet->disconnectWorksheets(); $reloadedSpreadsheet->disconnectWorksheets(); } diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php index d7265651d8..038f8c2351 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/ArrayFunctionsTest.php @@ -333,10 +333,18 @@ public function testArrayMultipleColumns(): void self::assertSame($expectedUnique, $sheet2->getCell('H1')->getCalculatedValue()); for ($row = 1; $row <= 5; ++$row) { if ($row > 1) { - self::assertSame($expectedUnique[$row - 1][0], $sheet2->getCell("H$row")->getCalculatedValue(), "cell H$row"); + self::assertSame($expectedUnique[$row - 1][0], $sheet2->getCell("H$row")->getValue(), "cell H$row"); + } else { + self::assertTrue($sheet2->getCell("H$row")->isFormula()); + self::assertSame($expectedUnique[$row - 1][0], $sheet2->getCell("H$row")->getOldCalculatedValue(), "cell H$row"); } - self::assertSame($expectedUnique[$row - 1][1], $sheet2->getCell("I$row")->getCalculatedValue(), "cell I$row"); + self::assertSame($expectedUnique[$row - 1][1], $sheet2->getCell("I$row")->getValue(), "cell I$row"); } + $cellFormulaAttributes = $sheet2->getCell('H1')->getFormulaAttributes(); + self::assertArrayHasKey('t', $cellFormulaAttributes); + self::assertSame('array', $cellFormulaAttributes['t']); + self::assertArrayHasKey('ref', $cellFormulaAttributes); + self::assertSame('H1:I5', $cellFormulaAttributes['ref']); $spreadsheet2->disconnectWorksheets(); } @@ -374,4 +382,58 @@ public function testSpill(): void self::assertSame('x', $sheet2->getCell('A3')->getValue()); $spreadsheet2->disconnectWorksheets(); } + + public function testArrayStringOutput(): void + { + Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY); + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $columnArray = [ + ['item1'], + ['item2'], + ['item3'], + ['item1'], + ['item1'], + ['item6'], + ['item7'], + ['item1'], + ['item9'], + ['item1'], + ]; + $sheet->fromArray($columnArray, 'A1'); + $sheet->setCellValue('C1', '=UNIQUE(A1:A10)'); + $writer = new XlsxWriter($spreadsheet); + $this->outputFile = File::temporaryFilename(); + $writer->save($this->outputFile); + $spreadsheet->disconnectWorksheets(); + + $reader = new XlsxReader(); + $spreadsheet2 = $reader->load($this->outputFile); + $sheet2 = $spreadsheet2->getActiveSheet(); + $expectedUnique = [ + ['item1'], + ['item2'], + ['item3'], + ['item6'], + ['item7'], + ['item9'], + ]; + self::assertCount(6, $expectedUnique); + self::assertSame($expectedUnique, $sheet2->getCell('C1')->getCalculatedValue()); + self::assertSame($expectedUnique[0][0], $sheet2->getCell('C1')->getCalculatedValueString()); + for ($row = 2; $row <= 6; ++$row) { + self::assertSame($expectedUnique[$row - 1][0], $sheet2->getCell("C$row")->getCalculatedValue(), "cell C$row"); + } + $spreadsheet2->disconnectWorksheets(); + + $file = 'zip://'; + $file .= $this->outputFile; + $file .= '#xl/worksheets/sheet1.xml'; + $data = file_get_contents($file); + if ($data === false) { + self::fail('Unable to read file'); + } else { + self::assertStringContainsString('_xlfn.UNIQUE(A1:A10)item1', $data, '6 results for UNIQUE'); + } + } } diff --git a/tests/data/Reader/Ods/ArrayFormulaTest.ods b/tests/data/Reader/Ods/ArrayFormulaTest.ods new file mode 100644 index 0000000000000000000000000000000000000000..84b5cd02a2e00241ecac09ec974974d129b8ee3d GIT binary patch literal 14534 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 i-%q*Qb+sTNBltyQDXLPQjPEiO$R& z2jB*4ytN6VwJZS5)lpl&Q?qR;by6Aaku;iHzqS&$wg+eLj6Qb-f!L6CqDyzlv(1>1 z?B~Yf`OSNKF|TfSg+tLw6;Tn)&EJ1iiJLnUh&?-_r>u+TI!! zZP;uaxx*DBZH;03+)p-W0IA!&{Gqx^crv4H>Q?QS8!0cb;%KGdLOSf3Wn50>bLudN zrTjy+>xnq4He7hX?FG@#UT=Rj*v)+(pvQ{zv)3OW5+MA2GSScdxZm_EwdlXOem{YL z@H0#0H)-A%|0#Ls&$a*N`*+00@88jXf%7|==Ayn z$mE}~M87HaK9J~l9?yUC{W0G4XWa5{>br-z&&B&!6!U*`{W0w6XDsY*D!p&E#qY@2 zUyOej74@5O7Vqz4q<*3N684Xp_s`w5P5tM+_5bGlqxSfznSaw1*Wc)ARYhc!pO4|* Oe=P5njv~*`U;ht;>*eVH literal 0 HcmV?d00001 diff --git a/tests/data/Reader/Xml/ArrayFormula.xml b/tests/data/Reader/Xml/ArrayFormula.xml new file mode 100644 index 0000000000..602a1b8a84 --- /dev/null +++ b/tests/data/Reader/Xml/ArrayFormula.xml @@ -0,0 +1,75 @@ + + + + + Owen Leibman + Owen Leibman + 2024-06-17T19:03:14Z + 2024-06-17T19:04:33Z + 16.00 + + + + + + 10300 + 19420 + 32767 + 32767 + False + False + + + + + + + + a + a-1 + 1 + + + b + b-2 + 2 + + + c + c-3 + 3 + +
+ + +

+