From 9fc8e501b84fe1da2f643d166dfa2d853f3fba6f Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Wed, 18 Dec 2024 17:28:36 -0800 Subject: [PATCH 01/30] Extremely Limited Support for GROUPBY Function This is a partial response to issue #4282. The actual logic to implement GROUPBY is probably very complicated. And, even worse, Excel has thrown a whole new way of (internally) specifying one of the arguments into the mix. That argument is a function name, expressed not as a mapped integer (as SUBTOTAL does), nor even as a string, but as the unquoted function name prefixed by `_xleta.`. And, unlike its `_xlfn.` and `_xlws.` predecessors, it is difficult to figure out when the new prefix needs to be added, and when it needs to be ignored. I am not even going to attempt that task with this ticket. So, what does this change do? Like earlier attempts to introduce limited functionality (such as with form controls), it is there so that using GROUPBY can be passed through - you can load a spreadsheet that contains it, and save it to a new spreadsheet, and the function and its results are preserved. Some cautionary notes. Dynamic arrays must be enabled (the function makes no sense without doing that). Changing any of the inputs used in the function may result in internal inconsistencies between PhpSpreadsheet and Excel; this is especially so if the dimensions of the returned array change as a result of changes to the input data. The programmer can avoid some of these problems by changing the formulatAttributes of the cell where the function is used; this may be difficult to do in practice. Oh, yes, using the GROUPBY cell as an argument in another formula will probably lead to problems. Finally, I confess that part of this solution looks awfully kludgey to me. With its limitations and those cautions, is it worth proceeding with this change? My gut feel is that it is more useful to proceed than not. However, I will give others the opportunity to weigh in. I will wait at least a couple of weeks into the new year before proceeding with this. --- docs/references/function-list-by-category.md | 1 + docs/references/function-list-by-name.md | 1 + .../Calculation/Calculation.php | 11 +++++- src/PhpSpreadsheet/Worksheet/Worksheet.php | 6 +++- .../Writer/Xlsx/FunctionPrefix.php | 1 + src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 6 +++- .../Reader/Xlsx/GroupByLimitedTest.php | 34 ++++++++++++++++++ tests/data/Reader/XLSX/excel-groupby-one.xlsx | Bin 0 -> 13632 bytes 8 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Reader/Xlsx/GroupByLimitedTest.php create mode 100644 tests/data/Reader/XLSX/excel-groupby-one.xlsx diff --git a/docs/references/function-list-by-category.md b/docs/references/function-list-by-category.md index 458a59b39c..fdd23ae624 100644 --- a/docs/references/function-list-by-category.md +++ b/docs/references/function-list-by-category.md @@ -245,6 +245,7 @@ COLUMNS | \PhpOffice\PhpSpreadsheet\Calculation\LookupRef\RowCo FILTER | \PhpOffice\PhpSpreadsheet\Calculation\LookupRef\Filter::filter FORMULATEXT | \PhpOffice\PhpSpreadsheet\Calculation\LookupRef\Formula::text GETPIVOTDATA | **Not yet Implemented** +GROUPBY | **Not yet Implemented** HLOOKUP | \PhpOffice\PhpSpreadsheet\Calculation\LookupRef\HLookup::lookup HYPERLINK | \PhpOffice\PhpSpreadsheet\Calculation\LookupRef\Hyperlink::set INDEX | \PhpOffice\PhpSpreadsheet\Calculation\LookupRef\Matrix::index diff --git a/docs/references/function-list-by-name.md b/docs/references/function-list-by-name.md index addc2e3e97..a403beffe8 100644 --- a/docs/references/function-list-by-name.md +++ b/docs/references/function-list-by-name.md @@ -239,6 +239,7 @@ GCD | CATEGORY_MATH_AND_TRIG | \PhpOffice\PhpSpread GEOMEAN | CATEGORY_STATISTICAL | \PhpOffice\PhpSpreadsheet\Calculation\Statistical\Averages\Mean::geometric GESTEP | CATEGORY_ENGINEERING | \PhpOffice\PhpSpreadsheet\Calculation\Engineering\Compare::GESTEP GETPIVOTDATA | CATEGORY_LOOKUP_AND_REFERENCE | **Not yet Implemented** +GROUPBY | CATEGORY_LOOKUP_AND_REFERENCE | **Not yet Implemented** GROWTH | CATEGORY_STATISTICAL | \PhpOffice\PhpSpreadsheet\Calculation\Statistical\Trends::GROWTH ## H diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index ab52fb1af9..704332b59e 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -1256,6 +1256,11 @@ public static function getExcelConstants(string $key): bool|null 'functionCall' => [Functions::class, 'DUMMY'], 'argumentCount' => '2+', ], + 'GROUPBY' => [ + 'category' => Category::CATEGORY_LOOKUP_AND_REFERENCE, + 'functionCall' => [Functions::class, 'DUMMY'], + 'argumentCount' => '3-7', + ], 'GROWTH' => [ 'category' => Category::CATEGORY_STATISTICAL, 'functionCall' => [Statistical\Trends::class, 'GROWTH'], @@ -4601,7 +4606,7 @@ private static function dataTestReference(array &$operandData): mixed private static int $matchIndex10 = 10; /** - * @return array|false + * @return array|false|string */ private function processTokenStack(mixed $tokens, ?string $cellID = null, ?Cell $cell = null) { @@ -5182,6 +5187,9 @@ private function processTokenStack(mixed $tokens, ?string $cellID = null, ?Cell } elseif (preg_match('/^' . self::CALCULATION_REGEXP_DEFINEDNAME . '$/miu', $token, $matches)) { // if the token is a named range or formula, evaluate it and push the result onto the stack $definedName = $matches[6]; + if (str_starts_with($definedName, '_xleta')) { + return Functions::NOT_YET_IMPLEMENTED; + } if ($cell === null || $pCellWorksheet === null) { return $this->raiseFormulaError("undefined name '$token'"); } @@ -5214,6 +5222,7 @@ private function processTokenStack(mixed $tokens, ?string $cellID = null, ?Cell } $result = $this->evaluateDefinedName($cell, $namedRange, $pCellWorksheet, $stack, $specifiedWorksheet !== ''); + if (isset($storeKey)) { $branchStore[$storeKey] = $result; } diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 546a6ffe5b..5943e5a993 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -49,6 +49,8 @@ class Worksheet public const MERGE_CELL_CONTENT_HIDE = 'hide'; public const MERGE_CELL_CONTENT_MERGE = 'merge'; + public const FUNCTION_LIKE_GROUPBY = '/\\b(groupby|_xleta)\\b/i'; // weird new syntax + protected const SHEET_NAME_REQUIRES_NO_QUOTES = '/^[_\p{L}][_\p{L}\p{N}]*$/mui'; /** @@ -3701,7 +3703,9 @@ public function calculateArrays(bool $preCalculateFormulas = true): void $keys = $this->cellCollection->getCoordinates(); foreach ($keys as $key) { if ($this->getCell($key)->getDataType() === DataType::TYPE_FORMULA) { - $this->getCell($key)->getCalculatedValue(); + if (preg_match(self::FUNCTION_LIKE_GROUPBY, $this->getCell($key)->getValue()) !== 1) { + $this->getCell($key)->getCalculatedValue(); + } } } } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php b/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php index a563bd9130..16834dc04d 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php @@ -142,6 +142,7 @@ class FunctionPrefix . '|drop' . '|expand' . '|filter' + . '|groupby' . '|hstack' . '|isomitted' . '|lambda' diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index 28af258297..929b4f8c09 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -1578,7 +1578,11 @@ private function writeCell(XMLWriter $objWriter, PhpspreadsheetWorksheet $worksh $mappedType = $pCell->getDataType(); if ($mappedType === DataType::TYPE_FORMULA) { if ($this->useDynamicArrays) { - $tempCalc = $pCell->getCalculatedValue(); + if (preg_match(PhpspreadsheetWorksheet::FUNCTION_LIKE_GROUPBY, $cellValue) === 1) { + $tempCalc = []; + } else { + $tempCalc = $pCell->getCalculatedValue(); + } if (is_array($tempCalc)) { $objWriter->writeAttribute('cm', '1'); } diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/GroupByLimitedTest.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/GroupByLimitedTest.php new file mode 100644 index 0000000000..a7edf6b7ed --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/GroupByLimitedTest.php @@ -0,0 +1,34 @@ +load(self::$testbook); + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Xlsx'); + $spreadsheet->disconnectWorksheets(); + $reloadedSheet = $reloadedSpreadsheet->getActiveSheet(); + self::assertSame(['t' => 'array', 'ref' => 'E3:F7'], $reloadedSheet->getCell('E3')->getFormulaAttributes()); + $group = $reloadedSheet->rangeToArray('E3:F8'); + $expected = [ + ['Design', '$505,000 '], + ['Development', '$346,000 '], + ['Marketing', '$491,000 '], + ['Research', '$573,000 '], + ['Total', '$1,915,000 '], + [null, null], + ]; + self::assertSame($expected, $reloadedSheet->rangeToArray('E3:F8')); + $reloadedSpreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/data/Reader/XLSX/excel-groupby-one.xlsx b/tests/data/Reader/XLSX/excel-groupby-one.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..dbb92b6057e6630bd54243a2e91a16fc033291c0 GIT binary patch literal 13632 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 U6a2xzke)uDpBNO*^z+yM1GEqh6aWAK literal 0 HcmV?d00001 From 52c6a67af4c58e00eb66712e376a7d7c8e7e05bc Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 19 Dec 2024 00:08:42 -0800 Subject: [PATCH 02/30] Eliminate Unused Statement --- tests/PhpSpreadsheetTests/Reader/Xlsx/GroupByLimitedTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/GroupByLimitedTest.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/GroupByLimitedTest.php index a7edf6b7ed..e41b2190ed 100644 --- a/tests/PhpSpreadsheetTests/Reader/Xlsx/GroupByLimitedTest.php +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/GroupByLimitedTest.php @@ -19,7 +19,6 @@ public function testRowBreaks(): void $spreadsheet->disconnectWorksheets(); $reloadedSheet = $reloadedSpreadsheet->getActiveSheet(); self::assertSame(['t' => 'array', 'ref' => 'E3:F7'], $reloadedSheet->getCell('E3')->getFormulaAttributes()); - $group = $reloadedSheet->rangeToArray('E3:F8'); $expected = [ ['Design', '$505,000 '], ['Development', '$346,000 '], From 2c95ec3bc86a9b7e0d4b65ff3723cd9dbb78fbc0 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 26 Dec 2024 23:07:30 -0800 Subject: [PATCH 03/30] Document Styling Whole Columns and Rows Fix #4285. Documentation change only - no changed code. It is not clear to me why using individual set options when styling an entire column works differently than applyFromArray, but the latter produces the expected result and the former doesn't. I will continue to research why. However, in the meantime, we can at least document that applyFromArray is preferred for this operation. --- docs/topics/recipes.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/topics/recipes.md b/docs/topics/recipes.md index 8b27155d90..c768c49f7e 100644 --- a/docs/topics/recipes.md +++ b/docs/topics/recipes.md @@ -925,6 +925,8 @@ getStyle('A1:M500'), rather than styling the cells individually in a loop. This is much faster compared to looping through cells and styling them individually. +**Tip** If you are styling entire row(s) or column(s), e.g. getStyle('A:A'), it is recommended to use applyFromArray as described below rather than setting the styles individually as described above. + There is also an alternative manner to set styles. The following code sets a cell's style to font bold, alignment right, top border thin and a gradient fill: From 7e24333f729477734bbaf3c92ef26f322e6cbe33 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 2 Jan 2025 09:59:34 -0800 Subject: [PATCH 04/30] Upgrade mitoteam/jpgraph They have made a change at our request to help us eliminate runInSeparateProcess for one or more tests. This will be helpful when we get to PhpUnit 11 (not imminent, since it doesn't support Php8.1, but it will happen eventually). --- composer.lock | 12 ++++++------ .../Chart/ChartsDynamicTitleTest.php | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/composer.lock b/composer.lock index 3025170cb3..cc706e608e 100644 --- a/composer.lock +++ b/composer.lock @@ -1221,16 +1221,16 @@ }, { "name": "mitoteam/jpgraph", - "version": "10.4.3", + "version": "10.4.4", "source": { "type": "git", "url": "https://github.com/mitoteam/jpgraph.git", - "reference": "f0db97108aec23a3bbb34721365931af992b83b3" + "reference": "9ad8e2fcc30f765c788a28543e9705fb541d499f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mitoteam/jpgraph/zipball/f0db97108aec23a3bbb34721365931af992b83b3", - "reference": "f0db97108aec23a3bbb34721365931af992b83b3", + "url": "https://api.github.com/repos/mitoteam/jpgraph/zipball/9ad8e2fcc30f765c788a28543e9705fb541d499f", + "reference": "9ad8e2fcc30f765c788a28543e9705fb541d499f", "shasum": "" }, "require": { @@ -1270,9 +1270,9 @@ ], "support": { "issues": "https://github.com/mitoteam/jpgraph/issues", - "source": "https://github.com/mitoteam/jpgraph/tree/10.4.3" + "source": "https://github.com/mitoteam/jpgraph/tree/10.4.4" }, - "time": "2024-12-01T06:36:31+00:00" + "time": "2025-01-01T05:39:20+00:00" }, { "name": "mpdf/mpdf", diff --git a/tests/PhpSpreadsheetTests/Chart/ChartsDynamicTitleTest.php b/tests/PhpSpreadsheetTests/Chart/ChartsDynamicTitleTest.php index 7aa484d9cb..2546d60de6 100644 --- a/tests/PhpSpreadsheetTests/Chart/ChartsDynamicTitleTest.php +++ b/tests/PhpSpreadsheetTests/Chart/ChartsDynamicTitleTest.php @@ -34,7 +34,6 @@ public function writeCharts(XlsxWriter $writer): void $writer->setIncludeCharts(true); } - #[\PHPUnit\Framework\Attributes\RunInSeparateProcess] public function testDynamicTitle(): void { // based on samples/templates/issue.3797.2007.xlsx From 27563525cc7f60302ab075ad1b436e6d89419db0 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Sat, 4 Jan 2025 21:20:45 -0800 Subject: [PATCH 05/30] Update License Someone opened a PR to do this a few days ago. I posted a comment. When I was ready to return to the ticket, I couldn't find it as opened or closed. It seemed like a reasonable idea. I will submit this PR for now and sit on it for about a week to give the original author a chance to resubmit. --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 3ec5723dde..04a90f083e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 PhpSpreadsheet Authors +Copyright (c) 2019-2025 PhpSpreadsheet Authors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From dcc25637f2ac1eb734bc020ae50693589e7885c7 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Sun, 5 Jan 2025 22:40:29 -0800 Subject: [PATCH 06/30] Retitling Cloned Worksheets Fix #641 (marked stale in 2018, but now reopened). When a sheet's title is changed, PhpSpreadsheet updates references to the old sheet name found in formulas. Which is a good idea when the sheet is attached to the spreadsheet, but a bad idea when it isn't (often because it has been cloned without re-attaching to the spreadsheet). This PR continues to change formulas in the former case, but will no longer do so for the latter. --- src/PhpSpreadsheet/Spreadsheet.php | 10 +++ src/PhpSpreadsheet/Worksheet/Worksheet.php | 6 +- .../Worksheet/Issue641Test.php | 82 +++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Worksheet/Issue641Test.php diff --git a/src/PhpSpreadsheet/Spreadsheet.php b/src/PhpSpreadsheet/Spreadsheet.php index 943db95cc3..530a56da8f 100644 --- a/src/PhpSpreadsheet/Spreadsheet.php +++ b/src/PhpSpreadsheet/Spreadsheet.php @@ -514,6 +514,16 @@ public function getActiveSheet(): Worksheet public function createSheet(?int $sheetIndex = null): Worksheet { $newSheet = new Worksheet($this); + $title = $newSheet->getTitle(); + if ($this->sheetNameExists($title)) { + $i = 1; + $newTitle = "$title $i"; + while ($this->sheetNameExists($newTitle)) { + ++$i; + $newTitle = "$title $i"; + } + $newSheet->setTitle($newTitle); + } $this->addSheet($newSheet, $sheetIndex); return $newSheet; diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 546a6ffe5b..a3ee4818b0 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -321,6 +321,7 @@ public function __construct(?Spreadsheet $parent = null, string $title = 'Worksh { // Set parent and title $this->parent = $parent; + $this->hash = spl_object_id($this); $this->setTitle($title, false); // setTitle can change $pTitle $this->setCodeName($this->getTitle()); @@ -349,7 +350,6 @@ public function __construct(?Spreadsheet $parent = null, string $title = 'Worksh $this->autoFilter = new AutoFilter('', $this); // Table collection $this->tableCollection = new ArrayObject(); - $this->hash = spl_object_id($this); } /** @@ -869,7 +869,7 @@ public function setTitle(string $title, bool $updateFormulaCellReferences = true // Syntax check self::checkSheetTitle($title); - if ($this->parent) { + if ($this->parent && $this->parent->getIndex($this, true) >= 0) { // Is there already such sheet name? if ($this->parent->sheetNameExists($title)) { // Use name, but append with lowest possible integer @@ -899,7 +899,7 @@ public function setTitle(string $title, bool $updateFormulaCellReferences = true // Set title $this->title = $title; - if ($this->parent && $this->parent->getCalculationEngine()) { + if ($this->parent && $this->parent->getIndex($this, true) >= 0 && $this->parent->getCalculationEngine()) { // New title $newTitle = $this->getTitle(); $this->parent->getCalculationEngine() diff --git a/tests/PhpSpreadsheetTests/Worksheet/Issue641Test.php b/tests/PhpSpreadsheetTests/Worksheet/Issue641Test.php new file mode 100644 index 0000000000..bbb6c8bf00 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Worksheet/Issue641Test.php @@ -0,0 +1,82 @@ +removeSheetByIndex(0); + $availableWs = []; + + $worksheet = $xlsx->createSheet(); + $worksheet->setTitle('Condensed A'); + $worksheet->getCell('A1')->setValue("=SUM('Detailed A'!A1:A10)"); + $worksheet->getCell('A2')->setValue(mt_rand(1, 30)); + $availableWs[] = 'Condensed A'; + + $worksheet = $xlsx->createSheet(); + $worksheet->setTitle('Condensed B'); + $worksheet->getCell('A1')->setValue("=SUM('Detailed B'!A1:A10)"); + $worksheet->getCell('A2')->setValue(mt_rand(1, 30)); + $availableWs[] = 'Condensed B'; + + // at this point the value in worksheet 'Condensed B' cell A1 is + // =SUM('Detailed B'!A1:A10) + + // worksheet in question is cloned and totals are attached + $totalWs1 = clone $xlsx->getSheet($xlsx->getSheetCount() - 1); + $totalWs1->setTitle('Condensed Total'); + $xlsx->addSheet($totalWs1); + $formula = '='; + foreach ($availableWs as $ws) { + $formula .= sprintf("+'%s'!A2", $ws); + } + $totalWs1->getCell('A1')->setValue("=SUM('Detailed Total'!A1:A10)"); + $totalWs1->getCell('A2')->setValue($formula); + + $availableWs = []; + + $worksheet = $xlsx->createSheet(); + $worksheet->setTitle('Detailed A'); + for ($step = 1; $step <= 10; ++$step) { + $worksheet->getCell("A{$step}")->setValue(mt_rand(1, 30)); + } + $availableWs[] = 'Detailed A'; + + $worksheet = $xlsx->createSheet(); + $worksheet->setTitle('Detailed B'); + for ($step = 1; $step <= 10; ++$step) { + $worksheet->getCell("A{$step}")->setValue(mt_rand(1, 30)); + } + $availableWs[] = 'Detailed B'; + + $totalWs2 = clone $xlsx->getSheet($xlsx->getSheetCount() - 1); + $totalWs2->setTitle('Detailed Total'); + $xlsx->addSheet($totalWs2); + + for ($step = 1; $step <= 10; ++$step) { + $formula = '='; + foreach ($availableWs as $ws) { + $formula .= sprintf("+'%s'!A%s", $ws, $step); + } + $totalWs2->getCell("A{$step}")->setValue($formula); + } + + self::assertSame("=SUM('Detailed A'!A1:A10)", $xlsx->getSheetByName('Condensed A')?->getCell('A1')?->getValue()); + self::assertSame("=SUM('Detailed B'!A1:A10)", $xlsx->getSheetByName('Condensed B')?->getCell('A1')?->getValue()); + self::assertSame("=SUM('Detailed Total'!A1:A10)", $xlsx->getSheetByName('Condensed Total')?->getCell('A1')?->getValue()); + self::assertSame("=+'Detailed A'!A1+'Detailed B'!A1", $xlsx->getSheetByName('Detailed Total')?->getCell('A1')?->getValue()); + + $xlsx->disconnectWorksheets(); + } +} From ac5d3706a4b7fcf42bdd4eeaaac6ff39f2bd0e4d Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Mon, 6 Jan 2025 16:56:53 -0800 Subject: [PATCH 07/30] Add Parameter retitleIfNeeded to AddSheet A little more useful than my first crack at this. --- src/PhpSpreadsheet/Spreadsheet.php | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/PhpSpreadsheet/Spreadsheet.php b/src/PhpSpreadsheet/Spreadsheet.php index 530a56da8f..b044f1d8a4 100644 --- a/src/PhpSpreadsheet/Spreadsheet.php +++ b/src/PhpSpreadsheet/Spreadsheet.php @@ -515,16 +515,7 @@ public function createSheet(?int $sheetIndex = null): Worksheet { $newSheet = new Worksheet($this); $title = $newSheet->getTitle(); - if ($this->sheetNameExists($title)) { - $i = 1; - $newTitle = "$title $i"; - while ($this->sheetNameExists($newTitle)) { - ++$i; - $newTitle = "$title $i"; - } - $newSheet->setTitle($newTitle); - } - $this->addSheet($newSheet, $sheetIndex); + $this->addSheet($newSheet, $sheetIndex, true); return $newSheet; } @@ -545,8 +536,20 @@ public function sheetNameExists(string $worksheetName): bool * @param Worksheet $worksheet The worksheet to add * @param null|int $sheetIndex Index where sheet should go (0,1,..., or null for last) */ - public function addSheet(Worksheet $worksheet, ?int $sheetIndex = null): Worksheet + public function addSheet(Worksheet $worksheet, ?int $sheetIndex = null, bool $retitleIfNeeded = false): Worksheet { + if ($retitleIfNeeded) { + $title = $worksheet->getTitle(); + if ($this->sheetNameExists($title)) { + $i = 1; + $newTitle = "$title $i"; + while ($this->sheetNameExists($newTitle)) { + ++$i; + $newTitle = "$title $i"; + } + $worksheet->setTitle($newTitle); + } + } if ($this->sheetNameExists($worksheet->getTitle())) { throw new Exception( "Workbook already contains a worksheet named '{$worksheet->getTitle()}'. Rename this worksheet first." From 0ecf9c22731d3049bd71c122b0a105ef329b1acd Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Mon, 6 Jan 2025 16:59:04 -0800 Subject: [PATCH 08/30] Eliminate Unneeded Statement --- src/PhpSpreadsheet/Spreadsheet.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/PhpSpreadsheet/Spreadsheet.php b/src/PhpSpreadsheet/Spreadsheet.php index b044f1d8a4..f40a8889ac 100644 --- a/src/PhpSpreadsheet/Spreadsheet.php +++ b/src/PhpSpreadsheet/Spreadsheet.php @@ -514,7 +514,6 @@ public function getActiveSheet(): Worksheet public function createSheet(?int $sheetIndex = null): Worksheet { $newSheet = new Worksheet($this); - $title = $newSheet->getTitle(); $this->addSheet($newSheet, $sheetIndex, true); return $newSheet; From 77f3f17ee87c6d77378d4998ec5ca772c785b267 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Tue, 7 Jan 2025 16:49:37 -0800 Subject: [PATCH 09/30] getStyle Accept RowRange or ColumnRange Using Phpstan Fix #4309. No executable source code is changed, just some doc blocks, and some new tests added. --- src/PhpSpreadsheet/Worksheet/AutoFilter.php | 2 +- src/PhpSpreadsheet/Worksheet/Table.php | 4 ++-- src/PhpSpreadsheet/Worksheet/Validations.php | 4 ++-- src/PhpSpreadsheet/Worksheet/Worksheet.php | 14 ++++++------- .../Cell/ColumnRangeTest.php | 20 +++++++++++++++++++ .../PhpSpreadsheetTests/Cell/RowRangeTest.php | 17 ++++++++++++++++ 6 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/PhpSpreadsheet/Worksheet/AutoFilter.php b/src/PhpSpreadsheet/Worksheet/AutoFilter.php index 2c4a469559..4b88fcadc9 100644 --- a/src/PhpSpreadsheet/Worksheet/AutoFilter.php +++ b/src/PhpSpreadsheet/Worksheet/AutoFilter.php @@ -50,7 +50,7 @@ public function setEvaluated(bool $value): void /** * Create a new AutoFilter. * - * @param AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|string $range + * @param AddressRange|AddressRange|AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|string $range * A simple string containing a Cell range like 'A1:E10' is permitted * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), * or an AddressRange object. diff --git a/src/PhpSpreadsheet/Worksheet/Table.php b/src/PhpSpreadsheet/Worksheet/Table.php index 234014a0ff..a7a62a954c 100644 --- a/src/PhpSpreadsheet/Worksheet/Table.php +++ b/src/PhpSpreadsheet/Worksheet/Table.php @@ -64,7 +64,7 @@ class Table implements Stringable /** * Create a new Table. * - * @param AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|string $range + * @param AddressRange|AddressRange|AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|string $range * A simple string containing a Cell range like 'A1:E10' is permitted * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), * or an AddressRange object. @@ -268,7 +268,7 @@ public function getRange(): string /** * Set Table Cell Range. * - * @param AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|string $range + * @param AddressRange|AddressRange|AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|string $range * A simple string containing a Cell range like 'A1:E10' is permitted * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), * or an AddressRange object. diff --git a/src/PhpSpreadsheet/Worksheet/Validations.php b/src/PhpSpreadsheet/Worksheet/Validations.php index 99b5eedbc5..ec78c22b0b 100644 --- a/src/PhpSpreadsheet/Worksheet/Validations.php +++ b/src/PhpSpreadsheet/Worksheet/Validations.php @@ -36,7 +36,7 @@ public static function validateCellAddress(null|CellAddress|string|array $cellAd /** * Validate a cell address or cell range. * - * @param AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|CellAddress|int|string $cellRange Coordinate of the cells as a string, eg: 'C5:F12'; + * @param AddressRange|AddressRange|AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|CellAddress|int|string $cellRange Coordinate of the cells as a string, eg: 'C5:F12'; * or as an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 12]), * or as a CellAddress or AddressRange object. */ @@ -59,7 +59,7 @@ public static function validateCellOrCellRange(AddressRange|CellAddress|int|stri /** * Validate a cell range. * - * @param AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|string $cellRange Coordinate of the cells as a string, eg: 'C5:F12'; + * @param AddressRange|AddressRange|AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|string $cellRange Coordinate of the cells as a string, eg: 'C5:F12'; * or as an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 12]), * or as an AddressRange object. */ diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 546a6ffe5b..9f9457f644 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -1389,7 +1389,7 @@ public function getStyles(): array /** * Get style for cell. * - * @param AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|CellAddress|int|string $cellCoordinate + * @param AddressRange|AddressRange|AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|CellAddress|int|string $cellCoordinate * A simple string containing a cell address like 'A1' or a cell range like 'A1:E10' * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), * or a CellAddress or AddressRange object. @@ -1693,7 +1693,7 @@ public function getColumnBreaks(): array /** * Set merge on a cell range. * - * @param AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|string $range A simple string containing a Cell range like 'A1:E10' + * @param AddressRange|AddressRange|AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|string $range A simple string containing a Cell range like 'A1:E10' * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), * or an AddressRange. * @param string $behaviour How the merged cells should behave. @@ -1818,7 +1818,7 @@ public function mergeCellBehaviour(Cell $cell, string $upperLeft, string $behavi /** * Remove merge on a cell range. * - * @param AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|string $range A simple string containing a Cell range like 'A1:E10' + * @param AddressRange|AddressRange|AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|string $range A simple string containing a Cell range like 'A1:E10' * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), * or an AddressRange. * @@ -1869,7 +1869,7 @@ public function setMergeCells(array $mergeCells): static /** * Set protection on a cell or cell range. * - * @param AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|CellAddress|int|string $range A simple string containing a Cell range like 'A1:E10' + * @param AddressRange|AddressRange|AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|CellAddress|int|string $range A simple string containing a Cell range like 'A1:E10' * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), * or a CellAddress or AddressRange object. * @param string $password Password to unlock the protection @@ -1892,7 +1892,7 @@ public function protectCells(AddressRange|CellAddress|int|string|array $range, s /** * Remove protection on a cell or cell range. * - * @param AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|CellAddress|int|string $range A simple string containing a Cell range like 'A1:E10' + * @param AddressRange|AddressRange|AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|CellAddress|int|string $range A simple string containing a Cell range like 'A1:E10' * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), * or a CellAddress or AddressRange object. * @@ -1950,7 +1950,7 @@ public function getAutoFilter(): AutoFilter /** * Set AutoFilter. * - * @param AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|AutoFilter|string $autoFilterOrRange + * @param AddressRange|AddressRange|AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|AutoFilter|string $autoFilterOrRange * A simple string containing a Cell range like 'A1:E10' is permitted for backward compatibility * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), * or an AddressRange. @@ -2696,7 +2696,7 @@ public function setSelectedCell(string $coordinate): static /** * Select a range of cells. * - * @param AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|CellAddress|int|string $coordinate A simple string containing a Cell range like 'A1:E10' + * @param AddressRange|AddressRange|AddressRange|array{0: int, 1: int, 2: int, 3: int}|array{0: int, 1: int}|CellAddress|int|string $coordinate A simple string containing a Cell range like 'A1:E10' * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), * or a CellAddress or AddressRange object. * diff --git a/tests/PhpSpreadsheetTests/Cell/ColumnRangeTest.php b/tests/PhpSpreadsheetTests/Cell/ColumnRangeTest.php index 10da1dd21c..f15352aee9 100644 --- a/tests/PhpSpreadsheetTests/Cell/ColumnRangeTest.php +++ b/tests/PhpSpreadsheetTests/Cell/ColumnRangeTest.php @@ -43,6 +43,7 @@ public function testCreateColumnRangeWithWorksheet(): void self::assertSame('E', $columnRange->to()); self::assertSame("'Mark''s Worksheet'!C:E", (string) $columnRange); self::assertSame("'Mark''s Worksheet'!C1:E1048576", (string) $columnRange->toCellRange()); + $spreadsheet->disconnectWorksheets(); } public function testCreateColumnRangeFromArray(): void @@ -88,4 +89,23 @@ public function testColumnRangePrevious(): void // Check that original Column Range isn't changed self::assertSame('C:E', (string) $columnRange); } + + public function testIssue4309(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $columnRange = new ColumnRange('A', 'A'); + $columnStyle = $sheet->getStyle($columnRange); + $columnStyle->applyFromArray([ + 'font' => ['bold' => true], + ]); + $columnXf = $sheet->getColumnDimension('A')->getXfIndex(); + self::assertNotNull($columnXf); + self::assertTrue( + $spreadsheet->getCellXfByIndex($columnXf) + ->getFont()->getBold() + ); + + $spreadsheet->disconnectWorksheets(); + } } diff --git a/tests/PhpSpreadsheetTests/Cell/RowRangeTest.php b/tests/PhpSpreadsheetTests/Cell/RowRangeTest.php index 52fd357f51..005f1af6b2 100644 --- a/tests/PhpSpreadsheetTests/Cell/RowRangeTest.php +++ b/tests/PhpSpreadsheetTests/Cell/RowRangeTest.php @@ -39,6 +39,7 @@ public function testCreateRowRangeWithWorksheet(): void self::assertSame(3, $rowRange->from()); self::assertSame(5, $rowRange->to()); self::assertSame("'Mark''s Worksheet'!3:5", (string) $rowRange); + $spreadsheet->disconnectWorksheets(); } public function testCreateRowRangeFromArray(): void @@ -74,4 +75,20 @@ public function testRowRangePrevious(): void // Check that original Row Range isn't changed self::assertSame('3:5', (string) $rowRange); } + + public function testIssue4309(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $rowRange = new RowRange(1, 1); + $rowStyle = $sheet->getStyle($rowRange); + $rowStyle->applyFromArray([ + 'font' => ['name' => 'Arial'], + ]); + $rowXf = $sheet->getRowDimension(1)->getXfIndex(); + self::assertNotNull($rowXf); + self::assertSame('Arial', $spreadsheet->getCellXfByIndex($rowXf)->getFont()->getName()); + + $spreadsheet->disconnectWorksheets(); + } } From decc0a4d09e4db07b606a62b06c3645e4181ba45 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:29:38 -0800 Subject: [PATCH 10/30] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f19a8cd4..1d78534a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Fixed - Xlsx Reader Shared Formula with Boolean Result. Partial solution for [Issue #4280](https://github.com/PHPOffice/PhpSpreadsheet/issues/4280) [PR #4281](https://github.com/PHPOffice/PhpSpreadsheet/pull/4281) +- Retitling cloned Worksheets. [Issue #641](https://github.com/PHPOffice/PhpSpreadsheet/issues/641) [PR #4302](https://github.com/PHPOffice/PhpSpreadsheet/pull/4302) ## 2024-12-26 - 3.7.0 From bfcfaeea8ed27fa1a495d6752ec0ec640898c3d5 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:04:47 -0800 Subject: [PATCH 11/30] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d78534a3e..51defbef9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). - Xlsx Reader Shared Formula with Boolean Result. Partial solution for [Issue #4280](https://github.com/PHPOffice/PhpSpreadsheet/issues/4280) [PR #4281](https://github.com/PHPOffice/PhpSpreadsheet/pull/4281) - Retitling cloned Worksheets. [Issue #641](https://github.com/PHPOffice/PhpSpreadsheet/issues/641) [PR #4302](https://github.com/PHPOffice/PhpSpreadsheet/pull/4302) +- Extremely limited support for GROUPBY function. Partial response to [Issue #4282](https://github.com/PHPOffice/PhpSpreadsheet/issues/4282) [PR #4283](https://github.com/PHPOffice/PhpSpreadsheet/pull/4283) ## 2024-12-26 - 3.7.0 From 3d98d34b8ee36e57a5e30bcd1263a2ce21ba8701 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 9 Jan 2025 23:16:39 -0800 Subject: [PATCH 12/30] Ods Reader Sheet Names with Period in Addresses and Formulas Fix #4311. Period is a valid character in a sheet name. When a sheet with such a name is referenced in Ods format, the sheet name must be enclosed in apostrophes, because Ods uses period to separate sheet name from cell address. (Excel uses exclamation point so doesn't necessarily need to enclose the sheet name in apostrophes.) This causes a problem for the Ods Reader whenever it tries to parse such an address; however, the problem showed up specifically for auto filters, because the Ods xml for those specifies *'sheetname'.startcell:'sheetname'.endcell* (Excel omits sheetname). Ods Reader translates these addresses in 2 different methods in FormulaTranslator. I had a relatively elegant method for handling this situation in convertToExcelAddressValue, but I could not make it work in convertToExcelFormulaValue. A kludgier method works for Formula, and also for Address. I decided it's better to be consistent, so I'm going with the kludgier method for both. It would not surprise me in the least if there are similar problems lying in wait for other special characters in sheet names, and for other formats besides Ods. For now, I will limit myself to fixing the known problem. --- .../Reader/Ods/FormulaTranslator.php | 26 ++++++-- .../Reader/Ods/FormulaTranslatorTest.php | 62 +++++++++++++++++++ .../Writer/Ods/AutoFilterTest.php | 25 ++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Reader/Ods/FormulaTranslatorTest.php diff --git a/src/PhpSpreadsheet/Reader/Ods/FormulaTranslator.php b/src/PhpSpreadsheet/Reader/Ods/FormulaTranslator.php index 27862d7a5a..6e54e0039e 100644 --- a/src/PhpSpreadsheet/Reader/Ods/FormulaTranslator.php +++ b/src/PhpSpreadsheet/Reader/Ods/FormulaTranslator.php @@ -6,10 +6,24 @@ class FormulaTranslator { - public static function convertToExcelAddressValue(string $openOfficeAddress): string + private static function replaceQuotedPeriod(string $value): string { - $excelAddress = $openOfficeAddress; + $value2 = ''; + $quoted = false; + foreach (mb_str_split($value, 1, 'UTF-8') as $char) { + if ($char === "'") { + $quoted = !$quoted; + } elseif ($char === '.' && $quoted) { + $char = "\u{fffe}"; + } + $value2 .= $char; + } + return $value2; + } + + public static function convertToExcelAddressValue(string $openOfficeAddress): string + { // Cell range 3-d reference // As we don't support 3-d ranges, we're just going to take a quick and dirty approach // and assume that the second worksheet reference is the same as the first @@ -20,6 +34,7 @@ public static function convertToExcelAddressValue(string $openOfficeAddress): st '/\$?([^\.]+)\.([^\.]+)/miu', // Cell reference in another sheet '/\.([^\.]+):\.([^\.]+)/miu', // Cell range reference '/\.([^\.]+)/miu', // Simple cell reference + '/\\x{FFFE}/miu', // restore quoted periods ], [ '$1!$2:$4', @@ -27,8 +42,9 @@ public static function convertToExcelAddressValue(string $openOfficeAddress): st '$1!$2', '$1:$2', '$1', + '.', ], - $excelAddress + self::replaceQuotedPeriod($openOfficeAddress) ); return $excelAddress; @@ -52,14 +68,16 @@ public static function convertToExcelFormulaValue(string $openOfficeFormula): st '/\[\$?([^\.]+)\.([^\.]+)\]/miu', // Cell reference in another sheet '/\[\.([^\.]+):\.([^\.]+)\]/miu', // Cell range reference '/\[\.([^\.]+)\]/miu', // Simple cell reference + '/\\x{FFFE}/miu', // restore quoted periods ], [ '$1!$2:$3', '$1!$2', '$1:$2', '$1', + '.', ], - $value + self::replaceQuotedPeriod($value) ); // Convert references to defined names/formulae $value = str_replace('$$', '', $value); diff --git a/tests/PhpSpreadsheetTests/Reader/Ods/FormulaTranslatorTest.php b/tests/PhpSpreadsheetTests/Reader/Ods/FormulaTranslatorTest.php new file mode 100644 index 0000000000..8246784bf3 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Ods/FormulaTranslatorTest.php @@ -0,0 +1,62 @@ + ["'sheet1.bug'!a1:a5", "'sheet1.bug'.a1:'sheet1.bug'.a5"], + 'range special chars and period in sheet name' => ["'#sheet1.x'!a1:a5", "'#sheet1.x'.a1:'#sheet1.x'.a5"], + 'cell period in sheet name' => ["'sheet1.bug'!b9", "'sheet1.bug'.b9"], + 'range unquoted sheet name' => ['sheet1!b9:c12', 'sheet1.b9:sheet1.c12'], + 'range unquoted sheet name with $' => ['sheet1!$b9:c$12', 'sheet1.$b9:sheet1.c$12'], + 'range quoted sheet name with $' => ["'sheet1'!\$b9:c\$12", '\'sheet1\'.$b9:\'sheet1\'.c$12'], + 'cell unquoted sheet name' => ['sheet1!B$9', 'sheet1.B$9'], + 'range no sheet name all dollars' => ['$B$9:$C$12', '$B$9:$C$12'], + 'range no sheet name some dollars' => ['B$9:$C12', 'B$9:$C12'], + 'range no sheet name no dollars' => ['B9:C12', 'B9:C12'], + ]; + } + + #[DataProvider('formulaProvider')] + public function testFormulas(string $result, string $formula): void + { + self::assertSame($result, FormulaTranslator::convertToExcelFormulaValue($formula)); + } + + public static function formulaProvider(): array + { + return [ + 'ranges no sheet name' => [ + 'SUM(A5:A7,B$5:$B7)', + 'SUM([.A5:.A7];[.B$5:.$B7])', + ], + 'ranges sheet name with period' => [ + 'SUM(\'test.bug\'!A5:A7,\'test.bug\'!B5:B7)', + 'SUM([\'test.bug\'.A5:.A7];[\'test.bug\'.B5:.B7])', + ], + 'ranges unquoted sheet name' => [ + 'SUM(testbug!A5:A7,testbug!B5:B7)', + 'SUM([testbug.A5:.A7];[testbug.B5:.B7])', + ], + 'ranges quoted sheet name without period' => [ + 'SUM(\'testbug\'!A5:A7,\'testbug\'!B5:B7)', + 'SUM([\'testbug\'.A5:.A7];[\'testbug\'.B5:.B7])', + ], + ]; + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Ods/AutoFilterTest.php b/tests/PhpSpreadsheetTests/Writer/Ods/AutoFilterTest.php index ddfe85570a..952db5cb5b 100644 --- a/tests/PhpSpreadsheetTests/Writer/Ods/AutoFilterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Ods/AutoFilterTest.php @@ -32,4 +32,29 @@ public function testAutoFilterWriter(): void self::assertSame('A1:C9', $reloaded->getActiveSheet()->getAutoFilter()->getRange()); } + + public function testPeriodInSheetNames(): void + { + $spreadsheet = new Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + $worksheet->setTitle('work.sheet'); + + $dataSet = [ + ['Year', 'Quarter', 'Sales'], + [2020, 'Q1', 100], + [2020, 'Q2', 120], + [2020, 'Q3', 140], + [2020, 'Q4', 160], + [2021, 'Q1', 180], + [2021, 'Q2', 75], + [2021, 'Q3', 0], + [2021, 'Q4', 0], + ]; + $worksheet->fromArray($dataSet, null, 'A1'); + $worksheet->getAutoFilter()->setRange('A1:C9'); + + $reloaded = $this->writeAndReload($spreadsheet, 'Ods'); + + self::assertSame('A1:C9', $reloaded->getActiveSheet()->getAutoFilter()->getRange()); + } } From 4088381ccfaf241d7d42c333de0dc8c98e338743 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Sat, 11 Jan 2025 18:00:07 -0800 Subject: [PATCH 13/30] Merge commit from fork --- src/PhpSpreadsheet/Writer/Html.php | 2 +- .../Writer/Html/NavigationBadTitleTest.php | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/PhpSpreadsheetTests/Writer/Html/NavigationBadTitleTest.php diff --git a/src/PhpSpreadsheet/Writer/Html.php b/src/PhpSpreadsheet/Writer/Html.php index d70a067f6f..48e8450352 100644 --- a/src/PhpSpreadsheet/Writer/Html.php +++ b/src/PhpSpreadsheet/Writer/Html.php @@ -561,7 +561,7 @@ public function generateNavigation(): string $html .= '