Skip to content

Commit d770012

Browse files
committed
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).
1 parent 784e8a0 commit d770012

File tree

17 files changed

+445
-33
lines changed

17 files changed

+445
-33
lines changed

src/PhpSpreadsheet/Calculation/Information/ExcelError.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,14 @@ public static function CALC(): string
152152
{
153153
return self::ERROR_CODES['calculation'];
154154
}
155+
156+
/**
157+
* SPILL.
158+
*
159+
* @return string #SPILL!
160+
*/
161+
public static function SPILL(): string
162+
{
163+
return self::ERROR_CODES['spill'];
164+
}
155165
}

src/PhpSpreadsheet/Calculation/TextData/Concatenate.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ public static function CONCATENATE(...$args): string
5151
*/
5252
public static function actualCONCATENATE(...$args): array|string
5353
{
54+
if (Functions::getCompatibilityMode() === Functions::COMPATIBILITY_GNUMERIC) {
55+
return self::CONCATENATE(...$args);
56+
}
5457
$result = '';
5558
foreach ($args as $operand2) {
5659
$result = self::concatenate2Args($result, $operand2);

src/PhpSpreadsheet/Cell/Cell.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ class Cell implements Stringable
6161

6262
/**
6363
* Attributes of the formula.
64+
*
65+
* @var null|array<string, string>
6466
*/
6567
private mixed $formulaAttributes = null;
6668

@@ -366,6 +368,18 @@ public function getCalculatedValueString(): string
366368
public function getCalculatedValue(bool $resetLog = true): mixed
367369
{
368370
$title = 'unknown';
371+
$oldAttributes = $this->formulaAttributes;
372+
$oldAttributesT = $oldAttributes['t'] ?? '';
373+
$coordinate = $this->getCoordinate();
374+
$oldAttributesRef = $oldAttributes['ref'] ?? $coordinate;
375+
if (!str_contains($oldAttributesRef, ':')) {
376+
$oldAttributesRef .= ":$oldAttributesRef";
377+
}
378+
$originalValue = $this->value;
379+
$originalDataType = $this->dataType;
380+
$this->formulaAttributes = [];
381+
$spill = false;
382+
369383
if ($this->dataType === DataType::TYPE_FORMULA) {
370384
try {
371385
$thisworksheet = $this->getWorksheet();
@@ -397,8 +411,79 @@ public function getCalculatedValue(bool $resetLog = true): mixed
397411
}
398412
$newColumn = $this->getColumn();
399413
if (is_array($result)) {
414+
$this->formulaAttributes['t'] = 'array';
415+
$this->formulaAttributes['ref'] = $maxCoordinate = $coordinate;
400416
$newRow = $row = $this->getRow();
401417
$column = $this->getColumn();
418+
foreach ($result as $resultRow) {
419+
if (is_array($resultRow)) {
420+
$newColumn = $column;
421+
foreach ($resultRow as $resultValue) {
422+
if ($row !== $newRow || $column !== $newColumn) {
423+
$maxCoordinate = $newColumn . $newRow;
424+
if ($thisworksheet->getCell($newColumn . $newRow)->getValue() !== null) {
425+
if (!Coordinate::coordinateIsInsideRange($oldAttributesRef, $newColumn . $newRow)) {
426+
$spill = true;
427+
428+
break;
429+
}
430+
}
431+
}
432+
++$newColumn;
433+
}
434+
++$newRow;
435+
} else {
436+
if ($row !== $newRow || $column !== $newColumn) {
437+
$maxCoordinate = $newColumn . $newRow;
438+
if ($thisworksheet->getCell($newColumn . $newRow)->getValue() !== null) {
439+
if (!Coordinate::coordinateIsInsideRange($oldAttributesRef, $newColumn . $newRow)) {
440+
$spill = true;
441+
}
442+
}
443+
}
444+
++$newColumn;
445+
}
446+
if ($spill) {
447+
break;
448+
}
449+
}
450+
if (!$spill) {
451+
$this->formulaAttributes['ref'] .= ":$maxCoordinate";
452+
}
453+
$thisworksheet->getCell($column . $row);
454+
}
455+
if (is_array($result)) {
456+
if ($oldAttributes !== null && Calculation::getArrayReturnType() === Calculation::RETURN_ARRAY_AS_ARRAY) {
457+
if (($oldAttributesT) === 'array') {
458+
$thisworksheet = $this->getWorksheet();
459+
$coordinate = $this->getCoordinate();
460+
$ref = $oldAttributesRef;
461+
if (preg_match('/^([A-Z]{1,3})([0-9]{1,7})(:([A-Z]{1,3})([0-9]{1,7}))?$/', $ref, $matches) === 1) {
462+
if (isset($matches[3])) {
463+
$minCol = $matches[1];
464+
$minRow = (int) $matches[2];
465+
$maxCol = $matches[4];
466+
++$maxCol;
467+
$maxRow = (int) $matches[5];
468+
for ($row = $minRow; $row <= $maxRow; ++$row) {
469+
for ($col = $minCol; $col !== $maxCol; ++$col) {
470+
if ("$col$row" !== $coordinate) {
471+
$thisworksheet->getCell("$col$row")->setValue(null);
472+
}
473+
}
474+
}
475+
}
476+
}
477+
$thisworksheet->getCell($coordinate);
478+
}
479+
}
480+
}
481+
if ($spill) {
482+
$result = ExcelError::SPILL();
483+
}
484+
if (is_array($result)) {
485+
$newRow = $row = $this->getRow();
486+
$newColumn = $column = $this->getColumn();
402487
foreach ($result as $resultRow) {
403488
if (is_array($resultRow)) {
404489
$newColumn = $column;
@@ -417,6 +502,8 @@ public function getCalculatedValue(bool $resetLog = true): mixed
417502
}
418503
}
419504
$thisworksheet->getCell($column . $row);
505+
$this->value = $originalValue;
506+
$this->dataType = $originalDataType;
420507
}
421508
} catch (SpreadsheetException $ex) {
422509
if (($ex->getMessage() === 'Unable to access External Workbook') && ($this->calculatedValue !== null)) {
@@ -808,6 +895,8 @@ public function setXfIndex(int $indexValue): self
808895
/**
809896
* Set the formula attributes.
810897
*
898+
* @param $attributes null|array<string, string>
899+
*
811900
* @return $this
812901
*/
813902
public function setFormulaAttributes(mixed $attributes): self
@@ -819,6 +908,8 @@ public function setFormulaAttributes(mixed $attributes): self
819908

820909
/**
821910
* Get the formula attributes.
911+
*
912+
* @return null|array<string, string>
822913
*/
823914
public function getFormulaAttributes(): mixed
824915
{

src/PhpSpreadsheet/Reader/Ods.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,11 +425,27 @@ public function loadIntoExisting(string $filename, Spreadsheet $spreadsheet): Sp
425425
$formatting = $hyperlink = null;
426426
$hasCalculatedValue = false;
427427
$cellDataFormula = '';
428+
$cellDataType = '';
429+
$cellDataRef = '';
428430

429431
if ($cellData->hasAttributeNS($tableNs, 'formula')) {
430432
$cellDataFormula = $cellData->getAttributeNS($tableNs, 'formula');
431433
$hasCalculatedValue = true;
432434
}
435+
if ($cellData->hasAttributeNS($tableNs, 'number-matrix-columns-spanned')) {
436+
if ($cellData->hasAttributeNS($tableNs, 'number-matrix-rows-spanned')) {
437+
$cellDataType = 'array';
438+
$arrayRow = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-rows-spanned');
439+
$arrayCol = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-columns-spanned');
440+
$lastRow = $rowID + $arrayRow - 1;
441+
$lastCol = $columnID;
442+
while ($arrayCol > 1) {
443+
++$lastCol;
444+
--$arrayCol;
445+
}
446+
$cellDataRef = "$columnID$rowID:$lastCol$lastRow";
447+
}
448+
}
433449

434450
// Annotations
435451
$annotation = $cellData->getElementsByTagNameNS($officeNs, 'annotation');
@@ -590,6 +606,9 @@ public function loadIntoExisting(string $filename, Spreadsheet $spreadsheet): Sp
590606
// Set value
591607
if ($hasCalculatedValue) {
592608
$cell->setValueExplicit($cellDataFormula, $type);
609+
if ($cellDataType === 'array') {
610+
$cell->setFormulaAttributes(['t' => 'array', 'ref' => $cellDataRef]);
611+
}
593612
} else {
594613
$cell->setValueExplicit($dataValue, $type);
595614
}

src/PhpSpreadsheet/Reader/Xlsx.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PhpOffice\PhpSpreadsheet\Reader;
44

5+
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
56
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
67
use PhpOffice\PhpSpreadsheet\Cell\DataType;
78
use PhpOffice\PhpSpreadsheet\Cell\Hyperlink;
@@ -884,6 +885,12 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet
884885
} else {
885886
// Formula
886887
$this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToError');
888+
$eattr = $c->attributes();
889+
if (isset($eattr['vm'])) {
890+
if ($calculatedValue === ExcelError::VALUE()) {
891+
$calculatedValue = ExcelError::SPILL();
892+
}
893+
}
887894
}
888895

889896
break;

src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,6 @@ class Namespaces
119119
const PURL_WORKSHEET = 'http://purl.oclc.org/ooxml/officeDocument/relationships/worksheet';
120120

121121
const DYNAMIC_ARRAY = 'http://schemas.microsoft.com/office/spreadsheetml/2017/dynamicarray';
122+
123+
const DYNAMIC_ARRAY_RICHDATA = 'http://schemas.microsoft.com/office/spreadsheetml/2017/richdata';
122124
}

src/PhpSpreadsheet/Style/ConditionalFormatting/CellMatcher.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ protected function wrapValue(mixed $value): float|int|string
131131

132132
protected function wrapCellValue(): float|int|string
133133
{
134+
$this->cell = $this->worksheet->getCell([$this->cellColumn, $this->cellRow]);
135+
134136
return $this->wrapValue($this->cell->getCalculatedValue());
135137
}
136138

src/PhpSpreadsheet/Worksheet/Worksheet.php

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2848,12 +2848,13 @@ public function rangeToArray(
28482848
bool $calculateFormulas = true,
28492849
bool $formatData = true,
28502850
bool $returnCellRef = false,
2851-
bool $ignoreHidden = false
2851+
bool $ignoreHidden = false,
2852+
bool $reduceArrays = false
28522853
): array {
28532854
$returnValue = [];
28542855

28552856
// Loop through rows
2856-
foreach ($this->rangeToArrayYieldRows($range, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden) as $rowRef => $rowArray) {
2857+
foreach ($this->rangeToArrayYieldRows($range, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays) as $rowRef => $rowArray) {
28572858
$returnValue[$rowRef] = $rowArray;
28582859
}
28592860

@@ -2880,7 +2881,8 @@ public function rangeToArrayYieldRows(
28802881
bool $calculateFormulas = true,
28812882
bool $formatData = true,
28822883
bool $returnCellRef = false,
2883-
bool $ignoreHidden = false
2884+
bool $ignoreHidden = false,
2885+
bool $reduceArrays = false
28842886
) {
28852887
$range = Validations::validateCellOrCellRange($range);
28862888

@@ -2926,6 +2928,11 @@ public function rangeToArrayYieldRows(
29262928
$cell = $this->cellCollection->get("{$col}{$thisRow}");
29272929
if ($cell !== null) {
29282930
$value = $this->cellToArray($cell, $calculateFormulas, $formatData, $nullValue);
2931+
if ($reduceArrays) {
2932+
while (is_array($value)) {
2933+
$value = array_shift($value);
2934+
}
2935+
}
29292936
if ($value !== $nullValue) {
29302937
$returnValue[$columnRef] = $value;
29312938
}
@@ -3026,7 +3033,8 @@ public function namedRangeToArray(
30263033
bool $calculateFormulas = true,
30273034
bool $formatData = true,
30283035
bool $returnCellRef = false,
3029-
bool $ignoreHidden = false
3036+
bool $ignoreHidden = false,
3037+
bool $reduceArrays = false
30303038
): array {
30313039
$retVal = [];
30323040
$namedRange = $this->validateNamedRange($definedName);
@@ -3035,7 +3043,7 @@ public function namedRangeToArray(
30353043
$cellRange = str_replace('$', '', $cellRange);
30363044
$workSheet = $namedRange->getWorksheet();
30373045
if ($workSheet !== null) {
3038-
$retVal = $workSheet->rangeToArray($cellRange, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden);
3046+
$retVal = $workSheet->rangeToArray($cellRange, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays);
30393047
}
30403048
}
30413049

@@ -3058,7 +3066,8 @@ public function toArray(
30583066
bool $calculateFormulas = true,
30593067
bool $formatData = true,
30603068
bool $returnCellRef = false,
3061-
bool $ignoreHidden = false
3069+
bool $ignoreHidden = false,
3070+
bool $reduceArrays = false
30623071
): array {
30633072
// Garbage collect...
30643073
$this->garbageCollect();
@@ -3068,7 +3077,7 @@ public function toArray(
30683077
$maxRow = $this->getHighestRow();
30693078

30703079
// Return
3071-
return $this->rangeToArray("A1:{$maxCol}{$maxRow}", $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden);
3080+
return $this->rangeToArray("A1:{$maxCol}{$maxRow}", $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays);
30723081
}
30733082

30743083
/**
@@ -3679,7 +3688,9 @@ public function calculateArrays(bool $preCalculateFormulas = true): void
36793688
if ($preCalculateFormulas && Calculation::getArrayReturnType() === Calculation::RETURN_ARRAY_AS_ARRAY) {
36803689
$keys = $this->cellCollection->getCoordinates();
36813690
foreach ($keys as $key) {
3682-
$this->getCell($key)->getCalculatedValue();
3691+
if ($this->getCell($key)->getDataType() === DataType::TYPE_FORMULA) {
3692+
$this->getCell($key)->getCalculatedValue();
3693+
}
36833694
}
36843695
}
36853696
}

src/PhpSpreadsheet/Writer/Ods/Content.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ private function writeCells(XMLWriter $objWriter, RowCellIterator $cells): void
196196
foreach ($cells as $cell) {
197197
/** @var Cell $cell */
198198
$column = Coordinate::columnIndexFromString($cell->getColumn()) - 1;
199+
$attributes = $cell->getFormulaAttributes() ?? [];
200+
$coordinate = $cell->getCoordinate();
199201

200202
$this->writeCellSpan($objWriter, $column, $prevColumn);
201203
$objWriter->startElement('table:table-cell');
@@ -230,6 +232,22 @@ private function writeCells(XMLWriter $objWriter, RowCellIterator $cells): void
230232
// don't do anything
231233
}
232234
}
235+
if (isset($attributes['ref'])) {
236+
if (preg_match('/^([A-Z]{1,3})([0-9]{1,7})(:([A-Z]{1,3})([0-9]{1,7}))?$/', (string) $attributes['ref'], $matches) == 1) {
237+
$matrixRowSpan = 1;
238+
$matrixColSpan = 1;
239+
if (isset($matches[3])) {
240+
$minRow = (int) $matches[2];
241+
$maxRow = (int) $matches[5];
242+
$matrixRowSpan = $maxRow - $minRow + 1;
243+
$minCol = Coordinate::columnIndexFromString($matches[1]);
244+
$maxCol = Coordinate::columnIndexFromString($matches[4]);
245+
$matrixColSpan = $maxCol - $minCol + 1;
246+
}
247+
$objWriter->writeAttribute('table:number-matrix-columns-spanned', "$matrixColSpan");
248+
$objWriter->writeAttribute('table:number-matrix-rows-spanned', "$matrixRowSpan");
249+
}
250+
}
233251
$objWriter->writeAttribute('table:formula', $this->formulaConvertor->convertFormula($cell->getValueString()));
234252
if (is_numeric($formulaValue)) {
235253
$objWriter->writeAttribute('office:value-type', 'float');

0 commit comments

Comments
 (0)