From 64a1d1ab02aaca914e348019ff49db8ecc9b99e6 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Mon, 30 Oct 2023 09:35:02 -0700 Subject: [PATCH 1/4] Slk Shared Formulas (#3776) * Slk Shared Formulas Fix #2267. The Slk format has a way to express a "shared formula", but the Slk reader does not yet understand it. Thanks to @SheetJSDev for documenting the problem and pointing the way towards a solution. It has taken a long time to get there. Part of the problem is that I have not been successful in getting Excel to use this type of construction when saving a Slk file. So I have resorted to saving a Slk file where shared formulas *could* be used, and then editing it by hand to actually use them. It would not surprise me in the least to have neglected one or more possible ways to specify a shared formula; but, at least the issue as documented is resolved, and if new issues arise, we'll probably be in better shape to deal with them. * Update CHANGELOG.md --- CHANGELOG.md | 1 + src/PhpSpreadsheet/Reader/Slk.php | 32 ++++++- .../Reader/Slk/SlkSharedFormulasTest.php | 38 ++++++++ tests/data/Reader/Slk/issue.2267c.slk | 89 +++++++++++++++++++ 4 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Reader/Slk/SlkSharedFormulasTest.php create mode 100644 tests/data/Reader/Slk/issue.2267c.slk diff --git a/CHANGELOG.md b/CHANGELOG.md index 05dda2d384..407abdd093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). - Missing Font Index in Some Xls. [PR #3734](https://github.com/PHPOffice/PhpSpreadsheet/pull/3734) - Load Tables even with READ_DATA_ONLY. [PR #3726](https://github.com/PHPOffice/PhpSpreadsheet/pull/3726) - Theme File Missing but Referenced in Spreadsheet. [Issue #3770](https://github.com/PHPOffice/PhpSpreadsheet/issues/3770) [PR #3772](https://github.com/PHPOffice/PhpSpreadsheet/pull/3772) +- Slk Shared Formulas. [Issue #2267](https://github.com/PHPOffice/PhpSpreadsheet/issues/2267) [PR #3776](https://github.com/PHPOffice/PhpSpreadsheet/pull/3776) ## 1.29.0 - 2023-06-15 diff --git a/src/PhpSpreadsheet/Reader/Slk.php b/src/PhpSpreadsheet/Reader/Slk.php index 3b026fe277..275d0971b9 100644 --- a/src/PhpSpreadsheet/Reader/Slk.php +++ b/src/PhpSpreadsheet/Reader/Slk.php @@ -5,6 +5,7 @@ use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Reader\Exception as ReaderException; +use PhpOffice\PhpSpreadsheet\ReferenceHelper; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Style\Border; @@ -269,14 +270,14 @@ private function processCRecord(array $rowData, Spreadsheet &$spreadsheet, strin $hasCalculatedValue = false; $tryNumeric = false; $cellDataFormula = $cellData = ''; + $sharedColumn = $sharedRow = -1; + $sharedFormula = false; foreach ($rowData as $rowDatum) { switch ($rowDatum[0]) { - case 'C': case 'X': $column = substr($rowDatum, 1); break; - case 'R': case 'Y': $row = substr($rowDatum, 1); @@ -298,9 +299,36 @@ private function processCRecord(array $rowData, Spreadsheet &$spreadsheet, strin ->getText() ->createText($comment); + break; + case 'C': + $sharedColumn = (int) substr($rowDatum, 1); + + break; + case 'R': + $sharedRow = (int) substr($rowDatum, 1); + + break; + case 'S': + $sharedFormula = true; + break; } } + if ($sharedFormula === true && $sharedRow >= 0 && $sharedColumn >= 0) { + $thisCoordinate = Coordinate::stringFromColumnIndex((int) $column) . $row; + $sharedCoordinate = Coordinate::stringFromColumnIndex($sharedColumn) . $sharedRow; + $formula = $spreadsheet->getActiveSheet()->getCell($sharedCoordinate)->getValue(); + $spreadsheet->getActiveSheet()->getCell($thisCoordinate)->setValue($formula); + $referenceHelper = ReferenceHelper::getInstance(); + $newFormula = $referenceHelper->updateFormulaReferences($formula, 'A1', (int) $column - $sharedColumn, (int) $row - $sharedRow, '', true, false); + $spreadsheet->getActiveSheet()->getCell($thisCoordinate)->setValue($newFormula); + //$calc = $spreadsheet->getActiveSheet()->getCell($thisCoordinate)->getCalculatedValue(); + //$spreadsheet->getActiveSheet()->getCell($thisCoordinate)->setCalculatedValue($calc); + $cellData = Calculation::unwrapResult($cellData); + $spreadsheet->getActiveSheet()->getCell($thisCoordinate)->setCalculatedValue($cellData, $tryNumeric); + + return; + } $columnLetter = Coordinate::stringFromColumnIndex((int) $column); $cellData = Calculation::unwrapResult($cellData); diff --git a/tests/PhpSpreadsheetTests/Reader/Slk/SlkSharedFormulasTest.php b/tests/PhpSpreadsheetTests/Reader/Slk/SlkSharedFormulasTest.php new file mode 100644 index 0000000000..15af9f8db8 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Slk/SlkSharedFormulasTest.php @@ -0,0 +1,38 @@ +load($testbook); + $sheet = $spreadsheet->getActiveSheet(); + $range = 'A1:' . $sheet->getHighestDataColumn() . $sheet->getHighestDataRow(); + $values = $sheet->RangeToArray($range, null, false, false, false, false); // just get values, don't calculate + $expected = [ + [1, 10, 100, 101, 102], + ['=A1+1', '=B1+1', '=C1+1', '=D1+1', '=E1+1'], + ['=A2+1', '=B2+1', '=C2+1', '=D2+1', '=E2+1'], + ['=A3+1', '=B3+1', '=C3+1', '=D3+1', '=E3+1'], + ['=A4+1', '=B4+1', '=C4+1', '=D4+1', '=E4+1'], + ]; + self::assertSame($expected, $values); + $calcValues = $sheet->RangeToArray($range, null, true, false, false, false); // get calculated values + $expectedCalc = [ + [1, 10, 100, 101, 102], + [2, 11, 101, 102, 103], + [3, 12, 102, 103, 104], + [4, 13, 103, 104, 105], + [5, 14, 104, 105, 106], + ]; + self::assertSame($expectedCalc, $calcValues); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/data/Reader/Slk/issue.2267c.slk b/tests/data/Reader/Slk/issue.2267c.slk new file mode 100644 index 0000000000..e069d6c06e --- /dev/null +++ b/tests/data/Reader/Slk/issue.2267c.slk @@ -0,0 +1,89 @@ +ID;PWXL;N;E +P;PGeneral +P;P0 +P;P0.00 +P;P#,##0 +P;P#,##0.00 +P;P#,##0_);;\(#,##0\) +P;P#,##0_);;[Red]\(#,##0\) +P;P#,##0.00_);;\(#,##0.00\) +P;P#,##0.00_);;[Red]\(#,##0.00\) +P;P"$"#,##0_);;\("$"#,##0\) +P;P"$"#,##0_);;[Red]\("$"#,##0\) +P;P"$"#,##0.00_);;\("$"#,##0.00\) +P;P"$"#,##0.00_);;[Red]\("$"#,##0.00\) +P;P0% +P;P0.00% +P;P0.00E+00 +P;P##0.0E+0 +P;P#\ ?/? +P;P#\ ??/?? +P;Pyyyy/mm/dd +P;Pdd/mmm/yy +P;Pdd/mmm +P;Pmmm/yy +P;Ph:mm\ AM/PM +P;Ph:mm:ss\ AM/PM +P;Ph:mm +P;Ph:mm:ss +P;Pyyyy/mm/dd\ h:mm +P;Pmm:ss +P;Pmm:ss.0 +P;P@ +P;P[h]:mm:ss +P;P_("$"* #,##0_);;_("$"* \(#,##0\);;_("$"* "-"_);;_(@_) +P;P_(* #,##0_);;_(* \(#,##0\);;_(* "-"_);;_(@_) +P;P_("$"* #,##0.00_);;_("$"* \(#,##0.00\);;_("$"* "-"??_);;_(@_) +P;P_(* #,##0.00_);;_(* \(#,##0.00\);;_(* "-"??_);;_(@_) +P;FCalibri;M220;L9 +P;FCalibri;M220;L9 +P;FCalibri;M220;L9 +P;FCalibri;M220;L9 +P;ECalibri;M220;L9 +P;ECalibri Light;M360;L55 +P;ECalibri;M300;SB;L55 +P;ECalibri;M260;SB;L55 +P;ECalibri;M220;SB;L55 +P;ECalibri;M220;L18 +P;ECalibri;M220;L21 +P;ECalibri;M220;L61 +P;ECalibri;M220;L63 +P;ECalibri;M220;SB;L64 +P;ECalibri;M220;SB;L53 +P;ECalibri;M220;L53 +P;ECalibri;M220;SB;L10 +P;ECalibri;M220;L11 +P;ECalibri;M220;SI;L24 +P;ECalibri;M220;SB;L9 +P;ECalibri;M220;L10 +P;ESegoe UI;M200;L9 +F;P0;DG0G8;M320 +B;Y6;X5;D0 0 5 4 +O;D;V0;K47;G100 0.001 +F;W1 16384 10 +C;Y1;X1;K1 +C;X2;K10 +C;X3;K100 +C;X4;K101 +C;X5;K102 +C;Y2;X1;K2;ER[-1]C+1 +C;X2;K11;ER[-1]C+1 +C;X3;K101;S;R2;C1 +C;X4;K102;S;R2;C1 +C;X5;K103;S;R2;C1 +C;Y3;X1;K3;S;R2;C1 +C;X2;K12;ER[-1]C+1 +C;X3;K102;S;R2;C1 +C;X4;K103;S;R2;C1 +C;X5;K104;S;R2;C1 +C;Y4;X1;K4;S;R2;C1 +C;X2;K13;ER[-1]C+1 +C;X3;K103;S;R2;C1 +C;X4;K104;S;R2;C1 +C;X5;K105;S;R2;C1 +C;Y5;X1;K5;S;R2;C1 +C;X2;K14;ER[-1]C+1 +C;X3;K104;S;R2;C1 +C;X4;K105;S;R2;C1 +C;X5;K106;S;R2;C1 +E From 98b5c77424b8d1fa822461999684df125f66be75 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Tue, 31 Oct 2023 09:08:15 -0700 Subject: [PATCH 2/4] Anticipate Dependabot November 2023 (#3780) --- composer.lock | 51 +++++++++---------- .../Calculation/Calculation.php | 2 +- .../Shared/OLE/ChainedBlockStream.php | 3 +- src/PhpSpreadsheet/Writer/Xls.php | 10 ++-- 4 files changed, 30 insertions(+), 36 deletions(-) diff --git a/composer.lock b/composer.lock index 562eaac4ea..e2e3341b20 100644 --- a/composer.lock +++ b/composer.lock @@ -1136,16 +1136,16 @@ "packages-dev": [ { "name": "composer/pcre", - "version": "3.1.0", + "version": "3.1.1", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "4bff79ddd77851fe3cdd11616ed3f92841ba5bd2" + "reference": "00104306927c7a0919b4ced2aaa6782c1e61a3c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/4bff79ddd77851fe3cdd11616ed3f92841ba5bd2", - "reference": "4bff79ddd77851fe3cdd11616ed3f92841ba5bd2", + "url": "https://api.github.com/repos/composer/pcre/zipball/00104306927c7a0919b4ced2aaa6782c1e61a3c9", + "reference": "00104306927c7a0919b4ced2aaa6782c1e61a3c9", "shasum": "" }, "require": { @@ -1187,7 +1187,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.1.0" + "source": "https://github.com/composer/pcre/tree/3.1.1" }, "funding": [ { @@ -1203,7 +1203,7 @@ "type": "tidelift" } ], - "time": "2022-11-17T09:50:14+00:00" + "time": "2023-10-11T07:11:09+00:00" }, { "name": "composer/semver", @@ -1564,16 +1564,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.34.0", + "version": "v3.37.1", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "7c7a4ad2ed8fe50df3e25528218b13d383608f23" + "reference": "c3fe76976081ab871aa654e872da588077e19679" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/7c7a4ad2ed8fe50df3e25528218b13d383608f23", - "reference": "7c7a4ad2ed8fe50df3e25528218b13d383608f23", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/c3fe76976081ab871aa654e872da588077e19679", + "reference": "c3fe76976081ab871aa654e872da588077e19679", "shasum": "" }, "require": { @@ -1594,9 +1594,6 @@ "symfony/process": "^5.4 || ^6.0", "symfony/stopwatch": "^5.4 || ^6.0" }, - "conflict": { - "stevebauman/unfinalize": "*" - }, "require-dev": { "facile-it/paraunit": "^1.3 || ^2.0", "justinrainbow/json-schema": "^5.2", @@ -1609,8 +1606,6 @@ "phpspec/prophecy": "^1.16", "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^9.5", - "phpunitgoodpractices/polyfill": "^1.6", - "phpunitgoodpractices/traits": "^1.9.2", "symfony/phpunit-bridge": "^6.2.3", "symfony/yaml": "^5.4 || ^6.0" }, @@ -1650,7 +1645,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.34.0" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.37.1" }, "funding": [ { @@ -1658,7 +1653,7 @@ "type": "github" } ], - "time": "2023-09-29T15:34:26+00:00" + "time": "2023-10-29T20:51:23+00:00" }, { "name": "masterminds/html5", @@ -2376,16 +2371,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.10.36", + "version": "1.10.40", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "ffa3089511121a672e62969404e4fddc753f9b15" + "reference": "93c84b5bf7669920d823631e39904d69b9c7dc5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ffa3089511121a672e62969404e4fddc753f9b15", - "reference": "ffa3089511121a672e62969404e4fddc753f9b15", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/93c84b5bf7669920d823631e39904d69b9c7dc5d", + "reference": "93c84b5bf7669920d823631e39904d69b9c7dc5d", "shasum": "" }, "require": { @@ -2434,20 +2429,20 @@ "type": "tidelift" } ], - "time": "2023-09-29T14:07:45+00:00" + "time": "2023-10-30T14:48:31+00:00" }, { "name": "phpstan/phpstan-phpunit", - "version": "1.3.14", + "version": "1.3.15", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-phpunit.git", - "reference": "614acc10c522e319639bf38b0698a4a566665f04" + "reference": "70ecacc64fe8090d8d2a33db5a51fe8e88acd93a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/614acc10c522e319639bf38b0698a4a566665f04", - "reference": "614acc10c522e319639bf38b0698a4a566665f04", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/70ecacc64fe8090d8d2a33db5a51fe8e88acd93a", + "reference": "70ecacc64fe8090d8d2a33db5a51fe8e88acd93a", "shasum": "" }, "require": { @@ -2484,9 +2479,9 @@ "description": "PHPUnit extensions and rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-phpunit/issues", - "source": "https://github.com/phpstan/phpstan-phpunit/tree/1.3.14" + "source": "https://github.com/phpstan/phpstan-phpunit/tree/1.3.15" }, - "time": "2023-08-25T09:46:39+00:00" + "time": "2023-10-09T18:58:39+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index ad13a500de..d8d0557399 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -4409,7 +4409,7 @@ private function internalParseFormula($formula, ?Cell $cell = null): bool|array [$rangeWS2, $val] = Worksheet::extractSheetTitle($val, true); if ($rangeWS2 !== '') { $rangeWS2 .= '!'; - } else { // @phpstan-ignore-line + } else { $rangeWS2 = $rangeWS1; } diff --git a/src/PhpSpreadsheet/Shared/OLE/ChainedBlockStream.php b/src/PhpSpreadsheet/Shared/OLE/ChainedBlockStream.php index 3c4e03c5a4..f95c9025fd 100644 --- a/src/PhpSpreadsheet/Shared/OLE/ChainedBlockStream.php +++ b/src/PhpSpreadsheet/Shared/OLE/ChainedBlockStream.php @@ -161,8 +161,7 @@ public function stream_seek($offset, $whence): bool // @codingStandardsIgnoreLin $this->pos = $offset; } elseif ($whence == SEEK_CUR && -$offset <= $this->pos) { $this->pos += $offset; - // @phpstan-ignore-next-line - } elseif ($whence == SEEK_END && -$offset <= count($this->data)) { + } elseif ($whence == SEEK_END && -$offset <= count($this->data)) { // @phpstan-ignore-line $this->pos = strlen($this->data) + $offset; } else { return false; diff --git a/src/PhpSpreadsheet/Writer/Xls.php b/src/PhpSpreadsheet/Writer/Xls.php index b2f2bc48b1..1b96579e03 100644 --- a/src/PhpSpreadsheet/Writer/Xls.php +++ b/src/PhpSpreadsheet/Writer/Xls.php @@ -747,12 +747,12 @@ private function writeDocumentSummaryInformation(): string $dataSection_Content .= $dataProp['data']['data']; $dataSection_Content_Offset += 4 + 4 + strlen($dataProp['data']['data']); - /* Condition below can never be true - } elseif ($dataProp['type']['data'] == 0x40) { // Filetime (64-bit value representing the number of 100-nanosecond intervals since January 1, 1601) - $dataSection_Content .= $dataProp['data']['data']; + /* Condition below can never be true + } elseif ($dataProp['type']['data'] == 0x40) { // Filetime (64-bit value representing the number of 100-nanosecond intervals since January 1, 1601) + $dataSection_Content .= $dataProp['data']['data']; - $dataSection_Content_Offset += 4 + 8; - */ + $dataSection_Content_Offset += 4 + 8; + */ } else { $dataSection_Content .= $dataProp['data']['data']; From ef3890aad34e3b7ce059de8536134896638b3663 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Tue, 7 Nov 2023 09:07:11 -0800 Subject: [PATCH 3/4] Integrate Exceptions Better with PhpStorm (#3765) Fix #3754. Because `PhpSpreadsheetException` extends `Exception`, PhpStorm warns that calls to `setValueExplicit` and `setValue`, among others, do not handle the exception, because PhpStorm treats `Exception`, by default, as "checked". On the other hand, PhpStorm treats `RunTimeException` as "unchecked" so won't flag such calls. It is reasonable to let `PhpSpreadsheetException` extend `RuntTimeException`, and will eliminate the problem, without having to wrap code in all-but-useless try-catch blocks (e.g. for `setValue`, the code would raise an exception only if you try to set the cell's value to an unstringable object). --- src/PhpSpreadsheet/Cell/Cell.php | 78 ++++++++++++++--------- src/PhpSpreadsheet/Exception.php | 4 +- src/PhpSpreadsheet/Writer/Ods/Content.php | 4 +- 3 files changed, 54 insertions(+), 32 deletions(-) diff --git a/src/PhpSpreadsheet/Cell/Cell.php b/src/PhpSpreadsheet/Cell/Cell.php index 5b8ba19834..8ebc115a88 100644 --- a/src/PhpSpreadsheet/Cell/Cell.php +++ b/src/PhpSpreadsheet/Cell/Cell.php @@ -3,9 +3,10 @@ namespace PhpOffice\PhpSpreadsheet\Cell; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; +use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalculationException; use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError; use PhpOffice\PhpSpreadsheet\Collection\Cells; -use PhpOffice\PhpSpreadsheet\Exception; +use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException; use PhpOffice\PhpSpreadsheet\RichText\RichText; use PhpOffice\PhpSpreadsheet\Shared\Date as SharedDate; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; @@ -76,13 +77,13 @@ class Cell implements Stringable /** * Update the cell into the cell collection. * - * @return $this + * @throws SpreadsheetException */ public function updateInCollection(): self { $parent = $this->parent; if ($parent === null) { - throw new Exception('Cannot update when cell is not bound to a worksheet'); + throw new SpreadsheetException('Cannot update when cell is not bound to a worksheet'); } $parent->update($this); @@ -101,6 +102,8 @@ public function attach(Cells $parent): void /** * Create a new Cell. + * + * @throws SpreadsheetException */ public function __construct(mixed $value, ?string $dataType, Worksheet $worksheet) { @@ -117,19 +120,21 @@ public function __construct(mixed $value, ?string $dataType, Worksheet $workshee } $this->dataType = $dataType; } elseif (self::getValueBinder()->bindValue($this, $value) === false) { - throw new Exception('Value could not be bound to cell.'); + throw new SpreadsheetException('Value could not be bound to cell.'); } $this->ignoredErrors = new IgnoredErrors(); } /** * Get cell coordinate column. + * + * @throws SpreadsheetException */ public function getColumn(): string { $parent = $this->parent; if ($parent === null) { - throw new Exception('Cannot get column when cell is not bound to a worksheet'); + throw new SpreadsheetException('Cannot get column when cell is not bound to a worksheet'); } return $parent->getCurrentColumn(); @@ -137,12 +142,14 @@ public function getColumn(): string /** * Get cell coordinate row. + * + * @throws SpreadsheetException */ public function getRow(): int { $parent = $this->parent; if ($parent === null) { - throw new Exception('Cannot get row when cell is not bound to a worksheet'); + throw new SpreadsheetException('Cannot get row when cell is not bound to a worksheet'); } return $parent->getCurrentRow(); @@ -151,9 +158,9 @@ public function getRow(): int /** * Get cell coordinate. * - * @return string + * @throws SpreadsheetException */ - public function getCoordinate() + public function getCoordinate(): string { $parent = $this->parent; if ($parent !== null) { @@ -162,7 +169,7 @@ public function getCoordinate() $coordinate = null; } if ($coordinate === null) { - throw new Exception('Coordinate no longer exists'); + throw new SpreadsheetException('Coordinate no longer exists'); } return $coordinate; @@ -216,15 +223,13 @@ protected static function updateIfCellIsTableHeader(?Worksheet $workSheet, self * @param mixed $value Value * @param null|IValueBinder $binder Value Binder to override the currently set Value Binder * - * @throws Exception - * - * @return $this + * @throws SpreadsheetException */ public function setValue(mixed $value, ?IValueBinder $binder = null): self { $binder ??= self::getValueBinder(); if (!$binder->bindValue($this, $value)) { - throw new Exception('Value could not be bound to cell.'); + throw new SpreadsheetException('Value could not be bound to cell.'); } return $this; @@ -241,9 +246,9 @@ public function setValue(mixed $value, ?IValueBinder $binder = null): self * If you do mismatch value and datatype, then the value you enter may be changed to match the datatype * that you specify. * - * @return Cell + * @throws SpreadsheetException */ - public function setValueExplicit(mixed $value, string $dataType = DataType::TYPE_STRING) + public function setValueExplicit(mixed $value, string $dataType = DataType::TYPE_STRING): self { $oldValue = $this->value; @@ -265,7 +270,7 @@ public function setValueExplicit(mixed $value, string $dataType = DataType::TYPE break; case DataType::TYPE_NUMERIC: if (is_string($value) && !is_numeric($value)) { - throw new Exception('Invalid numeric value for datatype Numeric'); + throw new SpreadsheetException('Invalid numeric value for datatype Numeric'); } $this->value = 0 + $value; @@ -288,7 +293,7 @@ public function setValueExplicit(mixed $value, string $dataType = DataType::TYPE break; default: - throw new Exception('Invalid datatype: ' . $dataType); + throw new SpreadsheetException('Invalid datatype: ' . $dataType); } // set the datatype @@ -313,11 +318,12 @@ public static function getCalculateDateTimeType(): int return self::$calculateDateTimeType; } + /** @throws CalculationException*/ public static function setCalculateDateTimeType(int $calculateDateTimeType): void { self::$calculateDateTimeType = match ($calculateDateTimeType) { self::CALCULATE_DATE_TIME_ASIS, self::CALCULATE_DATE_TIME_FLOAT, self::CALCULATE_TIME_FLOAT => $calculateDateTimeType, - default => throw new \PhpOffice\PhpSpreadsheet\Calculation\Exception("Invalid value $calculateDateTimeType for calculated date time type"), + default => throw new CalculationException("Invalid value $calculateDateTimeType for calculated date time type"), }; } @@ -348,9 +354,9 @@ private function convertDateTimeInt(mixed $result) * * @param bool $resetLog Whether the calculation engine logger should be reset or not * - * @return mixed + * @throws CalculationException */ - public function getCalculatedValue(bool $resetLog = true) + public function getCalculatedValue(bool $resetLog = true): mixed { if ($this->dataType === DataType::TYPE_FORMULA) { try { @@ -368,14 +374,14 @@ public function getCalculatedValue(bool $resetLog = true) $result = array_shift($result); } } - } catch (Exception $ex) { + } catch (SpreadsheetException $ex) { if (($ex->getMessage() === 'Unable to access External Workbook') && ($this->calculatedValue !== null)) { return $this->calculatedValue; // Fallback for calculations referencing external files. } elseif (preg_match('/[Uu]ndefined (name|offset: 2|array key 2)/', $ex->getMessage()) === 1) { return ExcelError::NAME(); } - throw new \PhpOffice\PhpSpreadsheet\Calculation\Exception( + throw new CalculationException( $this->getWorksheet()->getTitle() . '!' . $this->getCoordinate() . ' -> ' . $ex->getMessage(), $ex->getCode(), $ex @@ -453,11 +459,13 @@ public function isFormula(): bool /** * Does this cell contain Data validation rules? + * + * @throws SpreadsheetException */ public function hasDataValidation(): bool { if (!isset($this->parent)) { - throw new Exception('Cannot check for data validation when cell is not bound to a worksheet'); + throw new SpreadsheetException('Cannot check for data validation when cell is not bound to a worksheet'); } return $this->getWorksheet()->dataValidationExists($this->getCoordinate()); @@ -465,11 +473,13 @@ public function hasDataValidation(): bool /** * Get Data validation rules. + * + * @throws SpreadsheetException */ public function getDataValidation(): DataValidation { if (!isset($this->parent)) { - throw new Exception('Cannot get data validation for cell that is not bound to a worksheet'); + throw new SpreadsheetException('Cannot get data validation for cell that is not bound to a worksheet'); } return $this->getWorksheet()->getDataValidation($this->getCoordinate()); @@ -477,11 +487,13 @@ public function getDataValidation(): DataValidation /** * Set Data validation rules. + * + * @throws SpreadsheetException */ public function setDataValidation(?DataValidation $dataValidation = null): self { if (!isset($this->parent)) { - throw new Exception('Cannot set data validation for cell that is not bound to a worksheet'); + throw new SpreadsheetException('Cannot set data validation for cell that is not bound to a worksheet'); } $this->getWorksheet()->setDataValidation($this->getCoordinate(), $dataValidation); @@ -501,11 +513,13 @@ public function hasValidValue(): bool /** * Does this cell contain a Hyperlink? + * + * @throws SpreadsheetException */ public function hasHyperlink(): bool { if (!isset($this->parent)) { - throw new Exception('Cannot check for hyperlink when cell is not bound to a worksheet'); + throw new SpreadsheetException('Cannot check for hyperlink when cell is not bound to a worksheet'); } return $this->getWorksheet()->hyperlinkExists($this->getCoordinate()); @@ -513,11 +527,13 @@ public function hasHyperlink(): bool /** * Get Hyperlink. + * + * @throws SpreadsheetException */ public function getHyperlink(): Hyperlink { if (!isset($this->parent)) { - throw new Exception('Cannot get hyperlink for cell that is not bound to a worksheet'); + throw new SpreadsheetException('Cannot get hyperlink for cell that is not bound to a worksheet'); } return $this->getWorksheet()->getHyperlink($this->getCoordinate()); @@ -525,11 +541,13 @@ public function getHyperlink(): Hyperlink /** * Set Hyperlink. + * + * @throws SpreadsheetException */ public function setHyperlink(?Hyperlink $hyperlink = null): self { if (!isset($this->parent)) { - throw new Exception('Cannot set hyperlink for cell that is not bound to a worksheet'); + throw new SpreadsheetException('Cannot set hyperlink for cell that is not bound to a worksheet'); } $this->getWorksheet()->setHyperlink($this->getCoordinate(), $hyperlink); @@ -549,6 +567,8 @@ public function getParent() /** * Get parent worksheet. + * + * @throws SpreadsheetException */ public function getWorksheet(): Worksheet { @@ -560,7 +580,7 @@ public function getWorksheet(): Worksheet } if ($worksheet === null) { - throw new Exception('Worksheet no longer exists'); + throw new SpreadsheetException('Worksheet no longer exists'); } return $worksheet; diff --git a/src/PhpSpreadsheet/Exception.php b/src/PhpSpreadsheet/Exception.php index 9c5ab30ee0..349158078a 100644 --- a/src/PhpSpreadsheet/Exception.php +++ b/src/PhpSpreadsheet/Exception.php @@ -2,6 +2,8 @@ namespace PhpOffice\PhpSpreadsheet; -class Exception extends \Exception +use RuntimeException; + +class Exception extends RuntimeException { } diff --git a/src/PhpSpreadsheet/Writer/Ods/Content.php b/src/PhpSpreadsheet/Writer/Ods/Content.php index f08c90e86d..fb840cbf7f 100644 --- a/src/PhpSpreadsheet/Writer/Ods/Content.php +++ b/src/PhpSpreadsheet/Writer/Ods/Content.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Ods; +use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalculationException; use PhpOffice\PhpSpreadsheet\Cell\Cell; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; @@ -9,7 +10,6 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Worksheet\RowCellIterator; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; -use PhpOffice\PhpSpreadsheet\Writer\Exception; use PhpOffice\PhpSpreadsheet\Writer\Ods; use PhpOffice\PhpSpreadsheet\Writer\Ods\Cell\Comment; use PhpOffice\PhpSpreadsheet\Writer\Ods\Cell\Style; @@ -225,7 +225,7 @@ private function writeCells(XMLWriter $objWriter, RowCellIterator $cells): void if ($this->getParentWriter()->getPreCalculateFormulas()) { try { $formulaValue = $cell->getCalculatedValue(); - } catch (Exception) { + } catch (CalculationException $e) { // don't do anything } } From 656a7164e1a969437d02b7abeb3c3460ff5b07c0 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 9 Nov 2023 13:15:38 -0800 Subject: [PATCH 4/4] Address Some Chart Problems (#3771) * Address Some Chart Problems Fix #3767. The basic problem in that issue was user error, but it exposed a couple of problems in code which are being addressed. When a spreadsheet with charts is loaded without the includeCharts option and then saved, a corrupt spreadsheet is created. I believe this has been the case for quite some time. Nothing in the test suite covers this scenario. It is, in fact, a difficult thing to test, since the problem is exposed only when the file is opened through Excel. The specific problem is that a rels file generated by the output writer continues to refer to the drawing file which described the chart, but that file is not included (and not needed) in the output spreadsheet. The resolution is kludgey. The information that the file will not be needed is not available when the rels file is written. But, when it comes time to write the drawing file, it is known whether the rels file included it. So, if nothing else has caused the file to be generated, it is written out as a basically empty xml file after all. This solves the problem, but I will continue to search for a less kludgey solution. This solution is, at least, testable; if a different solution is applied later on, the test being introduced here is likely to break so a new one will be needed. When the provided spreadsheet is loaded with the includeCharts option and then saved, an error is exposed in processing the Chart Title caption when it doesn't exist. The change to Writer/Xlsx/Chart is simple and clearly justifiable. What is peculiar is that the error does not arise with release 1.29, but does arise with master. It is not at all clear to me what has changed since the release to expose the error - the code in question certainly hasn't changed. It is difficult to isolate changes because of the extensive number of changes following on the elimination of Php7.4 as a supported platform. The provided spreadsheet is unusual in at least two senses. When opened in Excel, it will show a clearly default value for the chart title, namely 'Chart Title'. I cannot find anything in the xml corresponding to that text. Since I have no idea why Excel is using that title, I will not try to duplicate its behavior, so that loading and saving the provided spreadsheet will omit the chart title. I will continue to investigate. The other sense in which it is unusual is that it includes some style files in the same directory as the chart. I doubt that PhpSpreadsheet looks at these. The styling after load and save seems to mostly match the original, although there is at least one color in the graph which does not match. I imagine it would be pretty complicated to formally support these files. * Unused Assignment --- src/PhpSpreadsheet/Writer/Xlsx.php | 3 + src/PhpSpreadsheet/Writer/Xlsx/Chart.php | 4 +- .../Reader/Xlsx/Issue3767Test.php | 88 ++++++++++++++++++ tests/data/Reader/XLSX/issue.3767.xlsx | Bin 0 -> 15637 bytes 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Reader/Xlsx/Issue3767Test.php create mode 100644 tests/data/Reader/XLSX/issue.3767.xlsx diff --git a/src/PhpSpreadsheet/Writer/Xlsx.php b/src/PhpSpreadsheet/Writer/Xlsx.php index 00c6752037..c18bc85862 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx.php +++ b/src/PhpSpreadsheet/Writer/Xlsx.php @@ -410,6 +410,9 @@ public function save($filename, int $flags = 0): void } } } + if (isset($unparsedLoadedData['sheets'][$sheetCodeName]['drawingOriginalIds']) && !isset($zipContent['xl/drawings/drawing' . ($i + 1) . '.xml'])) { + $zipContent['xl/drawings/drawing' . ($i + 1) . '.xml'] = ''; + } // Add comment relationship parts $legacy = $unparsedLoadedData['sheets'][$this->spreadSheet->getSheet($i)->getCodeName()]['legacyDrawing'] ?? null; diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Chart.php b/src/PhpSpreadsheet/Writer/Xlsx/Chart.php index 9bf65ea5ef..f27156507a 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Chart.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Chart.php @@ -169,8 +169,8 @@ private function writeTitle(XMLWriter $objWriter, ?Title $title = null): void $objWriter->endElement(); $caption = $title->getCaption(); - if ((is_array($caption)) && (count($caption) > 0)) { - $caption = $caption[0]; + if (is_array($caption)) { + $caption = $caption[0] ?? ''; } $this->getParentWriter()->getWriterPartstringtable()->writeRichTextForCharts($objWriter, $caption, 'a'); diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/Issue3767Test.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/Issue3767Test.php new file mode 100644 index 0000000000..9f8c3266c9 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/Issue3767Test.php @@ -0,0 +1,88 @@ +tempfile !== '') { + unlink($this->tempfile); + $this->tempfile = ''; + } + } + + public function readCharts(XlsxReader $reader): void + { + $reader->setIncludeCharts(true); + } + + public function writeCharts(XlsxWriter $writer): void + { + $writer->setIncludeCharts(true); + } + + public function testReadWithoutCharts(): void + { + $reader = new XlsxReader(); + //$this->readCharts($reader); // Commented out - don't want to read charts. + $spreadsheet = $reader->load(self::$testbook); + $sheet = $spreadsheet->getActiveSheet(); + $charts = $sheet->getChartCollection(); + self::assertCount(0, $charts); + $this->tempfile = File::temporaryFileName(); + $writer = new XlsxWriter($spreadsheet); + $this->writeCharts($writer); + $writer->save($this->tempfile); + $spreadsheet->disconnectWorksheets(); + $file = 'zip://'; + $file .= $this->tempfile; + $file .= '#xl/worksheets/_rels/sheet1.xml.rels'; + $data = (string) file_get_contents($file); + // PhpSpreadsheet still generates this target even though charts aren't included + self::assertStringContainsString('Target="../drawings/drawing1.xml"', $data); + $file = 'zip://'; + $file .= $this->tempfile; + $file .= '#xl/drawings/drawing1.xml'; + $data = file_get_contents($file); + self::assertSame('', $data); // fake file because rels needs it + } + + public function testReadWithCharts(): void + { + $reader = new XlsxReader(); + $this->readCharts($reader); + $spreadsheet = $reader->load(self::$testbook); + $xsheet = $spreadsheet->getActiveSheet(); + $xcharts = $xsheet->getChartCollection(); + self::assertCount(1, $xcharts); + /** @var callable */ + $callableReader = [$this, 'readCharts']; + /** @var callable */ + $callableWriter = [$this, 'writeCharts']; + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Xlsx', $callableReader, $callableWriter); + $spreadsheet->disconnectWorksheets(); + $sheet = $reloadedSpreadsheet->getActiveSheet(); + $charts = $xsheet->getChartCollection(); + self::assertCount(1, $charts); + // In Excel, a default title ('Chart Title') is shown. + // I can't find that anywhere in the Xml. + self::assertSame('', $charts[0]?->getTitle()?->getCaptionText()); + // Just test anything on the chart. + self::assertSame($sheet->getCell('B2')->getValue(), $charts[0]->getPlotArea()?->getPlotGroup()[0]->getPlotValues()[0]->getDataValues()[0]); + $reloadedSpreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/data/Reader/XLSX/issue.3767.xlsx b/tests/data/Reader/XLSX/issue.3767.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..bde09e6a665b9e85ad53552e84b8685461a2fd88 GIT binary patch literal 15637 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

hlkPeum2L&NYwSO__WBO1A+tdOvczC^y^ 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