Skip to content

Commit be25c44

Browse files
committed
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 #3659 (comment). 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.
1 parent 9a94aea commit be25c44

File tree

5 files changed

+205
-2
lines changed

5 files changed

+205
-2
lines changed

src/PhpSpreadsheet/Calculation/Calculation.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3489,6 +3489,7 @@ public function calculateCellValue(?Cell $cell = null, bool $resetLog = true): m
34893489
$testSheet->getCell($cellAddress['cell']);
34903490
}
34913491
}
3492+
self::$returnArrayAsType = $returnArrayAsType;
34923493

34933494
throw new Exception($e->getMessage(), $e->getCode(), $e);
34943495
}

src/PhpSpreadsheet/Cell/Cell.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ public function getCalculatedValue(bool $resetLog = true): mixed
356356
$this->getWorksheet()->setSelectedCells($selected);
357357
$this->getWorksheet()->getParentOrThrow()->setActiveSheetIndex($index);
358358
// We don't yet handle array returns
359-
if (is_array($result)) {
359+
if (is_array($result) && Calculation::getArrayReturnType() !== Calculation::RETURN_ARRAY_AS_ARRAY) {
360360
while (is_array($result)) {
361361
$result = array_shift($result);
362362
}

src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1402,10 +1402,24 @@ private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell
14021402
}
14031403

14041404
$attributes = $cell->getFormulaAttributes();
1405+
$ref = $cell->getCoordinate();
1406+
if (is_array($calculatedValue)) {
1407+
$attributes['t'] = 'array';
1408+
$rows = max(1, count($calculatedValue));
1409+
$cols = 1;
1410+
foreach ($calculatedValue as $row) {
1411+
$cols = max($cols, is_array($row) ? count($row) : 1);
1412+
}
1413+
$firstCellArray = Coordinate::indexesFromString($ref);
1414+
$lastRow = $firstCellArray[1] + $rows - 1;
1415+
$lastColumn = $firstCellArray[0] + $cols - 1;
1416+
$lastColumnString = Coordinate::stringFromColumnIndex($lastColumn);
1417+
$ref .= ":$lastColumnString$lastRow";
1418+
}
14051419
if (($attributes['t'] ?? null) === 'array') {
14061420
$objWriter->startElement('f');
14071421
$objWriter->writeAttribute('t', 'array');
1408-
$objWriter->writeAttribute('ref', $cell->getCoordinate());
1422+
$objWriter->writeAttribute('ref', $ref);
14091423
$objWriter->writeAttribute('aca', '1');
14101424
$objWriter->writeAttribute('ca', '1');
14111425
$objWriter->text(FunctionPrefix::addFunctionPrefixStripEquals($cellValue));

tests/PhpSpreadsheetTests/Worksheet/Table/Issue3659Test.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,24 @@
44

55
namespace PhpOffice\PhpSpreadsheetTests\Worksheet\Table;
66

7+
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
78
use PhpOffice\PhpSpreadsheet\Worksheet\Table;
89

910
class Issue3659Test extends SetupTeardown
1011
{
12+
private string $arrayReturnType;
13+
14+
protected function setUp(): void
15+
{
16+
$this->arrayReturnType = Calculation::getArrayReturnType();
17+
}
18+
19+
protected function tearDown(): void
20+
{
21+
parent::tearDown();
22+
Calculation::setArrayReturnType($this->arrayReturnType);
23+
}
24+
1125
public function testTableOnOtherSheet(): void
1226
{
1327
$spreadsheet = $this->getSpreadsheet();
@@ -44,4 +58,51 @@ public function testTableOnOtherSheet(): void
4458
self::assertSame('F8', $tableSheet->getSelectedCells());
4559
self::assertSame($sheet, $spreadsheet->getActiveSheet());
4660
}
61+
62+
public function testTableAsArray(): void
63+
{
64+
Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY);
65+
$spreadsheet = $this->getSpreadsheet();
66+
$sheet = $this->getSheet();
67+
$sheet->setTitle('Feuil1');
68+
$tableSheet = $spreadsheet->createSheet();
69+
$tableSheet->setTitle('sheet_with_table');
70+
$tableSheet->fromArray(
71+
[
72+
['MyCol', 'Colonne2', 'Colonne3'],
73+
[10, 20],
74+
[2],
75+
[3],
76+
[4],
77+
],
78+
null,
79+
'B1',
80+
true
81+
);
82+
$table = new Table('B1:D5', 'Tableau1');
83+
$tableSheet->addTable($table);
84+
$sheet->setSelectedCells('F7');
85+
$tableSheet->setSelectedCells('F8');
86+
self::assertSame($sheet, $spreadsheet->getActiveSheet());
87+
$sheet->getCell('F1')->setValue('=Tableau1[MyCol]');
88+
$sheet->getCell('H1')->setValue('=Tableau1[]');
89+
$sheet->getCell('F9')->setValue('=Tableau1');
90+
$sheet->getCell('J9')->setValue('=CONCAT(Tableau1)');
91+
$sheet->getCell('J11')->setValue('=SUM(Tableau1[])');
92+
$expectedResult = [2 => ['B' => 10], ['B' => 2], ['B' => 3], ['B' => 4]];
93+
self::assertSame($expectedResult, $sheet->getCell('F1')->getCalculatedValue());
94+
$expectedResult = [
95+
2 => ['B' => 10, 'C' => 20, 'D' => null],
96+
['B' => 2, 'C' => null, 'D' => null],
97+
['B' => 3, 'C' => null, 'D' => null],
98+
['B' => 4, 'C' => null, 'D' => null],
99+
];
100+
self::assertSame($expectedResult, $sheet->getCell('H1')->getCalculatedValue());
101+
self::assertSame($expectedResult, $sheet->getCell('F9')->getCalculatedValue());
102+
self::assertSame('1020234', $sheet->getCell('J9')->getCalculatedValue(), 'Header row not included');
103+
self::assertSame(39, $sheet->getCell('J11')->getCalculatedValue(), 'Header row not included');
104+
self::assertSame('F7', $sheet->getSelectedCells());
105+
self::assertSame('F8', $tableSheet->getSelectedCells());
106+
self::assertSame($sheet, $spreadsheet->getActiveSheet());
107+
}
47108
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpOffice\PhpSpreadsheetTests\Writer\Xlsx;
6+
7+
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
8+
use PhpOffice\PhpSpreadsheet\Reader\Xlsx as XlsxReader;
9+
use PhpOffice\PhpSpreadsheet\Shared\File;
10+
use PhpOffice\PhpSpreadsheet\Spreadsheet;
11+
use PhpOffice\PhpSpreadsheet\Writer\Xlsx as XlsxWriter;
12+
use PHPUnit\Framework\TestCase;
13+
14+
class ArrayFunctionsTest extends TestCase
15+
{
16+
private string $arrayReturnType;
17+
18+
private string $outputFile = '';
19+
20+
protected function setUp(): void
21+
{
22+
$this->arrayReturnType = Calculation::getArrayReturnType();
23+
}
24+
25+
protected function tearDown(): void
26+
{
27+
Calculation::setArrayReturnType($this->arrayReturnType);
28+
if ($this->outputFile !== '') {
29+
unlink($this->outputFile);
30+
$this->outputFile = '';
31+
}
32+
}
33+
34+
public function testArrayOutput(): void
35+
{
36+
Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_ARRAY);
37+
$spreadsheet = new Spreadsheet();
38+
$sheet = $spreadsheet->getActiveSheet();
39+
$columnArray = [
40+
[41],
41+
[57],
42+
[51],
43+
[54],
44+
[49],
45+
[43],
46+
[35],
47+
[35],
48+
[44],
49+
[47],
50+
[48],
51+
[26],
52+
[57],
53+
[34],
54+
[61],
55+
[34],
56+
[28],
57+
[29],
58+
[41],
59+
];
60+
$sheet->fromArray($columnArray, 'A1');
61+
$sheet->setCellValue('C1', '=UNIQUE(A1:A19)');
62+
$sheet->setCellValue('D1', '=SORT(A1:A19)');
63+
$writer = new XlsxWriter($spreadsheet);
64+
$this->outputFile = File::temporaryFilename();
65+
$writer->save($this->outputFile);
66+
$spreadsheet->disconnectWorksheets();
67+
68+
$reader = new XlsxReader();
69+
$spreadsheet2 = $reader->load($this->outputFile);
70+
$sheet2 = $spreadsheet2->getActiveSheet();
71+
$expectedUnique = [
72+
['41'],
73+
['57'],
74+
['51'],
75+
['54'],
76+
['49'],
77+
['43'],
78+
['35'],
79+
['44'],
80+
['47'],
81+
['48'],
82+
['26'],
83+
['34'],
84+
['61'],
85+
['28'],
86+
['29'],
87+
];
88+
self::assertCount(15, $expectedUnique);
89+
self::assertSame($expectedUnique, $sheet2->getCell('C1')->getCalculatedValue());
90+
$expectedSort = [
91+
[26],
92+
[28],
93+
[29],
94+
[34],
95+
[34],
96+
[35],
97+
[35],
98+
[41],
99+
[41],
100+
[43],
101+
[44],
102+
[47],
103+
[48],
104+
[49],
105+
[51],
106+
[54],
107+
[57],
108+
[57],
109+
[61],
110+
];
111+
self::assertCount(19, $expectedSort);
112+
self::assertCount(19, $columnArray);
113+
self::assertSame($expectedSort, $sheet2->getCell('D1')->getCalculatedValue());
114+
$spreadsheet2->disconnectWorksheets();
115+
116+
$file = 'zip://';
117+
$file .= $this->outputFile;
118+
$file .= '#xl/worksheets/sheet1.xml';
119+
$data = file_get_contents($file);
120+
if ($data === false) {
121+
self::fail('Unable to read file');
122+
} else {
123+
self::assertStringContainsString('<c r="C1"><f t="array" ref="C1:C15" aca="1" ca="1">_xlfn.UNIQUE(A1:A19)</f></c>', $data, '15 results for UNIQUE');
124+
self::assertStringContainsString('<c r="D1"><f t="array" ref="D1:D19" aca="1" ca="1">_xlfn._xlws.SORT(A1:A19)</f></c>', $data, '19 results for SORT');
125+
}
126+
}
127+
}

0 commit comments

Comments
 (0)