From 7b38d8c07f5d2ad7d07a455258ba4fdc40e647ba Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Fri, 16 May 2025 20:46:09 -0700 Subject: [PATCH 01/30] WIP Bug in resizeMatricesExtend Fix #4451. The reporter's analysis is correct - the method has a bug. The solution is relatively easy. Nevertheless, proving that the fix works as desired is tricky. The submitter's Reflection test is good, but it would be better to find one within Excel. My additional tests are contrived, for reasons explained in the test member. So, I will leave this as a work in progress while I search for something better. If that search doesn't succeed, I will nevertheless merge this in about a month. --- .../Calculation/Calculation.php | 16 +-- .../Calculation/LookupRef/Filter.php | 2 +- .../Calculation/Issue4451Test.php | 128 ++++++++++++++++++ 3 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Calculation/Issue4451Test.php diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index cca6ca446..05ebe0c1b 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -845,15 +845,15 @@ class Calculation extends CalculationLocale if (($matrix2Columns < $matrix1Columns) || ($matrix2Rows < $matrix1Rows)) { if ($matrix2Columns < $matrix1Columns) { for ($i = 0; $i < $matrix2Rows; ++$i) { - $x = $matrix2[$i][$matrix2Columns - 1]; + $x = ($matrix2Columns === 1) ? $matrix2[$i][0] : null; for ($j = $matrix2Columns; $j < $matrix1Columns; ++$j) { $matrix2[$i][$j] = $x; } } } if ($matrix2Rows < $matrix1Rows) { - $x = $matrix2[$matrix2Rows - 1]; - for ($i = 0; $i < $matrix1Rows; ++$i) { + $x = ($matrix2Rows === 1) ? $matrix2[0] : array_fill(0, $matrix2Columns, null); + for ($i = $matrix2Rows; $i < $matrix1Rows; ++$i) { $matrix2[$i] = $x; } } @@ -862,15 +862,15 @@ class Calculation extends CalculationLocale if (($matrix1Columns < $matrix2Columns) || ($matrix1Rows < $matrix2Rows)) { if ($matrix1Columns < $matrix2Columns) { for ($i = 0; $i < $matrix1Rows; ++$i) { - $x = $matrix1[$i][$matrix1Columns - 1]; + $x = ($matrix1Columns === 1) ? $matrix1[$i][0] : null; for ($j = $matrix1Columns; $j < $matrix2Columns; ++$j) { $matrix1[$i][$j] = $x; } } } if ($matrix1Rows < $matrix2Rows) { - $x = $matrix1[$matrix1Rows - 1]; - for ($i = 0; $i < $matrix2Rows; ++$i) { + $x = ($matrix1Rows === 1) ? $matrix1[0] : array_fill(0, $matrix1Columns, null); + for ($i = $matrix1Rows; $i < $matrix2Rows; ++$i) { $matrix1[$i] = $x; } } @@ -2334,14 +2334,14 @@ class Calculation extends CalculationLocale for ($row = 0; $row < $rows; ++$row) { for ($column = 0; $column < $columns; ++$column) { - if ($operand1[$row][$column] === null) { + if (($operand1[$row][$column] ?? null) === null) { $operand1[$row][$column] = 0; } elseif (!self::isNumericOrBool($operand1[$row][$column])) { $operand1[$row][$column] = self::makeError($operand1[$row][$column]); continue; } - if ($operand2[$row][$column] === null) { + if (($operand2[$row][$column] ?? null) === null) { $operand2[$row][$column] = 0; } elseif (!self::isNumericOrBool($operand2[$row][$column])) { $operand1[$row][$column] = self::makeError($operand2[$row][$column]); diff --git a/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php b/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php index daedcd0b9..1a2553beb 100644 --- a/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php +++ b/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php @@ -45,7 +45,7 @@ class Filter return array_filter( array_values($lookupArray), - fn ($index): bool => (bool) $matchArray[$index], + fn ($index): bool => (bool) ($matchArray[$index] ?? null), ARRAY_FILTER_USE_KEY ); } diff --git a/tests/PhpSpreadsheetTests/Calculation/Issue4451Test.php b/tests/PhpSpreadsheetTests/Calculation/Issue4451Test.php new file mode 100644 index 000000000..3b9107d18 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Calculation/Issue4451Test.php @@ -0,0 +1,128 @@ +setAccessible(true); + + // Call the method using reflection + $reflectionMethod->invokeArgs($calculation, [&$matrix1, &$matrix2, count($matrix1), 1, count($matrix2), 1]); + + self::assertSame([[1], [3], [null]], $matrix1); //* @phpstan-ignore-line + } + + /** + * These 2 tests are contrived. They prove that method + * works as desired, but Excel will actually return + * a CALC error, a result I don't know how to duplicate. + */ + public static function testExtendFirstColumn(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Products'); + $calculationEngine = Calculation::getInstance($spreadsheet); + $calculationEngine->setInstanceArrayReturnType( + Calculation::RETURN_ARRAY_AS_ARRAY + ); + + $sheet->getCell('D5')->setValue(5); + $sheet->getCell('E5')->setValue(20); + $sheet->fromArray( + [ + [5, 20, 'Apples'], + [10, 20, 'Bananas'], + [5, 20, 'Cherries'], + [5, 40, 'Grapes'], + [25, 50, 'Peaches'], + [30, 60, 'Pears'], + [35, 70, 'Papayas'], + [40, 80, 'Mangos'], + [null, 20, 'Unknown'], + ], + null, + 'K1', + true + ); + $kRows = $sheet->getHighestDataRow('K'); + self::assertSame(8, $kRows); + $lRows = $sheet->getHighestDataRow('L'); + self::assertSame(9, $lRows); + $mRows = $sheet->getHighestDataRow('M'); + self::assertSame(9, $mRows); + $sheet->getCell('A1') + ->setValue( + "=FILTER(Products!M1:M$mRows," + . "(Products!K1:K$kRows=D5)" + . "*(Products!L1:L$lRows=E5))" + ); + + $result = $sheet->getCell('A1')->getCalculatedValue(); + self::assertSame([['Apples'], ['Cherries']], $result); + $spreadsheet->disconnectWorksheets(); + } + + public static function testExtendSecondColumn(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Products'); + $calculationEngine = Calculation::getInstance($spreadsheet); + $calculationEngine->setInstanceArrayReturnType( + Calculation::RETURN_ARRAY_AS_ARRAY + ); + + $sheet->getCell('D5')->setValue(5); + $sheet->getCell('E5')->setValue(20); + $sheet->fromArray( + [ + [5, 20, 'Apples'], + [10, 20, 'Bananas'], + [5, 20, 'Cherries'], + [5, 40, 'Grapes'], + [25, 50, 'Peaches'], + [30, 60, 'Pears'], + [35, 70, 'Papayas'], + [40, 80, 'Mangos'], + [null, 20, 'Unknown'], + ], + null, + 'K1', + true + ); + $kRows = $sheet->getHighestDataRow('K'); + self::assertSame(8, $kRows); + //$lRows = $sheet->getHighestDataRow('L'); + //self::assertSame(9, $lRows); + $lRows = 2; + $mRows = $sheet->getHighestDataRow('M'); + self::assertSame(9, $mRows); + $sheet->getCell('A1') + ->setValue( + "=FILTER(Products!M1:M$mRows," + . "(Products!K1:K$kRows=D5)" + . "*(Products!L1:L$lRows=E5))" + ); + + $result = $sheet->getCell('A1')->getCalculatedValue(); + self::assertSame([['Apples']], $result); + $spreadsheet->disconnectWorksheets(); + } +} From ea6ed77fdd90a1ed267b30c4f72a18495eb39a48 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Mon, 2 Jun 2025 11:26:30 +0700 Subject: [PATCH 02/30] refactor: cleanup code twice calling --- src/PhpSpreadsheet/Spreadsheet.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/PhpSpreadsheet/Spreadsheet.php b/src/PhpSpreadsheet/Spreadsheet.php index 09022bbc6..0a2fc54a6 100644 --- a/src/PhpSpreadsheet/Spreadsheet.php +++ b/src/PhpSpreadsheet/Spreadsheet.php @@ -660,9 +660,11 @@ class Spreadsheet implements JsonSerializable */ public function getSheetByName(string $worksheetName): ?Worksheet { - $worksheetCount = count($this->workSheetCollection); + $worksheetCount = count($this->workSheetCollection); + $trimWorksheetName = trim($worksheetName, "'"); + for ($i = 0; $i < $worksheetCount; ++$i) { - if (strcasecmp($this->workSheetCollection[$i]->getTitle(), trim($worksheetName, "'")) === 0) { + if (strcasecmp($this->workSheetCollection[$i]->getTitle(), $trimWorksheetName) === 0) { return $this->workSheetCollection[$i]; } } From ca12ffd6ef6aae5ac72e92be28105301370809c5 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Mon, 2 Jun 2025 11:34:42 +0700 Subject: [PATCH 03/30] refactor: using function count instead --- src/PhpSpreadsheet/Spreadsheet.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/PhpSpreadsheet/Spreadsheet.php b/src/PhpSpreadsheet/Spreadsheet.php index 0a2fc54a6..fc85757a3 100644 --- a/src/PhpSpreadsheet/Spreadsheet.php +++ b/src/PhpSpreadsheet/Spreadsheet.php @@ -608,7 +608,7 @@ class Spreadsheet implements JsonSerializable */ public function removeSheetByIndex(int $sheetIndex): void { - $numSheets = count($this->workSheetCollection); + $numSheets = $this->getSheetCount(); if ($sheetIndex > $numSheets - 1) { throw new Exception( "You tried to remove a sheet by the out of bounds index: {$sheetIndex}. The actual number of sheets is {$numSheets}." @@ -660,10 +660,9 @@ class Spreadsheet implements JsonSerializable */ public function getSheetByName(string $worksheetName): ?Worksheet { - $worksheetCount = count($this->workSheetCollection); $trimWorksheetName = trim($worksheetName, "'"); - for ($i = 0; $i < $worksheetCount; ++$i) { + for ($i = 0; $i < $this->getSheetCount(); ++$i) { if (strcasecmp($this->workSheetCollection[$i]->getTitle(), $trimWorksheetName) === 0) { return $this->workSheetCollection[$i]; } @@ -792,8 +791,8 @@ class Spreadsheet implements JsonSerializable public function getSheetNames(): array { $returnValue = []; - $worksheetCount = $this->getSheetCount(); - for ($i = 0; $i < $worksheetCount; ++$i) { + + for ($i = 0; $i < $this->getSheetCount(); ++$i) { $returnValue[] = $this->getSheet($i)->getTitle(); } From 55294cf34c873f4307f48d1016c5bfbcc6624402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=A4drich?= <11225821+shaedrich@users.noreply.github.com> Date: Tue, 24 Jun 2025 09:32:40 +0200 Subject: [PATCH 04/30] Add blank line before list after paragaph So that it is displayed correctly as list in ReadTheDocs --- docs/topics/defined-names.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/topics/defined-names.md b/docs/topics/defined-names.md index d62b06fee..af47e6564 100644 --- a/docs/topics/defined-names.md +++ b/docs/topics/defined-names.md @@ -545,6 +545,7 @@ $this->spreadsheet->addDefinedName( ### Naming Names The names that you assign to Defined Name must follow the following set of rules: + - The first character of a name must be one of the following characters: - letter (including UTF-8 letters) - underscore (`_`) From a266911203181a77abb869e20e7a1c699deeb484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=A4drich?= <11225821+shaedrich@users.noreply.github.com> Date: Tue, 24 Jun 2025 09:58:48 +0200 Subject: [PATCH 05/30] Indent with four spaces instead of two to adhere to the standard's best practice and (hopefully) make the nesting carry over to being displayed in the rendered output --- docs/topics/defined-names.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/topics/defined-names.md b/docs/topics/defined-names.md index af47e6564..87ee09eea 100644 --- a/docs/topics/defined-names.md +++ b/docs/topics/defined-names.md @@ -547,16 +547,16 @@ $this->spreadsheet->addDefinedName( The names that you assign to Defined Name must follow the following set of rules: - The first character of a name must be one of the following characters: - - letter (including UTF-8 letters) - - underscore (`_`) + - letter (including UTF-8 letters) + - underscore (`_`) - Remaining characters in the name can be - - letters (including UTF-8 letters) - - numbers (including UTF-8 numbers) - - periods (`.`) - - underscore characters (`_`) + - letters (including UTF-8 letters) + - numbers (including UTF-8 numbers) + - periods (`.`) + - underscore characters (`_`) - The following are not allowed: - - Space characters are not allowed as part of a name. - - Names can't look like cell addresses, such as A35 or R2C2 + - Space characters are not allowed as part of a name. + - Names can't look like cell addresses, such as A35 or R2C2 - Names are not case sensitive. For example, `North` and `NORTH` are treated as the same name. ### Limitations From 52f846a637b87fd0f3d6af50de887acef8df0b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=A4drich?= <11225821+shaedrich@users.noreply.github.com> Date: Tue, 24 Jun 2025 10:08:56 +0200 Subject: [PATCH 06/30] Indent with four spaces instead of two to adhere to the standard's best practice and (verifiedly) make the nesting carry over to being displayed in the rendered output --- docs/topics/The Dating Game.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/topics/The Dating Game.md b/docs/topics/The Dating Game.md index 5b0c2812f..a212b974f 100644 --- a/docs/topics/The Dating Game.md +++ b/docs/topics/The Dating Game.md @@ -14,20 +14,20 @@ Open/Libre Office and Gnumeric don't have this limitation, and negative date/tim To write a date in a cell using PhpSpreadsheet, we need to calculate the serialized Excel datestamp for that date. Methods to do this are available in the Shared\Date class, which provides a number of methods for conversion between different date options typically used in PHP applications (Unix timestamp, PHP DateTime objects and some recognisable formatted strings) and the Excel serialized value; and vice versa. - Shared\Date::convertIsoDate() - - Converts a date/time in [ISO-8601 standard format](https://en.wikipedia.org/wiki/ISO_8601) to an Excel serialized timestamp + - Converts a date/time in [ISO-8601 standard format](https://en.wikipedia.org/wiki/ISO_8601) to an Excel serialized timestamp - Shared\Date::PHPToExcel() - - Converts a Unix timestamp, a PHP DateTime object, or a recognisable formatted string to an Excel serialized timestamp + - Converts a Unix timestamp, a PHP DateTime object, or a recognisable formatted string to an Excel serialized timestamp - Shared\Date::dateTimeToExcel() - - Converts a Unix timestamp to an Excel serialized timestamp + - Converts a Unix timestamp to an Excel serialized timestamp - Shared\Date::timestampToExcel() - - Converts a PHP DateTime object to an Excel serialized timestamp + - Converts a PHP DateTime object to an Excel serialized timestamp - Shared\Date::formattedPHPToExcel() - - Converts year, month, day, hour, minute, and second to an Excel serialized timestamp + - Converts year, month, day, hour, minute, and second to an Excel serialized timestamp - Shared\Date::excelToDateTimeObject() - - Converts an Excel serialized timestamp to a PHP DateTime object + - Converts an Excel serialized timestamp to a PHP DateTime object - Shared\Date::excelToTimestamp() - - Converts an Excel serialized timestamp to a Unix timestamp. - - The use of Unix timestamps, and therefore this function, is discouraged: they are not Y2038-safe on a 32-bit system, and have no timezone info. + - Converts an Excel serialized timestamp to a Unix timestamp. + - The use of Unix timestamps, and therefore this function, is discouraged: they are not Y2038-safe on a 32-bit system, and have no timezone info. We probably also want to set the number format mask for the cell so that it will be displayed as a human-readable date. ```php From bfd7dc8cee823fd853b0443022ac6cc4c9209d5f Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Mon, 30 Jun 2025 21:06:17 -0700 Subject: [PATCH 07/30] Recognize "New" Mimetype for Empty File Fix #4521. Reader/Csv (which can be invoked by IOFactory::read), checks the mimetype of files if they don't have a csv/tsv extension. It allows empty files, whose mimetype was set to `inode/x-empty` for Php5.3.11 through Php7.3.33 (except for 5.4.0). For 5.3.1-5.3.10, 5.4.0, and 7.4.0+, the mimetype is `application/x-empty`. Reader/Csv recognizes the `inode` version but not the `application` version, which this PR adds. The person who issued the report also noted that, for a file consisting of cr-lf, Php8.1.* and Php8.2.* report the mimetype as `application/octet-stream`, whereas all other releases report it as `text/plain`. The behavior of Php8.1/2 seems to be a bug, and I cannot possibly guess all the conditions that might lead to that bug. So I will not fix that problem. Anyone who is adversely affected by it can either add a `csv` extension to the filename, or upgrade to Php8.3+, or pre-process the file (e.g. to make it truly empty) before passing it to PhpSpreadsheet. A test case to document the problem is added for documentary purposes; the test will be skipped for Php8.1/2, but run for all other releases. --- src/PhpSpreadsheet/Reader/Csv.php | 1 + tests/PhpSpreadsheetTests/Issue4521Test.php | 56 +++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 tests/PhpSpreadsheetTests/Issue4521Test.php diff --git a/src/PhpSpreadsheet/Reader/Csv.php b/src/PhpSpreadsheet/Reader/Csv.php index 783c4f00c..21d6a3cff 100644 --- a/src/PhpSpreadsheet/Reader/Csv.php +++ b/src/PhpSpreadsheet/Reader/Csv.php @@ -605,6 +605,7 @@ class Csv extends BaseReader 'text/csv', 'text/plain', 'inode/x-empty', + 'application/x-empty', // has now replaced previous 'text/html', ]; diff --git a/tests/PhpSpreadsheetTests/Issue4521Test.php b/tests/PhpSpreadsheetTests/Issue4521Test.php new file mode 100644 index 000000000..9c7ff3949 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Issue4521Test.php @@ -0,0 +1,56 @@ +outfile !== '') { + unlink($this->outfile); + $this->outfile = ''; + } + } + + public function testEmptyFile(): void + { + $this->outfile = File::temporaryFilename(); + file_put_contents($this->outfile, ''); + $spreadsheet = IOFactory::load($this->outfile); + $sheet = $spreadsheet->getActiveSheet(); + self::assertSame('A', $sheet->getHighestColumn()); + self::assertSame(1, $sheet->getHighestRow()); + $spreadsheet->disconnectWorksheets(); + } + + public function testCrlfFile(): void + { + if (PHP_MAJOR_VERSION === $this->weirdMimetypeMajor) { + if ( + PHP_MINOR_VERSION === $this->weirdMimetypeMinor1 + || PHP_MINOR_VERSION === $this->weirdMimetypeMinor2 + ) { + self::markTestSkipped('Php mimetype bug with this release'); + } + } + $this->outfile = File::temporaryFilename(); + file_put_contents($this->outfile, "\r\n"); + $spreadsheet = IOFactory::load($this->outfile); + $sheet = $spreadsheet->getActiveSheet(); + self::assertSame('A', $sheet->getHighestColumn()); + self::assertSame(1, $sheet->getHighestRow()); + $spreadsheet->disconnectWorksheets(); + } +} From 1baa138f0f99a8c069ebba4022da80523dd9cb6b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 12:35:34 +0000 Subject: [PATCH 08/30] Bump friendsofphp/php-cs-fixer from 3.75.0 to 3.76.0 Bumps [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) from 3.75.0 to 3.76.0. - [Release notes](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases) - [Changelog](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/CHANGELOG.md) - [Commits](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/compare/v3.75.0...v3.76.0) --- updated-dependencies: - dependency-name: friendsofphp/php-cs-fixer dependency-version: 3.76.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- composer.lock | 162 +++++++++++++++++++++++++------------------------- 1 file changed, 82 insertions(+), 80 deletions(-) diff --git a/composer.lock b/composer.lock index 6044de2da..a387fe4fb 100644 --- a/composer.lock +++ b/composer.lock @@ -1051,58 +1051,59 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.75.0", + "version": "v3.76.0", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "399a128ff2fdaf4281e4e79b755693286cdf325c" + "reference": "0e3c484cef0ae9314b0f85986a36296087432c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/399a128ff2fdaf4281e4e79b755693286cdf325c", - "reference": "399a128ff2fdaf4281e4e79b755693286cdf325c", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/0e3c484cef0ae9314b0f85986a36296087432c40", + "reference": "0e3c484cef0ae9314b0f85986a36296087432c40", "shasum": "" }, "require": { "clue/ndjson-react": "^1.0", "composer/semver": "^3.4", - "composer/xdebug-handler": "^3.0.3", + "composer/xdebug-handler": "^3.0.5", "ext-filter": "*", "ext-hash": "*", "ext-json": "*", "ext-tokenizer": "*", "fidry/cpu-core-counter": "^1.2", "php": "^7.4 || ^8.0", - "react/child-process": "^0.6.5", + "react/child-process": "^0.6.6", "react/event-loop": "^1.0", - "react/promise": "^2.0 || ^3.0", + "react/promise": "^2.11 || ^3.0", "react/socket": "^1.0", "react/stream": "^1.0", - "sebastian/diff": "^4.0 || ^5.1 || ^6.0 || ^7.0", - "symfony/console": "^5.4 || ^6.4 || ^7.0", - "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0", - "symfony/filesystem": "^5.4 || ^6.4 || ^7.0", - "symfony/finder": "^5.4 || ^6.4 || ^7.0", - "symfony/options-resolver": "^5.4 || ^6.4 || ^7.0", - "symfony/polyfill-mbstring": "^1.31", - "symfony/polyfill-php80": "^1.31", - "symfony/polyfill-php81": "^1.31", - "symfony/process": "^5.4 || ^6.4 || ^7.2", - "symfony/stopwatch": "^5.4 || ^6.4 || ^7.0" + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0", + "symfony/console": "^5.4.45 || ^6.4.13 || ^7.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.13 || ^7.0", + "symfony/filesystem": "^5.4.45 || ^6.4.13 || ^7.0", + "symfony/finder": "^5.4.45 || ^6.4.17 || ^7.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.16 || ^7.0", + "symfony/polyfill-mbstring": "^1.32", + "symfony/polyfill-php80": "^1.32", + "symfony/polyfill-php81": "^1.32", + "symfony/process": "^5.4.47 || ^6.4.20 || ^7.2", + "symfony/stopwatch": "^5.4.45 || ^6.4.19 || ^7.0" }, "require-dev": { "facile-it/paraunit": "^1.3.1 || ^2.6", "infection/infection": "^0.29.14", - "justinrainbow/json-schema": "^5.3 || ^6.2", - "keradus/cli-executor": "^2.1", + "justinrainbow/json-schema": "^5.3 || ^6.4", + "keradus/cli-executor": "^2.2", "mikey179/vfsstream": "^1.6.12", - "php-coveralls/php-coveralls": "^2.7", + "php-coveralls/php-coveralls": "^2.8", "php-cs-fixer/accessible-object": "^1.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6", - "phpunit/phpunit": "^9.6.22 || ^10.5.45 || ^11.5.12", - "symfony/var-dumper": "^5.4.48 || ^6.4.18 || ^7.2.3", - "symfony/yaml": "^5.4.45 || ^6.4.18 || ^7.2.3" + "phpunit/phpunit": "^9.6.23 || ^10.5.47 || ^11.5.25", + "symfony/polyfill-php84": "^1.32", + "symfony/var-dumper": "^5.4.48 || ^6.4.23 || ^7.3.1", + "symfony/yaml": "^5.4.45 || ^6.4.23 || ^7.3.1" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -1143,7 +1144,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.75.0" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.76.0" }, "funding": [ { @@ -1151,7 +1152,7 @@ "type": "github" } ], - "time": "2025-03-31T18:40:42+00:00" + "time": "2025-06-30T14:15:06+00:00" }, { "name": "masterminds/html5", @@ -4192,16 +4193,16 @@ }, { "name": "symfony/console", - "version": "v6.4.20", + "version": "v6.4.23", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "2e4af9c952617cc3f9559ff706aee420a8464c36" + "reference": "9056771b8eca08d026cd3280deeec3cfd99c4d93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/2e4af9c952617cc3f9559ff706aee420a8464c36", - "reference": "2e4af9c952617cc3f9559ff706aee420a8464c36", + "url": "https://api.github.com/repos/symfony/console/zipball/9056771b8eca08d026cd3280deeec3cfd99c4d93", + "reference": "9056771b8eca08d026cd3280deeec3cfd99c4d93", "shasum": "" }, "require": { @@ -4266,7 +4267,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.20" + "source": "https://github.com/symfony/console/tree/v6.4.23" }, "funding": [ { @@ -4282,20 +4283,20 @@ "type": "tidelift" } ], - "time": "2025-03-03T17:16:38+00:00" + "time": "2025-06-27T19:37:22+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", "shasum": "" }, "require": { @@ -4308,7 +4309,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -4333,7 +4334,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" }, "funding": [ { @@ -4349,7 +4350,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/event-dispatcher", @@ -4433,16 +4434,16 @@ }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f" + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", "shasum": "" }, "require": { @@ -4456,7 +4457,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -4489,7 +4490,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" }, "funding": [ { @@ -4505,7 +4506,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/filesystem", @@ -4706,7 +4707,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -4765,7 +4766,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" }, "funding": [ { @@ -4785,7 +4786,7 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", @@ -4843,7 +4844,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" }, "funding": [ { @@ -4863,7 +4864,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -4924,7 +4925,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" }, "funding": [ { @@ -4944,19 +4945,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -5004,7 +5006,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" }, "funding": [ { @@ -5020,20 +5022,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", "shasum": "" }, "require": { @@ -5084,7 +5086,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" }, "funding": [ { @@ -5100,11 +5102,11 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-01-02T08:10:11+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -5160,7 +5162,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.32.0" }, "funding": [ { @@ -5241,16 +5243,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", "shasum": "" }, "require": { @@ -5268,7 +5270,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -5304,7 +5306,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" }, "funding": [ { @@ -5320,7 +5322,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2025-04-25T09:37:31+00:00" }, { "name": "symfony/stopwatch", @@ -5386,16 +5388,16 @@ }, { "name": "symfony/string", - "version": "v6.4.15", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f" + "reference": "73e2c6966a5aef1d4892873ed5322245295370c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", + "url": "https://api.github.com/repos/symfony/string/zipball/73e2c6966a5aef1d4892873ed5322245295370c6", + "reference": "73e2c6966a5aef1d4892873ed5322245295370c6", "shasum": "" }, "require": { @@ -5452,7 +5454,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.15" + "source": "https://github.com/symfony/string/tree/v6.4.21" }, "funding": [ { @@ -5468,7 +5470,7 @@ "type": "tidelift" } ], - "time": "2024-11-13T13:31:12+00:00" + "time": "2025-04-18T15:23:29+00:00" }, { "name": "tecnickcom/tcpdf", @@ -5619,5 +5621,5 @@ "platform-overrides": { "php": "8.1.99" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } From 1c17c1da4afc4e79054f9e77dc674687af76c211 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 12:35:37 +0000 Subject: [PATCH 09/30] Bump squizlabs/php_codesniffer from 3.13.0 to 3.13.2 Bumps [squizlabs/php_codesniffer](https://github.com/PHPCSStandards/PHP_CodeSniffer) from 3.13.0 to 3.13.2. - [Release notes](https://github.com/PHPCSStandards/PHP_CodeSniffer/releases) - [Changelog](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/CHANGELOG.md) - [Commits](https://github.com/PHPCSStandards/PHP_CodeSniffer/compare/3.13.0...3.13.2) --- updated-dependencies: - dependency-name: squizlabs/php_codesniffer dependency-version: 3.13.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 6044de2da..701bd20a1 100644 --- a/composer.lock +++ b/composer.lock @@ -4108,16 +4108,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.13.0", + "version": "3.13.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "65ff2489553b83b4597e89c3b8b721487011d186" + "reference": "5b5e3821314f947dd040c70f7992a64eac89025c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/65ff2489553b83b4597e89c3b8b721487011d186", - "reference": "65ff2489553b83b4597e89c3b8b721487011d186", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/5b5e3821314f947dd040c70f7992a64eac89025c", + "reference": "5b5e3821314f947dd040c70f7992a64eac89025c", "shasum": "" }, "require": { @@ -4188,7 +4188,7 @@ "type": "thanks_dev" } ], - "time": "2025-05-11T03:36:00+00:00" + "time": "2025-06-17T22:17:01+00:00" }, { "name": "symfony/console", @@ -5619,5 +5619,5 @@ "platform-overrides": { "php": "8.1.99" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } From 852182039f50a2d77816ee0ef54bf4638ba68611 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Fri, 4 Jul 2025 07:39:08 -0700 Subject: [PATCH 10/30] Update Spreadsheet.php --- src/PhpSpreadsheet/Spreadsheet.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/PhpSpreadsheet/Spreadsheet.php b/src/PhpSpreadsheet/Spreadsheet.php index fc85757a3..deb6be58d 100644 --- a/src/PhpSpreadsheet/Spreadsheet.php +++ b/src/PhpSpreadsheet/Spreadsheet.php @@ -608,7 +608,7 @@ class Spreadsheet implements JsonSerializable */ public function removeSheetByIndex(int $sheetIndex): void { - $numSheets = $this->getSheetCount(); + $numSheets = count($this->workSheetCollection); if ($sheetIndex > $numSheets - 1) { throw new Exception( "You tried to remove a sheet by the out of bounds index: {$sheetIndex}. The actual number of sheets is {$numSheets}." @@ -660,9 +660,10 @@ class Spreadsheet implements JsonSerializable */ public function getSheetByName(string $worksheetName): ?Worksheet { + $worksheetCount = count($this->workSheetCollection); $trimWorksheetName = trim($worksheetName, "'"); - for ($i = 0; $i < $this->getSheetCount(); ++$i) { + for ($i = 0; $i < $wroksheetCount; ++$i) { if (strcasecmp($this->workSheetCollection[$i]->getTitle(), $trimWorksheetName) === 0) { return $this->workSheetCollection[$i]; } @@ -791,8 +792,8 @@ class Spreadsheet implements JsonSerializable public function getSheetNames(): array { $returnValue = []; - - for ($i = 0; $i < $this->getSheetCount(); ++$i) { + $worksheetCount = $this->getSheetCount(); + for ($i = 0; $i < $worksheetCount; ++$i) { $returnValue[] = $this->getSheet($i)->getTitle(); } From f52d70984f8767198b353db5cedcb02b80360ac2 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Fri, 4 Jul 2025 07:41:56 -0700 Subject: [PATCH 11/30] Update Spreadsheet.php --- src/PhpSpreadsheet/Spreadsheet.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PhpSpreadsheet/Spreadsheet.php b/src/PhpSpreadsheet/Spreadsheet.php index deb6be58d..bdab99655 100644 --- a/src/PhpSpreadsheet/Spreadsheet.php +++ b/src/PhpSpreadsheet/Spreadsheet.php @@ -663,7 +663,7 @@ class Spreadsheet implements JsonSerializable $worksheetCount = count($this->workSheetCollection); $trimWorksheetName = trim($worksheetName, "'"); - for ($i = 0; $i < $wroksheetCount; ++$i) { + for ($i = 0; $i < $worksheetCount; ++$i) { if (strcasecmp($this->workSheetCollection[$i]->getTitle(), $trimWorksheetName) === 0) { return $this->workSheetCollection[$i]; } From ca284fac354aace25753929ce9954c59665db3bf Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Fri, 4 Jul 2025 09:15:02 -0700 Subject: [PATCH 12/30] Update Spreadsheet.php --- src/PhpSpreadsheet/Spreadsheet.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/PhpSpreadsheet/Spreadsheet.php b/src/PhpSpreadsheet/Spreadsheet.php index bdab99655..549e4cdda 100644 --- a/src/PhpSpreadsheet/Spreadsheet.php +++ b/src/PhpSpreadsheet/Spreadsheet.php @@ -660,12 +660,10 @@ class Spreadsheet implements JsonSerializable */ public function getSheetByName(string $worksheetName): ?Worksheet { - $worksheetCount = count($this->workSheetCollection); $trimWorksheetName = trim($worksheetName, "'"); - - for ($i = 0; $i < $worksheetCount; ++$i) { - if (strcasecmp($this->workSheetCollection[$i]->getTitle(), $trimWorksheetName) === 0) { - return $this->workSheetCollection[$i]; + foreach ($this->workSheetCollection as $worksheet) { + if (strcasecmp($worksheet->getTitle(), $trimWorksheetName) === 0) { + return $worksheet; } } From 7efb118cbd73edb8a1d4b5608dc3532266741a90 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Fri, 4 Jul 2025 10:41:20 -0700 Subject: [PATCH 13/30] Phpstan Catch-up --- CHANGELOG.md | 3 ++- src/PhpSpreadsheet/Calculation/Calculation.php | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8534ef6..41e375f64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Fixed -- Nothing yet. +- Micro-optimization in getSheetByName. [PR #4499](https://github.com/PHPOffice/PhpSpreadsheet/pull/4499) +- Bug in resizeMatricesExtend. [Issue #4451](https://github.com/PHPOffice/PhpSpreadsheet/issues/4451) [PR #4474](https://github.com/PHPOffice/PhpSpreadsheet/pull/4474) ## 2025-06-22 - 4.4.0 diff --git a/src/PhpSpreadsheet/Calculation/Calculation.php b/src/PhpSpreadsheet/Calculation/Calculation.php index 32f323817..72105814a 100644 --- a/src/PhpSpreadsheet/Calculation/Calculation.php +++ b/src/PhpSpreadsheet/Calculation/Calculation.php @@ -851,6 +851,7 @@ class Calculation extends CalculationLocale if (($matrix2Columns < $matrix1Columns) || ($matrix2Rows < $matrix1Rows)) { if ($matrix2Columns < $matrix1Columns) { for ($i = 0; $i < $matrix2Rows; ++$i) { + /** @var mixed[][] $matrix2 */ $x = ($matrix2Columns === 1) ? $matrix2[$i][0] : null; for ($j = $matrix2Columns; $j < $matrix1Columns; ++$j) { $matrix2[$i][$j] = $x; @@ -868,6 +869,7 @@ class Calculation extends CalculationLocale if (($matrix1Columns < $matrix2Columns) || ($matrix1Rows < $matrix2Rows)) { if ($matrix1Columns < $matrix2Columns) { for ($i = 0; $i < $matrix1Rows; ++$i) { + /** @var mixed[][] $matrix1 */ $x = ($matrix1Columns === 1) ? $matrix1[$i][0] : null; for ($j = $matrix1Columns; $j < $matrix2Columns; ++$j) { $matrix1[$i][$j] = $x; @@ -2372,6 +2374,7 @@ class Calculation extends CalculationLocale for ($row = 0; $row < $rows; ++$row) { for ($column = 0; $column < $columns; ++$column) { + /** @var mixed[][] $operand1 */ if (($operand1[$row][$column] ?? null) === null) { $operand1[$row][$column] = 0; } elseif (!self::isNumericOrBool($operand1[$row][$column])) { @@ -2379,6 +2382,7 @@ class Calculation extends CalculationLocale continue; } + /** @var mixed[][] $operand2 */ if (($operand2[$row][$column] ?? null) === null) { $operand2[$row][$column] = 0; } elseif (!self::isNumericOrBool($operand2[$row][$column])) { From 3cefc5069001c79cd22d646e9a3dc71b5da5445f Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Fri, 4 Jul 2025 12:53:28 -0700 Subject: [PATCH 14/30] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8534ef6..ca9198aae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Fixed -- Nothing yet. +- Recognize application/x-empty mimetype. [Issue #4521](https://github.com/PHPOffice/PhpSpreadsheet/issues/4521) [PR #4524](https://github.com/PHPOffice/PhpSpreadsheet/pull/4524) ## 2025-06-22 - 4.4.0 From 9708d72ee125baecae14afbc195fb099edd3459f Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Tue, 8 Jul 2025 12:52:55 -0700 Subject: [PATCH 15/30] Ods Reader Process Nested table-row Fix #4528. Fix #2507. Ods Reader expects `table:table-row` to descend directly from `table:table`. But it turns out that it can be nested under `table:table-header-rows`, `table:table-rows`, or `table:table-row-group`. Similar considerations apply to `table:table-column`. Ods Reader is changed to process the nesting tags, and therefore the nested tags underneath them. This is a major change, but it doesn't seem to have broken any existing tests or samples. No attempt is made to add any of the nesting tags in Ods Writer. It should be noted that 4528 has an additional problem, which has been reported as issue #4530. It is not fixed by this PR. --- src/PhpSpreadsheet/Reader/Ods.php | 856 ++++++++++++------ .../Reader/Ods/NestedTableRowTest.php | 42 + tests/data/Reader/Ods/issue.2507.ods | Bin 0 -> 20923 bytes tests/data/Reader/Ods/issue.4528.ods | Bin 0 -> 45138 bytes 4 files changed, 598 insertions(+), 300 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Reader/Ods/NestedTableRowTest.php create mode 100644 tests/data/Reader/Ods/issue.2507.ods create mode 100644 tests/data/Reader/Ods/issue.4528.ods diff --git a/src/PhpSpreadsheet/Reader/Ods.php b/src/PhpSpreadsheet/Reader/Ods.php index 420ae2f7b..97f6a45b2 100644 --- a/src/PhpSpreadsheet/Reader/Ods.php +++ b/src/PhpSpreadsheet/Reader/Ods.php @@ -354,317 +354,90 @@ class Ods extends BaseReader continue; } - $key = $childNode->nodeName; - - // Remove ns from node name - if (str_contains($key, ':')) { - $keyChunks = explode(':', $key); - $key = array_pop($keyChunks); - } + $key = self::extractNodeName($childNode->nodeName); switch ($key) { case 'table-header-rows': - /// TODO :: Figure this out. This is only a partial implementation I guess. - // ($rowData it's not used at all and I'm not sure that PHPExcel - // has an API for this) + case 'table-rows': + $this->processTableHeaderRows( + $childNode, + $tableNs, + $rowID, + $worksheetName, + $officeNs, + $textNs, + $xlinkNs, + $spreadsheet + ); + + break; + case 'table-row-group': + $this->processTableRowGroup( + $childNode, + $tableNs, + $rowID, + $worksheetName, + $officeNs, + $textNs, + $xlinkNs, + $spreadsheet + ); + + break; + case 'table-header-columns': + case 'table-columns': + $this->processTableHeaderColumns( + $childNode, + $tableNs, + $columnWidths, + $tableColumnIndex, + $spreadsheet + ); + + break; + case 'table-column-group': + $this->processTableColumnGroup( + $childNode, + $tableNs, + $columnWidths, + $tableColumnIndex, + $spreadsheet + ); -// foreach ($rowData as $keyRowData => $cellData) { -// $rowData = $cellData; -// break; -// } break; case 'table-column': - if ($childNode->hasAttributeNS($tableNs, 'number-columns-repeated')) { - $rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-columns-repeated'); - } else { - $rowRepeats = 1; - } - $tableStyleName = $childNode->getAttributeNS($tableNs, 'style-name'); - if (isset($columnWidths[$tableStyleName])) { - $columnWidth = new HelperDimension($columnWidths[$tableStyleName]); - $tableColumnString = Coordinate::stringFromColumnIndex($tableColumnIndex); - for ($rowRepeats2 = $rowRepeats; $rowRepeats2 > 0; --$rowRepeats2) { - /** @var string $tableColumnString */ - $spreadsheet->getActiveSheet() - ->getColumnDimension($tableColumnString) - ->setWidth($columnWidth->toUnit('cm'), 'cm'); - ++$tableColumnString; - } - } - $tableColumnIndex += $rowRepeats; + $this->processTableColumn( + $childNode, + $tableNs, + $columnWidths, + $tableColumnIndex, + $spreadsheet + ); break; case 'table-row': - if ($childNode->hasAttributeNS($tableNs, 'number-rows-repeated')) { - $rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-rows-repeated'); - } else { - $rowRepeats = 1; - } - - $columnID = 'A'; - /** @var DOMElement|DOMText $cellData */ - foreach ($childNode->childNodes as $cellData) { - if ($cellData instanceof DOMText) { - continue; // should just be whitespace - } - if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) { - if ($cellData->hasAttributeNS($tableNs, 'number-columns-repeated')) { - $colRepeats = (int) $cellData->getAttributeNS($tableNs, 'number-columns-repeated'); - } else { - $colRepeats = 1; - } - - for ($i = 0; $i < $colRepeats; ++$i) { - ++$columnID; - } - - continue; - } - - // Initialize variables - $formatting = $hyperlink = null; - $hasCalculatedValue = false; - $cellDataFormula = ''; - $cellDataType = ''; - $cellDataRef = ''; - - if ($cellData->hasAttributeNS($tableNs, 'formula')) { - $cellDataFormula = $cellData->getAttributeNS($tableNs, 'formula'); - $hasCalculatedValue = true; - } - if ($cellData->hasAttributeNS($tableNs, 'number-matrix-columns-spanned')) { - if ($cellData->hasAttributeNS($tableNs, 'number-matrix-rows-spanned')) { - $cellDataType = 'array'; - $arrayRow = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-rows-spanned'); - $arrayCol = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-columns-spanned'); - $lastRow = $rowID + $arrayRow - 1; - $lastCol = $columnID; - while ($arrayCol > 1) { - ++$lastCol; - --$arrayCol; - } - $cellDataRef = "$columnID$rowID:$lastCol$lastRow"; - } - } - - // Annotations - $annotation = $cellData->getElementsByTagNameNS($officeNs, 'annotation'); - - if ($annotation->length > 0 && $annotation->item(0) !== null) { - $textNode = $annotation->item(0)->getElementsByTagNameNS($textNs, 'p'); - $textNodeLength = $textNode->length; - $newLineOwed = false; - for ($textNodeIndex = 0; $textNodeIndex < $textNodeLength; ++$textNodeIndex) { - $textNodeItem = $textNode->item($textNodeIndex); - if ($textNodeItem !== null) { - $text = $this->scanElementForText($textNodeItem); - if ($newLineOwed) { - $spreadsheet->getActiveSheet() - ->getComment($columnID . $rowID) - ->getText() - ->createText("\n"); - } - $newLineOwed = true; - - $spreadsheet->getActiveSheet() - ->getComment($columnID . $rowID) - ->getText() - ->createText($this->parseRichText($text)); - } - } - } - - // Content - - /** @var DOMElement[] $paragraphs */ - $paragraphs = []; - - foreach ($cellData->childNodes as $item) { - /** @var DOMElement $item */ - - // Filter text:p elements - if ($item->nodeName == 'text:p') { - $paragraphs[] = $item; - } - } - - if (count($paragraphs) > 0) { - // Consolidate if there are multiple p records (maybe with spans as well) - $dataArray = []; - - // Text can have multiple text:p and within those, multiple text:span. - // text:p newlines, but text:span does not. - // Also, here we assume there is no text data is span fields are specified, since - // we have no way of knowing proper positioning anyway. - - foreach ($paragraphs as $pData) { - $dataArray[] = $this->scanElementForText($pData); - } - $allCellDataText = implode("\n", $dataArray); - - $type = $cellData->getAttributeNS($officeNs, 'value-type'); - - switch ($type) { - case 'string': - $type = DataType::TYPE_STRING; - $dataValue = $allCellDataText; - - foreach ($paragraphs as $paragraph) { - $link = $paragraph->getElementsByTagNameNS($textNs, 'a'); - if ($link->length > 0 && $link->item(0) !== null) { - $hyperlink = $link->item(0)->getAttributeNS($xlinkNs, 'href'); - } - } - - break; - case 'boolean': - $type = DataType::TYPE_BOOL; - $dataValue = ($cellData->getAttributeNS($officeNs, 'boolean-value') === 'true') ? true : false; - - break; - case 'percentage': - $type = DataType::TYPE_NUMERIC; - $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value'); - - // percentage should always be float - //if (floor($dataValue) == $dataValue) { - // $dataValue = (int) $dataValue; - //} - $formatting = NumberFormat::FORMAT_PERCENTAGE_00; - - break; - case 'currency': - $type = DataType::TYPE_NUMERIC; - $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value'); - - if (floor($dataValue) == $dataValue) { - $dataValue = (int) $dataValue; - } - $formatting = NumberFormat::FORMAT_CURRENCY_USD_INTEGER; - - break; - case 'float': - $type = DataType::TYPE_NUMERIC; - $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value'); - - if (floor($dataValue) == $dataValue) { - if ($dataValue == (int) $dataValue) { - $dataValue = (int) $dataValue; - } - } - - break; - case 'date': - $type = DataType::TYPE_NUMERIC; - $value = $cellData->getAttributeNS($officeNs, 'date-value'); - $dataValue = Date::convertIsoDate($value); - - if ($dataValue != floor($dataValue)) { - $formatting = NumberFormat::FORMAT_DATE_XLSX15 - . ' ' - . NumberFormat::FORMAT_DATE_TIME4; - } else { - $formatting = NumberFormat::FORMAT_DATE_XLSX15; - } - - break; - case 'time': - $type = DataType::TYPE_NUMERIC; - - $timeValue = $cellData->getAttributeNS($officeNs, 'time-value'); - - $dataValue = Date::PHPToExcel( - strtotime( - '01-01-1970 ' . implode(':', sscanf($timeValue, 'PT%dH%dM%dS') ?? []) - ) - ); - $formatting = NumberFormat::FORMAT_DATE_TIME4; - - break; - default: - $dataValue = null; - } - } else { - $type = DataType::TYPE_NULL; - $dataValue = null; - } - - if ($hasCalculatedValue) { - $type = DataType::TYPE_FORMULA; - $cellDataFormula = substr($cellDataFormula, strpos($cellDataFormula, ':=') + 1); - $cellDataFormula = FormulaTranslator::convertToExcelFormulaValue($cellDataFormula); - } - - if ($cellData->hasAttributeNS($tableNs, 'number-columns-repeated')) { - $colRepeats = (int) $cellData->getAttributeNS($tableNs, 'number-columns-repeated'); - } else { - $colRepeats = 1; - } - - if ($type !== null) { // @phpstan-ignore-line - for ($i = 0; $i < $colRepeats; ++$i) { - if ($i > 0) { - ++$columnID; - } - - if ($type !== DataType::TYPE_NULL) { - for ($rowAdjust = 0; $rowAdjust < $rowRepeats; ++$rowAdjust) { - $rID = $rowID + $rowAdjust; - - $cell = $spreadsheet->getActiveSheet() - ->getCell($columnID . $rID); - - // Set value - if ($hasCalculatedValue) { - $cell->setValueExplicit($cellDataFormula, $type); - if ($cellDataType === 'array') { - $cell->setFormulaAttributes(['t' => 'array', 'ref' => $cellDataRef]); - } - } elseif ($type !== '' || $dataValue !== null) { - $cell->setValueExplicit($dataValue, $type); - } - - if ($hasCalculatedValue) { - $cell->setCalculatedValue($dataValue, $type === DataType::TYPE_NUMERIC); - } - - // Set other properties - if ($formatting !== null) { - $spreadsheet->getActiveSheet() - ->getStyle($columnID . $rID) - ->getNumberFormat() - ->setFormatCode($formatting); - } else { - $spreadsheet->getActiveSheet() - ->getStyle($columnID . $rID) - ->getNumberFormat() - ->setFormatCode(NumberFormat::FORMAT_GENERAL); - } - - if ($hyperlink !== null) { - if ($hyperlink[0] === '#') { - $hyperlink = 'sheet://' . substr($hyperlink, 1); - } - $cell->getHyperlink() - ->setUrl($hyperlink); - } - } - } - } - } - - // Merged cells - $this->processMergedCells($cellData, $tableNs, $type, $columnID, $rowID, $spreadsheet); - - ++$columnID; - } - $rowID += $rowRepeats; + $this->processTableRow( + $childNode, + $tableNs, + $rowID, + $worksheetName, + $officeNs, + $textNs, + $xlinkNs, + $spreadsheet + ); break; } } - $pageSettings->setVisibilityForWorksheet($spreadsheet->getActiveSheet(), $worksheetStyleName); - $pageSettings->setPrintSettingsForWorksheet($spreadsheet->getActiveSheet(), $worksheetStyleName); + $pageSettings->setVisibilityForWorksheet( + $spreadsheet->getActiveSheet(), + $worksheetStyleName + ); + $pageSettings->setPrintSettingsForWorksheet( + $spreadsheet->getActiveSheet(), + $worksheetStyleName + ); ++$worksheetID; } @@ -681,6 +454,491 @@ class Ods extends BaseReader return $spreadsheet; } + private function processTableHeaderRows( + DOMElement $childNode, + string $tableNs, + int &$rowID, + string $worksheetName, + string $officeNs, + string $textNs, + string $xlinkNs, + Spreadsheet $spreadsheet + ): void { + foreach ($childNode->childNodes as $grandchildNode) { + /** @var DOMElement $grandchildNode */ + $grandkey = self::extractNodeName($grandchildNode->nodeName); + switch ($grandkey) { + case 'table-row': + $this->processTableRow( + $grandchildNode, + $tableNs, + $rowID, + $worksheetName, + $officeNs, + $textNs, + $xlinkNs, + $spreadsheet + ); + + break; + } + } + } + + private function processTableRowGroup( + DOMElement $childNode, + string $tableNs, + int &$rowID, + string $worksheetName, + string $officeNs, + string $textNs, + string $xlinkNs, + Spreadsheet $spreadsheet + ): void { + foreach ($childNode->childNodes as $grandchildNode) { + /** @var DOMElement $grandchildNode */ + $grandkey = self::extractNodeName($grandchildNode->nodeName); + switch ($grandkey) { + case 'table-row': + $this->processTableRow( + $grandchildNode, + $tableNs, + $rowID, + $worksheetName, + $officeNs, + $textNs, + $xlinkNs, + $spreadsheet + ); + + break; + case 'table-header-rows': + case 'table-rows': + $this->processTableHeaderRows( + $grandchildNode, + $tableNs, + $rowID, + $worksheetName, + $officeNs, + $textNs, + $xlinkNs, + $spreadsheet + ); + + break; + case 'table-row-group': + $this->processTableRowGroup( + $grandchildNode, + $tableNs, + $rowID, + $worksheetName, + $officeNs, + $textNs, + $xlinkNs, + $spreadsheet + ); + + break; + } + } + } + + private function processTableRow( + DOMElement $childNode, + string $tableNs, + int &$rowID, + string $worksheetName, + string $officeNs, + string $textNs, + string $xlinkNs, + Spreadsheet $spreadsheet + ): void { + if ($childNode->hasAttributeNS($tableNs, 'number-rows-repeated')) { + $rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-rows-repeated'); + } else { + $rowRepeats = 1; + } + + $columnID = 'A'; + /** @var DOMElement|DOMText $cellData */ + foreach ($childNode->childNodes as $cellData) { + if ($cellData instanceof DOMText) { + continue; // should just be whitespace + } + if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) { + if ($cellData->hasAttributeNS($tableNs, 'number-columns-repeated')) { + $colRepeats = (int) $cellData->getAttributeNS($tableNs, 'number-columns-repeated'); + } else { + $colRepeats = 1; + } + + for ($i = 0; $i < $colRepeats; ++$i) { + ++$columnID; + } + + continue; + } + + // Initialize variables + $formatting = $hyperlink = null; + $hasCalculatedValue = false; + $cellDataFormula = ''; + $cellDataType = ''; + $cellDataRef = ''; + + if ($cellData->hasAttributeNS($tableNs, 'formula')) { + $cellDataFormula = $cellData->getAttributeNS($tableNs, 'formula'); + $hasCalculatedValue = true; + } + if ($cellData->hasAttributeNS($tableNs, 'number-matrix-columns-spanned')) { + if ($cellData->hasAttributeNS($tableNs, 'number-matrix-rows-spanned')) { + $cellDataType = 'array'; + $arrayRow = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-rows-spanned'); + $arrayCol = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-columns-spanned'); + $lastRow = $rowID + $arrayRow - 1; + $lastCol = $columnID; + while ($arrayCol > 1) { + ++$lastCol; + --$arrayCol; + } + $cellDataRef = "$columnID$rowID:$lastCol$lastRow"; + } + } + + // Annotations + $annotation = $cellData->getElementsByTagNameNS($officeNs, 'annotation'); + + if ($annotation->length > 0 && $annotation->item(0) !== null) { + $textNode = $annotation->item(0)->getElementsByTagNameNS($textNs, 'p'); + $textNodeLength = $textNode->length; + $newLineOwed = false; + for ($textNodeIndex = 0; $textNodeIndex < $textNodeLength; ++$textNodeIndex) { + $textNodeItem = $textNode->item($textNodeIndex); + if ($textNodeItem !== null) { + $text = $this->scanElementForText($textNodeItem); + if ($newLineOwed) { + $spreadsheet->getActiveSheet() + ->getComment($columnID . $rowID) + ->getText() + ->createText("\n"); + } + $newLineOwed = true; + + $spreadsheet->getActiveSheet() + ->getComment($columnID . $rowID) + ->getText() + ->createText( + $this->parseRichText($text) + ); + } + } + } + + // Content + + /** @var DOMElement[] $paragraphs */ + $paragraphs = []; + + foreach ($cellData->childNodes as $item) { + /** @var DOMElement $item */ + + // Filter text:p elements + if ($item->nodeName == 'text:p') { + $paragraphs[] = $item; + } + } + + if (count($paragraphs) > 0) { + // Consolidate if there are multiple p records (maybe with spans as well) + $dataArray = []; + + // Text can have multiple text:p and within those, multiple text:span. + // text:p newlines, but text:span does not. + // Also, here we assume there is no text data is span fields are specified, since + // we have no way of knowing proper positioning anyway. + + foreach ($paragraphs as $pData) { + $dataArray[] = $this->scanElementForText($pData); + } + $allCellDataText = implode("\n", $dataArray); + + $type = $cellData->getAttributeNS($officeNs, 'value-type'); + + switch ($type) { + case 'string': + $type = DataType::TYPE_STRING; + $dataValue = $allCellDataText; + + foreach ($paragraphs as $paragraph) { + $link = $paragraph->getElementsByTagNameNS($textNs, 'a'); + if ($link->length > 0 && $link->item(0) !== null) { + $hyperlink = $link->item(0)->getAttributeNS($xlinkNs, 'href'); + } + } + + break; + case 'boolean': + $type = DataType::TYPE_BOOL; + $dataValue = ($cellData->getAttributeNS($officeNs, 'boolean-value') === 'true') ? true : false; + + break; + case 'percentage': + $type = DataType::TYPE_NUMERIC; + $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value'); + + // percentage should always be float + //if (floor($dataValue) == $dataValue) { + // $dataValue = (int) $dataValue; + //} + $formatting = NumberFormat::FORMAT_PERCENTAGE_00; + + break; + case 'currency': + $type = DataType::TYPE_NUMERIC; + $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value'); + + if (floor($dataValue) == $dataValue) { + $dataValue = (int) $dataValue; + } + $formatting = NumberFormat::FORMAT_CURRENCY_USD_INTEGER; + + break; + case 'float': + $type = DataType::TYPE_NUMERIC; + $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value'); + + if (floor($dataValue) == $dataValue) { + if ($dataValue == (int) $dataValue) { + $dataValue = (int) $dataValue; + } + } + + break; + case 'date': + $type = DataType::TYPE_NUMERIC; + $value = $cellData->getAttributeNS($officeNs, 'date-value'); + $dataValue = Date::convertIsoDate($value); + + if ($dataValue != floor($dataValue)) { + $formatting = NumberFormat::FORMAT_DATE_XLSX15 + . ' ' + . NumberFormat::FORMAT_DATE_TIME4; + } else { + $formatting = NumberFormat::FORMAT_DATE_XLSX15; + } + + break; + case 'time': + $type = DataType::TYPE_NUMERIC; + + $timeValue = $cellData->getAttributeNS($officeNs, 'time-value'); + + $dataValue = Date::PHPToExcel( + strtotime( + '01-01-1970 ' . implode(':', sscanf($timeValue, 'PT%dH%dM%dS') ?? []) + ) + ); + $formatting = NumberFormat::FORMAT_DATE_TIME4; + + break; + default: + $dataValue = null; + } + } else { + $type = DataType::TYPE_NULL; + $dataValue = null; + } + + if ($hasCalculatedValue) { + $type = DataType::TYPE_FORMULA; + $cellDataFormula = substr($cellDataFormula, strpos($cellDataFormula, ':=') + 1); + $cellDataFormula = FormulaTranslator::convertToExcelFormulaValue($cellDataFormula); + } + + if ($cellData->hasAttributeNS($tableNs, 'number-columns-repeated')) { + $colRepeats = (int) $cellData->getAttributeNS($tableNs, 'number-columns-repeated'); + } else { + $colRepeats = 1; + } + + if ($type !== null) { // @phpstan-ignore-line + for ($i = 0; $i < $colRepeats; ++$i) { + if ($i > 0) { + ++$columnID; + } + + if ($type !== DataType::TYPE_NULL) { + for ($rowAdjust = 0; $rowAdjust < $rowRepeats; ++$rowAdjust) { + $rID = $rowID + $rowAdjust; + + $cell = $spreadsheet->getActiveSheet() + ->getCell($columnID . $rID); + + // Set value + if ($hasCalculatedValue) { + $cell->setValueExplicit($cellDataFormula, $type); + if ($cellDataType === 'array') { + $cell->setFormulaAttributes(['t' => 'array', 'ref' => $cellDataRef]); + } + } elseif ($type !== '' || $dataValue !== null) { + $cell->setValueExplicit($dataValue, $type); + } + + if ($hasCalculatedValue) { + $cell->setCalculatedValue($dataValue, $type === DataType::TYPE_NUMERIC); + } + + // Set other properties + if ($formatting !== null) { + $spreadsheet->getActiveSheet() + ->getStyle($columnID . $rID) + ->getNumberFormat() + ->setFormatCode($formatting); + } else { + $spreadsheet->getActiveSheet() + ->getStyle($columnID . $rID) + ->getNumberFormat() + ->setFormatCode(NumberFormat::FORMAT_GENERAL); + } + + if ($hyperlink !== null) { + if ($hyperlink[0] === '#') { + $hyperlink = 'sheet://' . substr($hyperlink, 1); + } + $cell->getHyperlink() + ->setUrl($hyperlink); + } + } + } + } + } + + // Merged cells + $this->processMergedCells($cellData, $tableNs, $type, $columnID, $rowID, $spreadsheet); + + ++$columnID; + } + $rowID += $rowRepeats; + } + + private static function extractNodeName(string $key): string + { + // Remove ns from node name + if (str_contains($key, ':')) { + $keyChunks = explode(':', $key); + $key = array_pop($keyChunks); + } + + return $key; + } + + /** + * @param string[] $columnWidths + */ + private function processTableHeaderColumns( + DOMElement $childNode, + string $tableNs, + array $columnWidths, + int &$tableColumnIndex, + Spreadsheet $spreadsheet + ): void { + foreach ($childNode->childNodes as $grandchildNode) { + /** @var DOMElement $grandchildNode */ + $grandkey = self::extractNodeName($grandchildNode->nodeName); + switch ($grandkey) { + case 'table-column': + $this->processTableColumn( + $grandchildNode, + $tableNs, + $columnWidths, + $tableColumnIndex, + $spreadsheet + ); + + break; + } + } + } + + /** + * @param string[] $columnWidths + */ + private function processTableColumnGroup( + DOMElement $childNode, + string $tableNs, + array $columnWidths, + int &$tableColumnIndex, + Spreadsheet $spreadsheet + ): void { + foreach ($childNode->childNodes as $grandchildNode) { + /** @var DOMElement $grandchildNode */ + $grandkey = self::extractNodeName($grandchildNode->nodeName); + switch ($grandkey) { + case 'table-column': + $this->processTableColumn( + $grandchildNode, + $tableNs, + $columnWidths, + $tableColumnIndex, + $spreadsheet + ); + + break; + case 'table-header-columns': + case 'table-columns': + $this->processTableHeaderColumns( + $grandchildNode, + $tableNs, + $columnWidths, + $tableColumnIndex, + $spreadsheet + ); + + break; + case 'table-column-group': + $this->processTableColumnGroup( + $grandchildNode, + $tableNs, + $columnWidths, + $tableColumnIndex, + $spreadsheet + ); + + break; + } + } + } + + /** + * @param string[] $columnWidths + */ + private function processTableColumn( + DOMElement $childNode, + string $tableNs, + array $columnWidths, + int &$tableColumnIndex, + Spreadsheet $spreadsheet + ): void { + if ($childNode->hasAttributeNS($tableNs, 'number-columns-repeated')) { + $rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-columns-repeated'); + } else { + $rowRepeats = 1; + } + $tableStyleName = $childNode->getAttributeNS($tableNs, 'style-name'); + if (isset($columnWidths[$tableStyleName])) { + $columnWidth = new HelperDimension($columnWidths[$tableStyleName]); + $tableColumnString = Coordinate::stringFromColumnIndex($tableColumnIndex); + for ($rowRepeats2 = $rowRepeats; $rowRepeats2 > 0; --$rowRepeats2) { + /** @var string $tableColumnString */ + $spreadsheet->getActiveSheet() + ->getColumnDimension($tableColumnString) + ->setWidth($columnWidth->toUnit('cm'), 'cm'); + ++$tableColumnString; + } + } + $tableColumnIndex += $rowRepeats; + } + private function processSettings(ZipArchive $zip, Spreadsheet $spreadsheet): void { $dom = new DOMDocument('1.01', 'UTF-8'); @@ -688,9 +946,7 @@ class Ods extends BaseReader $this->getSecurityScannerOrThrow() ->scan($zip->getFromName('settings.xml')) ); - //$xlinkNs = $dom->lookupNamespaceUri('xlink'); $configNs = (string) $dom->lookupNamespaceUri('config'); - //$oooNs = $dom->lookupNamespaceUri('ooo'); $officeNs = (string) $dom->lookupNamespaceUri('office'); $settings = $dom->getElementsByTagNameNS($officeNs, 'settings') ->item(0); diff --git a/tests/PhpSpreadsheetTests/Reader/Ods/NestedTableRowTest.php b/tests/PhpSpreadsheetTests/Reader/Ods/NestedTableRowTest.php new file mode 100644 index 000000000..45e0646b9 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Ods/NestedTableRowTest.php @@ -0,0 +1,42 @@ +load($infile); + $sheet = $spreadsheet->getActiveSheet(); + self::assertSame('Atterissage', $sheet->getCell('AS1')->getValue()); + self::assertNull($sheet->getCell('AS2')->getValue()); + self::assertSame('jour', $sheet->getCell('AS3')->getValue()); + self::assertSame('=SUM(Y3:INDIRECT(CONCATENATE("Y",$C$3)))', $sheet->getCell('AS4')->getValue()); + $spreadsheet->disconnectWorksheets(); + } + + public function testTableRowGroup(): void + { + $infile = 'tests/data/Reader/Ods/issue.2507.ods'; + $reader = new OdsReader(); + $spreadsheet = $reader->load($infile); + $sheet = $spreadsheet->getActiveSheet(); + $values = $sheet->rangeToArray('B3:C7', null, false, false); + $expected = [ + ['Номенклатура', "Складское наличие,\nКол-во"], // before table-row-group + ['Квадрат 140х140мм ст.5ХНМ (т)', 0.225], // within table-row-group + ['Квадрат 200х200мм ст.3 (т)', 1.700], + ['Квадрат 210х210мм ст.65Г (т)', 0.280], + ['Квадрат 250х250мм ст.45 (т)', 0.133], + ]; + self::assertSame($expected, $values); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/data/Reader/Ods/issue.2507.ods b/tests/data/Reader/Ods/issue.2507.ods new file mode 100644 index 0000000000000000000000000000000000000000..1475bc648ffdb9eddd458b3486566cbff36b65be GIT binary patch literal 20923 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 o5dJP=GNd4P>;LlOPyg!P{d9L3c?$5BV^B;{X5v literal 0 HcmV?d00001 From ace17552383b0bda17100d0612361c309fede6c3 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:54:52 -0700 Subject: [PATCH 16/30] Excel Inappropriate Number Format Substitution My system short date format is set to `yyyy-mm-dd`. I used Excel to create a spreadsheet, and included some dates, specifying `yyyy-mm-dd` formatting. When I looked at the resulting spreadsheet, I was surprised to see that Excel had stored the style not as `yyyy-mm-dd`, but rather as builtin style 14 (system short date format). Apparently the fact that the Excel styling matched my system choice was sufficient for it to override my choice! This is an astonishingly user-hostile implementation. Even though there are formats which, by design, "respond to changes in regional date and time settings", and even though the format I selected was not among those, Excel decided it was appropriate to vary the display even when I said I wanted an unvarying format. This PR adds a new method `replaceBuiltinNumberFormat` to undo the damage that Excel does in such a situation. It also adds an `Excel Anomalies` document to the formal documentation, just to make situations like this readily available to the community. BTW, Excel's sabotage can be avoided by using a number format style like `[Black]yyyy-mm-dd`. --- docs/topics/Excel Anomalies.md | 45 ++++++++++++ docs/topics/The Dating Game.md | 2 +- src/PhpSpreadsheet/Spreadsheet.php | 17 +++++ .../Xlsx/ReplaceBuiltinNumberFormatTest.php | 68 +++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 docs/topics/Excel Anomalies.md create mode 100644 tests/PhpSpreadsheetTests/Reader/Xlsx/ReplaceBuiltinNumberFormatTest.php diff --git a/docs/topics/Excel Anomalies.md b/docs/topics/Excel Anomalies.md new file mode 100644 index 000000000..0055562f6 --- /dev/null +++ b/docs/topics/Excel Anomalies.md @@ -0,0 +1,45 @@ +# Excel Anomalies + +This is documentation for some behavior in Excel itself which we +just do not understand, or which may come as a surprise to the user. + +## Date Number Format + +My system short date format is set to `yyyy-mm-dd`. Excel, for a very long time, did not include that amongst its formatting choices for dates, so it needed to be added as a custom format - no big deal. It has recently been added to the list of date formats, but ... + +I used Excel to create a spreadsheet, and included some dates, specifying `yyyy-mm-dd` formatting. When I looked at the resulting spreadsheet, I was surprised to see that Excel had stored the style not as `yyyy-mm-dd`, but rather as builtin style 14 (system short date format). Apparently the fact that the Excel styling matched my system choice was sufficient for it to override my choice! This is an astonishingly user-hostile implementation. Even though there are formats which, by design, "respond to changes in regional date and time settings", and even though the format I selected was not among those, Excel decided it was appropriate to vary the display even when I said I wanted an unvarying format. I assume, but have not confirmed, that this applies to formats other than `yyyy-mm-dd`. + +Note that this is not a problem when using PhpSpreadsheet to set the style, only when you let Excel do it. And, in that case, after a little experimentation, I figured out a format that Excel doesn't sabotage `[Black]yyyy-mm-dd`. + +If you have a spreadsheet that has been altered in this way, it can be fixed with the following PhpSpreadsheet code: +```php + foreach ($spreadsheet->getCellXfCollection() as $style) { + $numberFormat = $style->getNumberFormat(); + if ($numberFormat->getBuiltInFormatCode() === 14) { + $numberFormat->setFormatCode('yyyy-mm-dd'); + } + } +``` +Starting with PhpSpreadsheet 4.5.0, this can be simplified to: +```php + $spreadsheet->replaceBuiltinNumberFormat( + 14, + 'yyyy-mm-dd' + ); +``` + +## Negative Time Intervals + +You have a time in one cell, and a time in another, and you want to subtract and display the result in `h:mm` format. No problem if the result is positive. But, if it's negative, Excel just fills the cell with `#`. There is a solution of sorts. If you use a 1904 base date (default on Mac), the negative interval will work just fine. Alas, no dice if you use a 1900 base data (default on Windows). No idea why they can't fix that - the existing implementation can't really be something that anybody actually wants. Note that it is *not* safe to change the base date for an existing spreadsheet, so, if this is something you want to do, make sure you change the base date before populating any data. + +## Long-ago Dates + +Excel does not support dates before either 1900-01-01 (Windows default) or 1904-01-01 (Mac default). For the 1900 base year, there is the additional problem that non-existent date 1900-02-29 is squeezed between 1900-02-28 and 1900-03-01. + +## Weird Fractions + +Similar fraction formats have inconsistent results in Excel. For example, if a cell contains the value 1 and the cell's format is `0 0/0`, it will display as `1 0/1`. But, if the cell's format is `? ??/???`, it will display as `1`. See [this issue](https://github.com/PHPOffice/PhpSpreadsheet/issues/3625), which remains open because, in the absence of usable documentation, we aren't sure how to handle things. + +## COUNTIF and Text Cells + +In Excel, COUNTIF appears to ignore text cells, behavior which doesn't seem to be documented anywhere. See [this issue](https://github.com/PHPOffice/PhpSpreadsheet/issues/3802), which remains open because, in the absence of usable documentation, we aren't sure how to handle things. \ No newline at end of file diff --git a/docs/topics/The Dating Game.md b/docs/topics/The Dating Game.md index 5b0c2812f..cbe46ccae 100644 --- a/docs/topics/The Dating Game.md +++ b/docs/topics/The Dating Game.md @@ -184,7 +184,7 @@ MS Excel allows any separator character between hours/minutes/seconds; PhpSpread ### Duration (Elapsed Time) -Excel also supports formatting a value as a duration; a total number of hours, minutes or seconds rather than a time of day. +Excel also supports formatting a value as a duration; a total number of hours, minutes or seconds rather than a time of day. However, please note that negative durations are supported only if using base year 1904 (Mac default). | Code | Description | Displays as | |---------|----------------------------------------------------------------|-------------| diff --git a/src/PhpSpreadsheet/Spreadsheet.php b/src/PhpSpreadsheet/Spreadsheet.php index 656f3b559..d035f402a 100644 --- a/src/PhpSpreadsheet/Spreadsheet.php +++ b/src/PhpSpreadsheet/Spreadsheet.php @@ -1784,4 +1784,21 @@ class Spreadsheet implements JsonSerializable } } } + + /** + * Excel will sometimes replace user's formatting choice + * with a built-in choice that it thinks is equivalent. + * Its choice is often not equivalent after all. + * Such treatment is astonishingly user-hostile. + * This function will undo such changes. + */ + public function replaceBuiltinNumberFormat(int $builtinFormatIndex, string $formatCode): void + { + foreach ($this->cellXfCollection as $style) { + $numberFormat = $style->getNumberFormat(); + if ($numberFormat->getBuiltInFormatCode() === $builtinFormatIndex) { + $numberFormat->setFormatCode($formatCode); + } + } + } } diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/ReplaceBuiltinNumberFormatTest.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/ReplaceBuiltinNumberFormatTest.php new file mode 100644 index 000000000..7ef29a388 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/ReplaceBuiltinNumberFormatTest.php @@ -0,0 +1,68 @@ +spreadsheet !== null) { + $this->spreadsheet->disconnectWorksheets(); + $this->spreadsheet = null; + } + if ($this->reloadedSpreadsheet !== null) { + $this->reloadedSpreadsheet->disconnectWorksheets(); + $this->reloadedSpreadsheet = null; + } + } + + public function testJustifyLastLine(): void + { + $spreadsheet = $this->spreadsheet = new Spreadsheet(); + $sheet = $this->spreadsheet->getActiveSheet(); + $sheet->fromArray([45486, 1023, 45487, 45488, 45489]); + $sheet->getStyle('A1')->getNumberFormat() + ->setBuiltInFormatCode(14); + $sheet->getStyle('B1')->getNumberFormat() + ->setFormatCode('#,##0.00'); + $sheet->getStyle('C1')->getNumberFormat() + ->setBuiltInFormatCode(14); + $sheet->getStyle('D1')->getNumberFormat() + ->setFormatCode('dd-MMM-yyyy'); + $sheet->getStyle('E1')->getNumberFormat() + ->setBuiltInFormatCode(16); + $values = $sheet->toArray(); + $expected = [[ + '7/13/2024', // builtin style 14 + '1,023.00', // #,##0.00 + '7/14/2024', // builtin style 14 + '15-Jul-2024', // dd-MMM-yyyy + '16-Jul', // builtin style 16 + ]]; + self::assertSame($expected, $values); + $this->reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Xlsx'); + $this->reloadedSpreadsheet->replaceBuiltinNumberFormat( + 14, + 'yyyy-mm-dd' + ); + $rsheet = $this->reloadedSpreadsheet->getActiveSheet(); + $newValues = $rsheet->toArray(); + $newExpected = [[ + '2024-07-13', // yyyy-mm-dd changed from builtin style 14 + '1,023.00', // unchanged #,##0.00 + '2024-07-14', // yyyy-mm-dd changed from builtin style 14 + '15-Jul-2024', // unchanged dd-MMM-yyyy + '16-Jul', // unchanged builtin style 16 + ]]; + self::assertSame($newExpected, $newValues); + } +} From ae4df820bf83ce7a170ddee12067ba035f900b76 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 10 Jul 2025 18:11:41 -0700 Subject: [PATCH 17/30] Xlsx Reader Use Dynamic Arrays if Spreadsheet Did So By default, when an Xlsx spreadsheet is read, its calculation engine is set to `RETURN_ARRAY_AS_VALUE` for array formulas. The user does have the option to change this. However, the presence of certain metadata in the file indicates that it was saved as if `RETURN_ARRAY_AS_ARRAY` was in use. This PR tests for the metadata, and, if present, sets `...ARRAY` rather than `...VALUE`. This eliminates the need for any extra action on the user's part. --- src/PhpSpreadsheet/Reader/Xlsx.php | 10 +++ .../Reader/Xlsx/ReadDynamTest.php | 71 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 tests/PhpSpreadsheetTests/Reader/Xlsx/ReadDynamTest.php diff --git a/src/PhpSpreadsheet/Reader/Xlsx.php b/src/PhpSpreadsheet/Reader/Xlsx.php index b3e133f78..7daed146e 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/src/PhpSpreadsheet/Reader/Xlsx.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Reader; +use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; @@ -449,6 +450,15 @@ class Xlsx extends BaseReader $relTarget = substr($relTarget, 4); } switch ($rel['Type']) { + case "$xmlNamespaceBase/sheetMetadata": + if ($this->fileExistsInArchive($zip, "xl/{$relTarget}")) { + $excel->getCalculationEngine() + ?->setInstanceArrayReturnType( + Calculation::RETURN_ARRAY_AS_ARRAY + ); + } + + break; case "$xmlNamespaceBase/theme": if (!$this->fileExistsInArchive($zip, "xl/{$relTarget}")) { break; // issue3770 diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/ReadDynamTest.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/ReadDynamTest.php new file mode 100644 index 000000000..8ad7053f1 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/ReadDynamTest.php @@ -0,0 +1,71 @@ +setUseCSEArrays(true); + } + + public function testCse(): void + { + $spreadsheetOld = new Spreadsheet(); + $sheetOld = $spreadsheetOld->getActiveSheet(); + $calcOld = Calculation::getInstance($spreadsheetOld); + $calcOld->setInstanceArrayReturnType( + Calculation::RETURN_ARRAY_AS_ARRAY + ); + $sheetOld->fromArray( + [1, 2, 2, 4, 3, 2, 1, 3, 3, 3, 5], + null, + 'A14', + true + ); + $sheetOld->setCellValue('A15', '=UNIQUE(A14:K14, TRUE)'); + /** @var callable */ + $callableWriter = [$this, 'writeCse']; + $spreadsheet = $this->writeAndReload($spreadsheetOld, 'Xlsx', null, $callableWriter); + $spreadsheetOld->disconnectWorksheets(); + $calc = Calculation::getInstance($spreadsheet); + self::assertSame( + Calculation::RETURN_ARRAY_AS_VALUE, + $calc->getInstanceArrayReturnType() + ); + $spreadsheet->disconnectWorksheets(); + } + + public function testDynam(): void + { + $spreadsheetOld = new Spreadsheet(); + $sheetOld = $spreadsheetOld->getActiveSheet(); + $calcOld = Calculation::getInstance($spreadsheetOld); + $calcOld->setInstanceArrayReturnType( + Calculation::RETURN_ARRAY_AS_ARRAY + ); + $sheetOld->fromArray( + [1, 2, 2, 4, 3, 2, 1, 3, 3, 3, 5], + null, + 'A14', + true + ); + $sheetOld->setCellValue('A15', '=UNIQUE(A14:K14, TRUE)'); + $reloadedSpreadsheet = $this->writeAndReload($spreadsheetOld, 'Xlsx'); + $spreadsheet = $this->writeAndReload($spreadsheetOld, 'Xlsx'); + $spreadsheetOld->disconnectWorksheets(); + $calc = Calculation::getInstance($spreadsheet); + self::assertSame( + Calculation::RETURN_ARRAY_AS_ARRAY, + $calc->getInstanceArrayReturnType() + ); + $spreadsheet->disconnectWorksheets(); + } +} From 159059595e0383d3cf05e46aee313176a939c5ed Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Fri, 11 Jul 2025 17:13:53 -0700 Subject: [PATCH 18/30] Minor Tweaks --- docs/topics/Excel Anomalies.md | 3 ++- .../Reader/Xlsx/ReplaceBuiltinNumberFormatTest.php | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/topics/Excel Anomalies.md b/docs/topics/Excel Anomalies.md index 0055562f6..d41337a5f 100644 --- a/docs/topics/Excel Anomalies.md +++ b/docs/topics/Excel Anomalies.md @@ -15,6 +15,7 @@ If you have a spreadsheet that has been altered in this way, it can be fixed wit ```php foreach ($spreadsheet->getCellXfCollection() as $style) { $numberFormat = $style->getNumberFormat(); + // okay to use NumberFormat::SHORT_DATE_INDEX below if ($numberFormat->getBuiltInFormatCode() === 14) { $numberFormat->setFormatCode('yyyy-mm-dd'); } @@ -23,7 +24,7 @@ If you have a spreadsheet that has been altered in this way, it can be fixed wit Starting with PhpSpreadsheet 4.5.0, this can be simplified to: ```php $spreadsheet->replaceBuiltinNumberFormat( - 14, + \PhpOffice\PhpSpreadsheet\Style\NumberFormat::SHORT_DATE_INDEX, 'yyyy-mm-dd' ); ``` diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/ReplaceBuiltinNumberFormatTest.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/ReplaceBuiltinNumberFormatTest.php index 7ef29a388..59b5cf3c3 100644 --- a/tests/PhpSpreadsheetTests/Reader/Xlsx/ReplaceBuiltinNumberFormatTest.php +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/ReplaceBuiltinNumberFormatTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace PhpOffice\PhpSpreadsheetTests\Reader\Xlsx; use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat; use PhpOffice\PhpSpreadsheetTests\Functional\AbstractFunctional; class ReplaceBuiltinNumberFormatTest extends AbstractFunctional @@ -25,17 +26,17 @@ class ReplaceBuiltinNumberFormatTest extends AbstractFunctional } } - public function testJustifyLastLine(): void + public function testReplaceBuiltinNumberFormat(): void { $spreadsheet = $this->spreadsheet = new Spreadsheet(); $sheet = $this->spreadsheet->getActiveSheet(); $sheet->fromArray([45486, 1023, 45487, 45488, 45489]); $sheet->getStyle('A1')->getNumberFormat() - ->setBuiltInFormatCode(14); + ->setBuiltInFormatCode(NumberFormat::SHORT_DATE_INDEX); $sheet->getStyle('B1')->getNumberFormat() ->setFormatCode('#,##0.00'); $sheet->getStyle('C1')->getNumberFormat() - ->setBuiltInFormatCode(14); + ->setBuiltInFormatCode(NumberFormat::SHORT_DATE_INDEX); $sheet->getStyle('D1')->getNumberFormat() ->setFormatCode('dd-MMM-yyyy'); $sheet->getStyle('E1')->getNumberFormat() @@ -51,7 +52,7 @@ class ReplaceBuiltinNumberFormatTest extends AbstractFunctional self::assertSame($expected, $values); $this->reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Xlsx'); $this->reloadedSpreadsheet->replaceBuiltinNumberFormat( - 14, + NumberFormat::SHORT_DATE_INDEX, 'yyyy-mm-dd' ); $rsheet = $this->reloadedSpreadsheet->getActiveSheet(); From 98f464b41f1867cf9fdd0bf574b3598d29915f13 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Mon, 14 Jul 2025 00:22:54 -0700 Subject: [PATCH 19/30] Writer Html/Pdf Support RTL Alignment of Table Fix #1104, which went stale over 6 years ago, and is now reopened. This PR addresses the alignment of the table, not alignment of text. Support is added for the following: - Html. Full support. - Mpdf. Full support, except that, when mixed LTR and RTL worksheets are output, all tables will be on the left of the page (but will be properly aligned). - Tcpdf. Full support when all worksheets being output are RTL; no support when mixed LTR and RTL worksheets are output. - Dompdf. No support. --- .../{21f_Drawing_mpdf.php => 21f_Drawing.php} | 13 +-- samples/Pdf/21g_Direction.php | 28 ++++++ samples/Pdf/21h_DirectionMultiple.php | 36 ++++++++ src/PhpSpreadsheet/Reader/Html.php | 3 + src/PhpSpreadsheet/Writer/Html.php | 81 ++++++++++++++-- src/PhpSpreadsheet/Writer/Pdf/Tcpdf.php | 24 ++++- .../Reader/Html/DirectionTest.php | 92 +++++++++++++++++++ .../Writer/Html/DirectionTest.php | 86 +++++++++++++++++ 8 files changed, 339 insertions(+), 24 deletions(-) rename samples/Pdf/{21f_Drawing_mpdf.php => 21f_Drawing.php} (78%) create mode 100644 samples/Pdf/21g_Direction.php create mode 100644 samples/Pdf/21h_DirectionMultiple.php create mode 100644 tests/PhpSpreadsheetTests/Reader/Html/DirectionTest.php create mode 100644 tests/PhpSpreadsheetTests/Writer/Html/DirectionTest.php diff --git a/samples/Pdf/21f_Drawing_mpdf.php b/samples/Pdf/21f_Drawing.php similarity index 78% rename from samples/Pdf/21f_Drawing_mpdf.php rename to samples/Pdf/21f_Drawing.php index 357b4690a..489914c3a 100644 --- a/samples/Pdf/21f_Drawing_mpdf.php +++ b/samples/Pdf/21f_Drawing.php @@ -2,12 +2,10 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Worksheet\Drawing; -use PhpOffice\PhpSpreadsheet\Writer\Pdf\Mpdf; require __DIR__ . '/../Header.php'; -/** @var PhpOffice\PhpSpreadsheet\Helper\Sample $helper */ -require_once __DIR__ . '/Mpdf2.php'; +/** @var PhpOffice\PhpSpreadsheet\Helper\Sample $helper */ $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); @@ -41,11 +39,6 @@ $helper->log('Merge drawing cells for Pdf'); $spreadsheet->mergeDrawingCellsForPdf(); $helper->log('Write to Mpdf'); -$writer = new Mpdf($spreadsheet); -$filename = $helper->getFileName(__FILE__, 'pdf'); -$writer->save($filename); -$helper->log("Saved $filename"); -if (PHP_SAPI !== 'cli') { - echo 'Download ' . basename($filename) . '
'; -} +$helper->write($spreadsheet, __FILE__, ['Mpdf']); + $spreadsheet->disconnectWorksheets(); diff --git a/samples/Pdf/21g_Direction.php b/samples/Pdf/21g_Direction.php new file mode 100644 index 000000000..180fad1a3 --- /dev/null +++ b/samples/Pdf/21g_Direction.php @@ -0,0 +1,28 @@ +getActiveSheet(); + +$sheet1 = $spreadsheet->getActiveSheet(); +$sheet2 = $spreadsheet->createSheet(); +$sheet3 = $spreadsheet->createSheet(); +$cells = [ + ['a1', 'b1', 'c1'], + ['a2', 'b2', 'c2'], +]; +$sheet1->fromArray($cells); +$sheet1->setRightToLeft(true); +$sheet2->fromArray($cells); +$sheet3->fromArray($cells); +$sheet3->setRightToLeft(true); + +$helper->log('Write to html, mpdf, tcpdf'); +// Save +$helper->write($spreadsheet, __FILE__, ['Html', 'Mpdf', 'Tcpdf']); + +$spreadsheet->disconnectWorksheets(); diff --git a/samples/Pdf/21h_DirectionMultiple.php b/samples/Pdf/21h_DirectionMultiple.php new file mode 100644 index 000000000..894dfa3f6 --- /dev/null +++ b/samples/Pdf/21h_DirectionMultiple.php @@ -0,0 +1,36 @@ +getActiveSheet(); + +$sheet1 = $spreadsheet->getActiveSheet(); +$sheet2 = $spreadsheet->createSheet(); +$sheet3 = $spreadsheet->createSheet(); +$cells = [ + ['a1', 'b1', 'c1'], + ['a2', 'b2', 'c2'], +]; +$sheet1->fromArray($cells); +$sheet1->setRightToLeft(true); +$sheet2->fromArray($cells); +$sheet3->fromArray($cells); +$sheet3->setRightToLeft(true); + +$helper->log('Write to html, mpdf'); +// Save +$helper->write( + $spreadsheet, + __FILE__, + ['Html', 'Mpdf'], + writerCallback: function (HtmlWriter $writer): void { + $writer->writeAllSheets(); + } +); + +$spreadsheet->disconnectWorksheets(); diff --git a/src/PhpSpreadsheet/Reader/Html.php b/src/PhpSpreadsheet/Reader/Html.php index af831cf46..e63c05327 100644 --- a/src/PhpSpreadsheet/Reader/Html.php +++ b/src/PhpSpreadsheet/Reader/Html.php @@ -532,6 +532,9 @@ class Html extends BaseReader $sheet->setShowGridlines(in_array('gridlines', $classes, true)); $sheet->setPrintGridlines(in_array('gridlinesp', $classes, true)); } + if ('rtl' === ($attributeArray['dir'] ?? '')) { + $sheet->setRightToLeft(true); + } $this->currentColumn = 'A'; $this->flushCell($sheet, $column, $row, $cellContent, $attributeArray); $column = $this->setTableStartColumn($column); diff --git a/src/PhpSpreadsheet/Writer/Html.php b/src/PhpSpreadsheet/Writer/Html.php index 8d7e45b0b..c79b5737a 100644 --- a/src/PhpSpreadsheet/Writer/Html.php +++ b/src/PhpSpreadsheet/Writer/Html.php @@ -158,6 +158,10 @@ class Html extends BaseWriter private string $getFalse = 'FALSE'; + protected bool $rtlSheets = false; + + protected bool $ltrSheets = false; + /** * Create a new HTML. */ @@ -186,11 +190,31 @@ class Html extends BaseWriter $this->maybeCloseFileHandle(); } + protected function checkRtlAndLtr(): void + { + $this->rtlSheets = false; + $this->ltrSheets = false; + if ($this->sheetIndex === null) { + foreach ($this->spreadsheet->getAllSheets() as $sheet) { + if ($sheet->getRightToLeft()) { + $this->rtlSheets = true; + } else { + $this->ltrSheets = true; + } + } + } else { + if ($this->spreadsheet->getSheet($this->sheetIndex)->getRightToLeft()) { + $this->rtlSheets = true; + } + } + } + /** * Save Spreadsheet as html to variable. */ public function generateHtmlAll(): string { + $this->checkRtlAndLtr(); $sheets = $this->generateSheetPrep(); foreach ($sheets as $sheet) { $sheet->calculateArrays($this->preCalculateFormulas); @@ -369,7 +393,8 @@ class Html extends BaseWriter // Construct HTML $properties = $this->spreadsheet->getProperties(); $html = '' . PHP_EOL; - $html .= '' . PHP_EOL; + $rtl = ($this->rtlSheets && !$this->ltrSheets) ? " dir='rtl'" : ''; + $html .= '' . PHP_EOL; $html .= ' ' . PHP_EOL; $html .= ' ' . PHP_EOL; $html .= ' ' . PHP_EOL; @@ -1013,6 +1038,9 @@ class Html extends BaseWriter // .s {} $css['.s']['text-align'] = 'left'; // STRING + $css['.floatright']['float'] = 'right'; + $css['.floatleft']['float'] = 'left'; + // Calculate cell style hashes foreach ($this->spreadsheet->getCellXfCollection() as $index => $style) { $css['td.style' . $index . ', th.style' . $index] = $this->createCSSStyle($style); @@ -1221,21 +1249,52 @@ class Html extends BaseWriter return $html; } + private function getDir(Worksheet $worksheet): string + { + if ($worksheet->getRightToLeft()) { + return " dir='rtl'"; + } + if ($this->rtlSheets) { + return " dir='ltr'"; + } + + return ''; + } + + private function getFloat(Worksheet $worksheet): string + { + $float = ''; + if ($worksheet->getRightToLeft()) { + if ($this->ltrSheets) { + $float = ' floatright'; + } + } else { + if ($this->rtlSheets) { + $float = ' floatleft'; + } + } + + return $float; + } + private function generateTableTagInline(Worksheet $worksheet, string $id): string { $style = isset($this->cssStyles['table']) ? $this->assembleCSS($this->cssStyles['table']) : ''; - + $rtl = $this->getDir($worksheet); + $float = $this->getFloat($worksheet); $prntgrid = $worksheet->getPrintGridlines(); $viewgrid = $this->isPdf ? $prntgrid : $worksheet->getShowGridlines(); if ($viewgrid && $prntgrid) { - $html = " " . PHP_EOL; + $html = "
" . PHP_EOL; } elseif ($viewgrid) { - $html = "
" . PHP_EOL; + $html = "
" . PHP_EOL; } elseif ($prntgrid) { - $html = "
" . PHP_EOL; + $html = "
" . PHP_EOL; + } elseif ($float === '') { + $html = "
" . PHP_EOL; } else { - $html = "
" . PHP_EOL; + $html = "
" . PHP_EOL; } return $html; @@ -1244,9 +1303,11 @@ class Html extends BaseWriter private function generateTableTag(Worksheet $worksheet, string $id, string &$html, int $sheetIndex): void { if (!$this->useInlineCss) { + $rtl = $this->getDir($worksheet); + $float = $this->getFloat($worksheet); $gridlines = $worksheet->getShowGridlines() ? ' gridlines' : ''; $gridlinesp = $worksheet->getPrintGridlines() ? ' gridlinesp' : ''; - $html .= "
" . PHP_EOL; + $html .= "
" . PHP_EOL; } else { $html .= $this->generateTableTagInline($worksheet, $id); } @@ -1265,10 +1326,12 @@ class Html extends BaseWriter // Construct HTML $html = ''; $id = $showid ? "id='sheet$sheetIndex'" : ''; + $clear = ($this->rtlSheets && $this->ltrSheets) ? '; clear:both' : ''; + if ($showid) { - $html .= "
" . PHP_EOL; + $html .= "
" . PHP_EOL; } else { - $html .= "
" . PHP_EOL; + $html .= "
" . PHP_EOL; } $this->generateTableTag($worksheet, $id, $html, $sheetIndex); diff --git a/src/PhpSpreadsheet/Writer/Pdf/Tcpdf.php b/src/PhpSpreadsheet/Writer/Pdf/Tcpdf.php index 540882708..fe5cfe25c 100644 --- a/src/PhpSpreadsheet/Writer/Pdf/Tcpdf.php +++ b/src/PhpSpreadsheet/Writer/Pdf/Tcpdf.php @@ -67,14 +67,28 @@ class Tcpdf extends Pdf // Set the appropriate font $pdf->SetFont($this->getFont()); + $this->checkRtlAndLtr(); + if ($this->rtlSheets && !$this->ltrSheets) { + $pdf->setRTL(true); + } $pdf->writeHTML($this->generateHTMLAll()); // Document info - $pdf->SetTitle($this->spreadsheet->getProperties()->getTitle()); - $pdf->SetAuthor($this->spreadsheet->getProperties()->getCreator()); - $pdf->SetSubject($this->spreadsheet->getProperties()->getSubject()); - $pdf->SetKeywords($this->spreadsheet->getProperties()->getKeywords()); - $pdf->SetCreator($this->spreadsheet->getProperties()->getCreator()); + $pdf->SetTitle( + $this->spreadsheet->getProperties()->getTitle() + ); + $pdf->SetAuthor( + $this->spreadsheet->getProperties()->getCreator() + ); + $pdf->SetSubject( + $this->spreadsheet->getProperties()->getSubject() + ); + $pdf->SetKeywords( + $this->spreadsheet->getProperties()->getKeywords() + ); + $pdf->SetCreator( + $this->spreadsheet->getProperties()->getCreator() + ); // Write to file fwrite($fileHandle, $pdf->output('', 'S')); diff --git a/tests/PhpSpreadsheetTests/Reader/Html/DirectionTest.php b/tests/PhpSpreadsheetTests/Reader/Html/DirectionTest.php new file mode 100644 index 000000000..5021a54f3 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Html/DirectionTest.php @@ -0,0 +1,92 @@ +", + '
', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
a1b1c1
a2b2c2
', + ]; + $html = implode("\n", $inlines); + $reader = new HtmlReader(); + $spreadsheet = $reader->loadFromString($html); + $sheet = $spreadsheet->getActiveSheet(); + self::assertTrue($sheet->getRightToLeft()); + self::assertSame('a1', $sheet->getCell('A1')->getValue()); + self::assertSame('c2', $sheet->getCell('C2')->getValue()); + $spreadsheet->disconnectWorksheets(); + } + + public function testLtr(): void + { + $inlines = [ + "", + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
a1b1c1
a2b2c2
', + ]; + $html = implode("\n", $inlines); + $reader = new HtmlReader(); + $spreadsheet = $reader->loadFromString($html); + $sheet = $spreadsheet->getActiveSheet(); + self::assertFalse($sheet->getRightToLeft()); + self::assertSame('a1', $sheet->getCell('A1')->getValue()); + self::assertSame('c2', $sheet->getCell('C2')->getValue()); + $spreadsheet->disconnectWorksheets(); + } + + public function testDefault(): void + { + $inlines = [ + "", + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
a1b1c1
a2b2c2
', + ]; + $html = implode("\n", $inlines); + $reader = new HtmlReader(); + $spreadsheet = $reader->loadFromString($html); + $sheet = $spreadsheet->getActiveSheet(); + self::assertFalse($sheet->getRightToLeft()); + self::assertSame('a1', $sheet->getCell('A1')->getValue()); + self::assertSame('c2', $sheet->getCell('C2')->getValue()); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Html/DirectionTest.php b/tests/PhpSpreadsheetTests/Writer/Html/DirectionTest.php new file mode 100644 index 000000000..9a406bc32 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Html/DirectionTest.php @@ -0,0 +1,86 @@ +getActiveSheet(); + $sheet1->setRightToLeft(true); + $sheet2 = $spreadsheet->createSheet(); + $sheet3 = $spreadsheet->createSheet(); + $sheet3->setRightToLeft(true); + $cells = [ + ['a1', 'b1', 'c1'], + ['a2', 'b2', 'c2'], + ]; + $sheet1->fromArray($cells); + $sheet2->fromArray($cells); + $sheet3->fromArray($cells); + $writer = new Html($spreadsheet); + $writer->writeAllSheets(); + $html = $writer->generateHTMLall(); + $rtlCount = substr_count($html, "dir='rtl'"); + self::assertSame(2, $rtlCount); + $ltrCount = substr_count($html, "dir='ltr'"); + self::assertSame(1, $ltrCount); + $spreadsheet->disconnectWorksheets(); + } + + public function testNoRtl(): void + { + $spreadsheet = new Spreadsheet(); + $sheet1 = $spreadsheet->getActiveSheet(); + $sheet2 = $spreadsheet->createSheet(); + $sheet3 = $spreadsheet->createSheet(); + $cells = [ + ['a1', 'b1', 'c1'], + ['a2', 'b2', 'c2'], + ]; + $sheet1->fromArray($cells); + $sheet2->fromArray($cells); + $sheet3->fromArray($cells); + $writer = new Html($spreadsheet); + $writer->writeAllSheets(); + $html = $writer->generateHTMLall(); + $rtlCount = substr_count($html, "dir='rtl'"); + self::assertSame(0, $rtlCount); + $ltrCount = substr_count($html, "dir='ltr'"); + self::assertSame(0, $ltrCount); + $spreadsheet->disconnectWorksheets(); + } + + public function testOnlyRtl(): void + { + $spreadsheet = new Spreadsheet(); + $sheet1 = $spreadsheet->getActiveSheet(); + $sheet2 = $spreadsheet->createSheet(); + $sheet3 = $spreadsheet->createSheet(); + $cells = [ + ['a1', 'b1', 'c1'], + ['a2', 'b2', 'c2'], + ]; + $sheet1->fromArray($cells); + $sheet1->setRightToLeft(true); + $sheet2->fromArray($cells); + $sheet2->setRightToLeft(true); + $sheet3->fromArray($cells); + $sheet3->setRightToLeft(true); + $writer = new Html($spreadsheet); + $writer->writeAllSheets(); + $html = $writer->generateHTMLall(); + $rtlCount = substr_count($html, "dir='rtl'"); + self::assertSame(4, $rtlCount, '3 sheets plus html tag'); + $ltrCount = substr_count($html, "dir='ltr'"); + self::assertSame(0, $ltrCount); + $spreadsheet->disconnectWorksheets(); + } +} From 11dc1fb67ee179f3a7e488eb9c45cdb709cf5b81 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Mon, 14 Jul 2025 23:20:41 -0700 Subject: [PATCH 20/30] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8534ef6..0696724f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Fixed -- Nothing yet. +- Ods Reader Nested table-row. [Issue #4528](https://github.com/PHPOffice/PhpSpreadsheet/issues/4528) [Issue #2507](https://github.com/PHPOffice/PhpSpreadsheet/issues/2507) [PR #4531](https://github.com/PHPOffice/PhpSpreadsheet/pull/4531) ## 2025-06-22 - 4.4.0 From 8fb10a665fd2359668d76002292cf5904c8809ba Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Mon, 14 Jul 2025 23:38:32 -0700 Subject: [PATCH 21/30] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8534ef6..d5e64bfc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Added -- Nothing yet. +- Address Excel Inappropriate Number Format Substitution. [PR #4532](https://github.com/PHPOffice/PhpSpreadsheet/pull/4532) ### Removed From 250394d1282ea0e2a026440ca7c5fe105ea9107e Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Tue, 15 Jul 2025 00:26:20 -0700 Subject: [PATCH 22/30] Preserve 0x0a In Strings If Desired Fix #347, which had gone stale but is now reopened. PhpSpreadsheet is automatically replacing CR-LF and CR with LF. The reason for this is unknown, although I suggest a possibility in the issue. I am not willing to make this a breaking change. This PR adds a new property `preserveCr` with setter and getter to DefaultValueBinder (and, by extension, to StringValueBinder and AdvancedValueBinder). The property defaults to false, but users who wish to preserve CR-LF and CR can set it to true for a Spreadsheet or a Reader. --- src/PhpSpreadsheet/Cell/Cell.php | 9 +- src/PhpSpreadsheet/Cell/DataType.php | 6 +- .../Cell/DefaultValueBinder.php | 14 + .../Reader/Xls/LoadSpreadsheet.php | 2 +- src/PhpSpreadsheet/Shared/StringHelper.php | 439 +++++++++--------- .../Shared/Issue347Test.php | 60 +++ 6 files changed, 317 insertions(+), 213 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Shared/Issue347Test.php diff --git a/src/PhpSpreadsheet/Cell/Cell.php b/src/PhpSpreadsheet/Cell/Cell.php index b53d1d1e0..8cf71f9d9 100644 --- a/src/PhpSpreadsheet/Cell/Cell.php +++ b/src/PhpSpreadsheet/Cell/Cell.php @@ -278,7 +278,14 @@ class Cell implements Stringable case DataType::TYPE_INLINE: // Rich text $value2 = StringHelper::convertToString($value, true); - $this->value = DataType::checkString(($value instanceof RichText) ? $value : $value2); + // Cells?->Worksheet?->Spreadsheet + $binder = $this->parent?->getParent()?->getParent()?->getValueBinder(); + $preserveCr = false; + if ($binder !== null && method_exists($binder, 'getPreserveCr')) { + /** @var bool */ + $preserveCr = $binder->getPreserveCr(); + } + $this->value = DataType::checkString(($value instanceof RichText) ? $value : $value2, $preserveCr); break; case DataType::TYPE_NUMERIC: diff --git a/src/PhpSpreadsheet/Cell/DataType.php b/src/PhpSpreadsheet/Cell/DataType.php index 39969d37a..774a57b23 100644 --- a/src/PhpSpreadsheet/Cell/DataType.php +++ b/src/PhpSpreadsheet/Cell/DataType.php @@ -53,7 +53,7 @@ class DataType * * @return RichText|string Sanitized value */ - public static function checkString(null|RichText|string $textValue): RichText|string + public static function checkString(null|RichText|string $textValue, bool $preserveCr = false): RichText|string { if ($textValue instanceof RichText) { // TODO: Sanitize Rich-Text string (max. character count is 32,767) @@ -64,7 +64,9 @@ class DataType $textValue = StringHelper::substring((string) $textValue, 0, self::MAX_STRING_LENGTH); // we require that newline is represented as "\n" in core, not as "\r\n" or "\r" - $textValue = str_replace(["\r\n", "\r"], "\n", $textValue); + if (!$preserveCr) { + $textValue = str_replace(["\r\n", "\r"], "\n", $textValue); + } return $textValue; } diff --git a/src/PhpSpreadsheet/Cell/DefaultValueBinder.php b/src/PhpSpreadsheet/Cell/DefaultValueBinder.php index 10c5c93c5..0d7f286f7 100644 --- a/src/PhpSpreadsheet/Cell/DefaultValueBinder.php +++ b/src/PhpSpreadsheet/Cell/DefaultValueBinder.php @@ -108,4 +108,18 @@ class DefaultValueBinder implements IValueBinder return DataType::TYPE_STRING; } + + protected bool $preserveCr = false; + + public function getPreserveCr(): bool + { + return $this->preserveCr; + } + + public function setPreserveCr(bool $preserveCr): self + { + $this->preserveCr = $preserveCr; + + return $this; + } } diff --git a/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php b/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php index 60fc5a125..fb28f398f 100644 --- a/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php +++ b/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php @@ -27,7 +27,7 @@ class LoadSpreadsheet extends Xls // Initialisations $xls->spreadsheet = $this->newSpreadsheet(); - $xls->spreadsheet->setValueBinder($this->valueBinder); + $xls->spreadsheet->setValueBinder($xls->valueBinder); $xls->spreadsheet->removeSheetByIndex(0); // remove 1st sheet if (!$xls->readDataOnly) { $xls->spreadsheet->removeCellStyleXfByIndex(0); // remove the default style diff --git a/src/PhpSpreadsheet/Shared/StringHelper.php b/src/PhpSpreadsheet/Shared/StringHelper.php index 045c7b472..323aff408 100644 --- a/src/PhpSpreadsheet/Shared/StringHelper.php +++ b/src/PhpSpreadsheet/Shared/StringHelper.php @@ -9,19 +9,236 @@ use Stringable; class StringHelper { - /** - * Control characters array. - * - * @var string[] - */ - private static array $controlCharacters = []; + private const CONTROL_CHARACTERS_KEYS = [ + "\x00", + "\x01", + "\x02", + "\x03", + "\x04", + "\x05", + "\x06", + "\x07", + "\x08", + "\x09", + "\x0a", + "\x0b", + "\x0c", + "\x0d", + "\x0e", + "\x0f", + "\x10", + "\x11", + "\x12", + "\x13", + "\x14", + "\x15", + "\x16", + "\x17", + "\x18", + "\x19", + "\x1a", + "\x1b", + "\x1c", + "\x1d", + "\x1e", + "\x1f", + ]; + private const CONTROL_CHARACTERS_VALUES = [ + '_x0000_', + '_x0001_', + '_x0002_', + '_x0003_', + '_x0004_', + '_x0005_', + '_x0006_', + '_x0007_', + '_x0008_', + '_x0009_', + '_x000A_', + '_x000B_', + '_x000C_', + '_x000D_', + '_x000E_', + '_x000F_', + '_x0010_', + '_x0011_', + '_x0012_', + '_x0013_', + '_x0014_', + '_x0015_', + '_x0016_', + '_x0017_', + '_x0018_', + '_x0019_', + '_x001A_', + '_x001B_', + '_x001C_', + '_x001D_', + '_x001E_', + '_x001F_', + ]; /** * SYLK Characters array. - * - * @var string[] */ - private static array $SYLKCharacters = []; + private const SYLK_CHARACTERS = [ + "\x1B 0" => "\x00", + "\x1B 1" => "\x01", + "\x1B 2" => "\x02", + "\x1B 3" => "\x03", + "\x1B 4" => "\x04", + "\x1B 5" => "\x05", + "\x1B 6" => "\x06", + "\x1B 7" => "\x07", + "\x1B 8" => "\x08", + "\x1B 9" => "\x09", + "\x1B :" => "\x0a", + "\x1B ;" => "\x0b", + "\x1B <" => "\x0c", + "\x1B =" => "\x0d", + "\x1B >" => "\x0e", + "\x1B ?" => "\x0f", + "\x1B!0" => "\x10", + "\x1B!1" => "\x11", + "\x1B!2" => "\x12", + "\x1B!3" => "\x13", + "\x1B!4" => "\x14", + "\x1B!5" => "\x15", + "\x1B!6" => "\x16", + "\x1B!7" => "\x17", + "\x1B!8" => "\x18", + "\x1B!9" => "\x19", + "\x1B!:" => "\x1a", + "\x1B!;" => "\x1b", + "\x1B!<" => "\x1c", + "\x1B!=" => "\x1d", + "\x1B!>" => "\x1e", + "\x1B!?" => "\x1f", + "\x1B'?" => "\x7f", + "\x1B(0" => '€', // 128 in CP1252 + "\x1B(2" => '‚', // 130 in CP1252 + "\x1B(3" => 'ƒ', // 131 in CP1252 + "\x1B(4" => '„', // 132 in CP1252 + "\x1B(5" => '…', // 133 in CP1252 + "\x1B(6" => '†', // 134 in CP1252 + "\x1B(7" => '‡', // 135 in CP1252 + "\x1B(8" => 'ˆ', // 136 in CP1252 + "\x1B(9" => '‰', // 137 in CP1252 + "\x1B(:" => 'Š', // 138 in CP1252 + "\x1B(;" => '‹', // 139 in CP1252 + "\x1BNj" => 'Œ', // 140 in CP1252 + "\x1B(>" => 'Ž', // 142 in CP1252 + "\x1B)1" => '‘', // 145 in CP1252 + "\x1B)2" => '’', // 146 in CP1252 + "\x1B)3" => '“', // 147 in CP1252 + "\x1B)4" => '”', // 148 in CP1252 + "\x1B)5" => '•', // 149 in CP1252 + "\x1B)6" => '–', // 150 in CP1252 + "\x1B)7" => '—', // 151 in CP1252 + "\x1B)8" => '˜', // 152 in CP1252 + "\x1B)9" => '™', // 153 in CP1252 + "\x1B):" => 'š', // 154 in CP1252 + "\x1B);" => '›', // 155 in CP1252 + "\x1BNz" => 'œ', // 156 in CP1252 + "\x1B)>" => 'ž', // 158 in CP1252 + "\x1B)?" => 'Ÿ', // 159 in CP1252 + "\x1B*0" => ' ', // 160 in CP1252 + "\x1BN!" => '¡', // 161 in CP1252 + "\x1BN\"" => '¢', // 162 in CP1252 + "\x1BN#" => '£', // 163 in CP1252 + "\x1BN(" => '¤', // 164 in CP1252 + "\x1BN%" => '¥', // 165 in CP1252 + "\x1B*6" => '¦', // 166 in CP1252 + "\x1BN'" => '§', // 167 in CP1252 + "\x1BNH " => '¨', // 168 in CP1252 + "\x1BNS" => '©', // 169 in CP1252 + "\x1BNc" => 'ª', // 170 in CP1252 + "\x1BN+" => '«', // 171 in CP1252 + "\x1B*<" => '¬', // 172 in CP1252 + "\x1B*=" => '­', // 173 in CP1252 + "\x1BNR" => '®', // 174 in CP1252 + "\x1B*?" => '¯', // 175 in CP1252 + "\x1BN0" => '°', // 176 in CP1252 + "\x1BN1" => '±', // 177 in CP1252 + "\x1BN2" => '²', // 178 in CP1252 + "\x1BN3" => '³', // 179 in CP1252 + "\x1BNB " => '´', // 180 in CP1252 + "\x1BN5" => 'µ', // 181 in CP1252 + "\x1BN6" => '¶', // 182 in CP1252 + "\x1BN7" => '·', // 183 in CP1252 + "\x1B+8" => '¸', // 184 in CP1252 + "\x1BNQ" => '¹', // 185 in CP1252 + "\x1BNk" => 'º', // 186 in CP1252 + "\x1BN;" => '»', // 187 in CP1252 + "\x1BN<" => '¼', // 188 in CP1252 + "\x1BN=" => '½', // 189 in CP1252 + "\x1BN>" => '¾', // 190 in CP1252 + "\x1BN?" => '¿', // 191 in CP1252 + "\x1BNAA" => 'À', // 192 in CP1252 + "\x1BNBA" => 'Á', // 193 in CP1252 + "\x1BNCA" => 'Â', // 194 in CP1252 + "\x1BNDA" => 'Ã', // 195 in CP1252 + "\x1BNHA" => 'Ä', // 196 in CP1252 + "\x1BNJA" => 'Å', // 197 in CP1252 + "\x1BNa" => 'Æ', // 198 in CP1252 + "\x1BNKC" => 'Ç', // 199 in CP1252 + "\x1BNAE" => 'È', // 200 in CP1252 + "\x1BNBE" => 'É', // 201 in CP1252 + "\x1BNCE" => 'Ê', // 202 in CP1252 + "\x1BNHE" => 'Ë', // 203 in CP1252 + "\x1BNAI" => 'Ì', // 204 in CP1252 + "\x1BNBI" => 'Í', // 205 in CP1252 + "\x1BNCI" => 'Î', // 206 in CP1252 + "\x1BNHI" => 'Ï', // 207 in CP1252 + "\x1BNb" => 'Ð', // 208 in CP1252 + "\x1BNDN" => 'Ñ', // 209 in CP1252 + "\x1BNAO" => 'Ò', // 210 in CP1252 + "\x1BNBO" => 'Ó', // 211 in CP1252 + "\x1BNCO" => 'Ô', // 212 in CP1252 + "\x1BNDO" => 'Õ', // 213 in CP1252 + "\x1BNHO" => 'Ö', // 214 in CP1252 + "\x1B-7" => '×', // 215 in CP1252 + "\x1BNi" => 'Ø', // 216 in CP1252 + "\x1BNAU" => 'Ù', // 217 in CP1252 + "\x1BNBU" => 'Ú', // 218 in CP1252 + "\x1BNCU" => 'Û', // 219 in CP1252 + "\x1BNHU" => 'Ü', // 220 in CP1252 + "\x1B-=" => 'Ý', // 221 in CP1252 + "\x1BNl" => 'Þ', // 222 in CP1252 + "\x1BN{" => 'ß', // 223 in CP1252 + "\x1BNAa" => 'à', // 224 in CP1252 + "\x1BNBa" => 'á', // 225 in CP1252 + "\x1BNCa" => 'â', // 226 in CP1252 + "\x1BNDa" => 'ã', // 227 in CP1252 + "\x1BNHa" => 'ä', // 228 in CP1252 + "\x1BNJa" => 'å', // 229 in CP1252 + "\x1BNq" => 'æ', // 230 in CP1252 + "\x1BNKc" => 'ç', // 231 in CP1252 + "\x1BNAe" => 'è', // 232 in CP1252 + "\x1BNBe" => 'é', // 233 in CP1252 + "\x1BNCe" => 'ê', // 234 in CP1252 + "\x1BNHe" => 'ë', // 235 in CP1252 + "\x1BNAi" => 'ì', // 236 in CP1252 + "\x1BNBi" => 'í', // 237 in CP1252 + "\x1BNCi" => 'î', // 238 in CP1252 + "\x1BNHi" => 'ï', // 239 in CP1252 + "\x1BNs" => 'ð', // 240 in CP1252 + "\x1BNDn" => 'ñ', // 241 in CP1252 + "\x1BNAo" => 'ò', // 242 in CP1252 + "\x1BNBo" => 'ó', // 243 in CP1252 + "\x1BNCo" => 'ô', // 244 in CP1252 + "\x1BNDo" => 'õ', // 245 in CP1252 + "\x1BNHo" => 'ö', // 246 in CP1252 + "\x1B/7" => '÷', // 247 in CP1252 + "\x1BNy" => 'ø', // 248 in CP1252 + "\x1BNAu" => 'ù', // 249 in CP1252 + "\x1BNBu" => 'ú', // 250 in CP1252 + "\x1BNCu" => 'û', // 251 in CP1252 + "\x1BNHu" => 'ü', // 252 in CP1252 + "\x1B/=" => 'ý', // 253 in CP1252 + "\x1BN|" => 'þ', // 254 in CP1252 + "\x1BNHy" => 'ÿ', // 255 in CP1252 + ]; /** * Decimal separator. @@ -48,185 +265,6 @@ class StringHelper */ private static string $iconvOptions = '//IGNORE//TRANSLIT'; - /** - * Build control characters array. - */ - private static function buildControlCharacters(): void - { - for ($i = 0; $i <= 31; ++$i) { - if ($i != 9 && $i != 10 && $i != 13) { - $find = '_x' . sprintf('%04s', strtoupper(dechex($i))) . '_'; - $replace = chr($i); - self::$controlCharacters[$find] = $replace; - } - } - } - - /** - * Build SYLK characters array. - */ - private static function buildSYLKCharacters(): void - { - self::$SYLKCharacters = [ - "\x1B 0" => chr(0), - "\x1B 1" => chr(1), - "\x1B 2" => chr(2), - "\x1B 3" => chr(3), - "\x1B 4" => chr(4), - "\x1B 5" => chr(5), - "\x1B 6" => chr(6), - "\x1B 7" => chr(7), - "\x1B 8" => chr(8), - "\x1B 9" => chr(9), - "\x1B :" => chr(10), - "\x1B ;" => chr(11), - "\x1B <" => chr(12), - "\x1B =" => chr(13), - "\x1B >" => chr(14), - "\x1B ?" => chr(15), - "\x1B!0" => chr(16), - "\x1B!1" => chr(17), - "\x1B!2" => chr(18), - "\x1B!3" => chr(19), - "\x1B!4" => chr(20), - "\x1B!5" => chr(21), - "\x1B!6" => chr(22), - "\x1B!7" => chr(23), - "\x1B!8" => chr(24), - "\x1B!9" => chr(25), - "\x1B!:" => chr(26), - "\x1B!;" => chr(27), - "\x1B!<" => chr(28), - "\x1B!=" => chr(29), - "\x1B!>" => chr(30), - "\x1B!?" => chr(31), - "\x1B'?" => chr(127), - "\x1B(0" => '€', // 128 in CP1252 - "\x1B(2" => '‚', // 130 in CP1252 - "\x1B(3" => 'ƒ', // 131 in CP1252 - "\x1B(4" => '„', // 132 in CP1252 - "\x1B(5" => '…', // 133 in CP1252 - "\x1B(6" => '†', // 134 in CP1252 - "\x1B(7" => '‡', // 135 in CP1252 - "\x1B(8" => 'ˆ', // 136 in CP1252 - "\x1B(9" => '‰', // 137 in CP1252 - "\x1B(:" => 'Š', // 138 in CP1252 - "\x1B(;" => '‹', // 139 in CP1252 - "\x1BNj" => 'Œ', // 140 in CP1252 - "\x1B(>" => 'Ž', // 142 in CP1252 - "\x1B)1" => '‘', // 145 in CP1252 - "\x1B)2" => '’', // 146 in CP1252 - "\x1B)3" => '“', // 147 in CP1252 - "\x1B)4" => '”', // 148 in CP1252 - "\x1B)5" => '•', // 149 in CP1252 - "\x1B)6" => '–', // 150 in CP1252 - "\x1B)7" => '—', // 151 in CP1252 - "\x1B)8" => '˜', // 152 in CP1252 - "\x1B)9" => '™', // 153 in CP1252 - "\x1B):" => 'š', // 154 in CP1252 - "\x1B);" => '›', // 155 in CP1252 - "\x1BNz" => 'œ', // 156 in CP1252 - "\x1B)>" => 'ž', // 158 in CP1252 - "\x1B)?" => 'Ÿ', // 159 in CP1252 - "\x1B*0" => ' ', // 160 in CP1252 - "\x1BN!" => '¡', // 161 in CP1252 - "\x1BN\"" => '¢', // 162 in CP1252 - "\x1BN#" => '£', // 163 in CP1252 - "\x1BN(" => '¤', // 164 in CP1252 - "\x1BN%" => '¥', // 165 in CP1252 - "\x1B*6" => '¦', // 166 in CP1252 - "\x1BN'" => '§', // 167 in CP1252 - "\x1BNH " => '¨', // 168 in CP1252 - "\x1BNS" => '©', // 169 in CP1252 - "\x1BNc" => 'ª', // 170 in CP1252 - "\x1BN+" => '«', // 171 in CP1252 - "\x1B*<" => '¬', // 172 in CP1252 - "\x1B*=" => '­', // 173 in CP1252 - "\x1BNR" => '®', // 174 in CP1252 - "\x1B*?" => '¯', // 175 in CP1252 - "\x1BN0" => '°', // 176 in CP1252 - "\x1BN1" => '±', // 177 in CP1252 - "\x1BN2" => '²', // 178 in CP1252 - "\x1BN3" => '³', // 179 in CP1252 - "\x1BNB " => '´', // 180 in CP1252 - "\x1BN5" => 'µ', // 181 in CP1252 - "\x1BN6" => '¶', // 182 in CP1252 - "\x1BN7" => '·', // 183 in CP1252 - "\x1B+8" => '¸', // 184 in CP1252 - "\x1BNQ" => '¹', // 185 in CP1252 - "\x1BNk" => 'º', // 186 in CP1252 - "\x1BN;" => '»', // 187 in CP1252 - "\x1BN<" => '¼', // 188 in CP1252 - "\x1BN=" => '½', // 189 in CP1252 - "\x1BN>" => '¾', // 190 in CP1252 - "\x1BN?" => '¿', // 191 in CP1252 - "\x1BNAA" => 'À', // 192 in CP1252 - "\x1BNBA" => 'Á', // 193 in CP1252 - "\x1BNCA" => 'Â', // 194 in CP1252 - "\x1BNDA" => 'Ã', // 195 in CP1252 - "\x1BNHA" => 'Ä', // 196 in CP1252 - "\x1BNJA" => 'Å', // 197 in CP1252 - "\x1BNa" => 'Æ', // 198 in CP1252 - "\x1BNKC" => 'Ç', // 199 in CP1252 - "\x1BNAE" => 'È', // 200 in CP1252 - "\x1BNBE" => 'É', // 201 in CP1252 - "\x1BNCE" => 'Ê', // 202 in CP1252 - "\x1BNHE" => 'Ë', // 203 in CP1252 - "\x1BNAI" => 'Ì', // 204 in CP1252 - "\x1BNBI" => 'Í', // 205 in CP1252 - "\x1BNCI" => 'Î', // 206 in CP1252 - "\x1BNHI" => 'Ï', // 207 in CP1252 - "\x1BNb" => 'Ð', // 208 in CP1252 - "\x1BNDN" => 'Ñ', // 209 in CP1252 - "\x1BNAO" => 'Ò', // 210 in CP1252 - "\x1BNBO" => 'Ó', // 211 in CP1252 - "\x1BNCO" => 'Ô', // 212 in CP1252 - "\x1BNDO" => 'Õ', // 213 in CP1252 - "\x1BNHO" => 'Ö', // 214 in CP1252 - "\x1B-7" => '×', // 215 in CP1252 - "\x1BNi" => 'Ø', // 216 in CP1252 - "\x1BNAU" => 'Ù', // 217 in CP1252 - "\x1BNBU" => 'Ú', // 218 in CP1252 - "\x1BNCU" => 'Û', // 219 in CP1252 - "\x1BNHU" => 'Ü', // 220 in CP1252 - "\x1B-=" => 'Ý', // 221 in CP1252 - "\x1BNl" => 'Þ', // 222 in CP1252 - "\x1BN{" => 'ß', // 223 in CP1252 - "\x1BNAa" => 'à', // 224 in CP1252 - "\x1BNBa" => 'á', // 225 in CP1252 - "\x1BNCa" => 'â', // 226 in CP1252 - "\x1BNDa" => 'ã', // 227 in CP1252 - "\x1BNHa" => 'ä', // 228 in CP1252 - "\x1BNJa" => 'å', // 229 in CP1252 - "\x1BNq" => 'æ', // 230 in CP1252 - "\x1BNKc" => 'ç', // 231 in CP1252 - "\x1BNAe" => 'è', // 232 in CP1252 - "\x1BNBe" => 'é', // 233 in CP1252 - "\x1BNCe" => 'ê', // 234 in CP1252 - "\x1BNHe" => 'ë', // 235 in CP1252 - "\x1BNAi" => 'ì', // 236 in CP1252 - "\x1BNBi" => 'í', // 237 in CP1252 - "\x1BNCi" => 'î', // 238 in CP1252 - "\x1BNHi" => 'ï', // 239 in CP1252 - "\x1BNs" => 'ð', // 240 in CP1252 - "\x1BNDn" => 'ñ', // 241 in CP1252 - "\x1BNAo" => 'ò', // 242 in CP1252 - "\x1BNBo" => 'ó', // 243 in CP1252 - "\x1BNCo" => 'ô', // 244 in CP1252 - "\x1BNDo" => 'õ', // 245 in CP1252 - "\x1BNHo" => 'ö', // 246 in CP1252 - "\x1B/7" => '÷', // 247 in CP1252 - "\x1BNy" => 'ø', // 248 in CP1252 - "\x1BNAu" => 'ù', // 249 in CP1252 - "\x1BNBu" => 'ú', // 250 in CP1252 - "\x1BNCu" => 'û', // 251 in CP1252 - "\x1BNHu" => 'ü', // 252 in CP1252 - "\x1B/=" => 'ý', // 253 in CP1252 - "\x1BN|" => 'þ', // 254 in CP1252 - "\x1BNHy" => 'ÿ', // 255 in CP1252 - ]; - } - /** * Get whether iconv extension is available. */ @@ -258,17 +296,6 @@ class StringHelper return self::$isIconvEnabled; } - private static function buildCharacterSets(): void - { - if (empty(self::$controlCharacters)) { - self::buildControlCharacters(); - } - - if (empty(self::$SYLKCharacters)) { - self::buildSYLKCharacters(); - } - } - /** * Convert from OpenXML escaped control character to PHP control character. * @@ -284,9 +311,7 @@ class StringHelper */ public static function controlCharacterOOXML2PHP(string $textValue): string { - self::buildCharacterSets(); - - return str_replace(array_keys(self::$controlCharacters), array_values(self::$controlCharacters), $textValue); + return str_replace(self::CONTROL_CHARACTERS_VALUES, self::CONTROL_CHARACTERS_KEYS, $textValue); } /** @@ -304,9 +329,7 @@ class StringHelper */ public static function controlCharacterPHP2OOXML(string $textValue): string { - self::buildCharacterSets(); - - return str_replace(array_values(self::$controlCharacters), array_keys(self::$controlCharacters), $textValue); + return str_replace(self::CONTROL_CHARACTERS_KEYS, self::CONTROL_CHARACTERS_VALUES, $textValue); } /** @@ -619,14 +642,12 @@ class StringHelper */ public static function SYLKtoUTF8(string $textValue): string { - self::buildCharacterSets(); - // If there is no escape character in the string there is nothing to do - if (!str_contains($textValue, '')) { + if (!str_contains($textValue, "\x1b")) { return $textValue; } - foreach (self::$SYLKCharacters as $k => $v) { + foreach (self::SYLK_CHARACTERS as $k => $v) { $textValue = str_replace($k, $v, $textValue); } diff --git a/tests/PhpSpreadsheetTests/Shared/Issue347Test.php b/tests/PhpSpreadsheetTests/Shared/Issue347Test.php new file mode 100644 index 000000000..c4e7707b6 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Shared/Issue347Test.php @@ -0,0 +1,60 @@ +setPreserveCr(true); + $reader->setValueBinder($binder); + } + + #[DataProvider('providerType')] + public function testPreserveCr(string $format): void + { + $s1 = "AB\r\nC\tD"; + $spreadsheet = new Spreadsheet(); + $binder = new DefaultValueBinder(); + $binder->setPreserveCr(true); + $spreadsheet->setValueBinder($binder); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->getCell('A1')->setValue($s1); + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, $format, readerCustomizer: $this->readerPreserveCr(...)); + $spreadsheet->disconnectWorksheets(); + $rsheet = $reloadedSpreadsheet->getActiveSheet(); + $s2 = $rsheet->getCell('A1')->getValue(); + self::assertSame($s1, $s2); + $reloadedSpreadsheet->disconnectWorksheets(); + } + + #[DataProvider('providerType')] + public function testNoPreserveCr(string $format): void + { + $s1 = "AB\r\nC\tD"; + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->getCell('A1')->setValue($s1); + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, $format); + $spreadsheet->disconnectWorksheets(); + $rsheet = $reloadedSpreadsheet->getActiveSheet(); + $s2 = $rsheet->getCell('A1')->getValue(); + self::assertNotEquals($s1, $s2); + self::assertSame("AB\nC\tD", $s2); + $reloadedSpreadsheet->disconnectWorksheets(); + } + + public static function providerType(): array + { + return [['Xls'], ['Xlsx'], ['Ods']]; + } +} From b82756de7649e6146cd519e0759301fde9a3c8c3 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:53:43 -0700 Subject: [PATCH 23/30] Don't Use htmlspecialchars When Formatting Xml Fix #4537. PhpSpreadsheet currently changes apostrophes in text values to `'`. This is perfectly valid Xml. Issue was opened because R does not handle this correctly; this is unquestionably a bug on R's part. So I was not inclined to do anything about it. However ... User suggested a change to how `htmlspecialchars` was called. Investigating the use of that routine in PhpSpreadsheet, I found that there was some double escaping going on for cells whose type was set to `TYPE_INLINE` - `htmlspecialchars` escaped the string correctly, but it was later written as Xml using a method which escaped the data a second time. So, a real bug in PhpSpreadsheet after all. There was one call to `htmlspecialchars` in `Shared\XmlWriter`. I replaced `writeRaw(htmlspecialchars(...))` with `text(...)`. And one call in `Writer\Xlsx\Worksheet`, the source of the double escaping bug above; the call to `htmlspecialchars` can just be eliminated there. Making those changes, the only remaining calls to `htmlspecialchars` are in `Writer\Html`, where they belong. As a bonus, apostrophes now wind up unescaped, so R will be satisfied (even though they should fix their bug). --- src/PhpSpreadsheet/Shared/XMLWriter.php | 2 +- src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 6 +- .../Writer/Ods/Issue4537Test.php | 64 ++++++++++++++++ .../Writer/Xlsx/Issue4537Test.php | 76 +++++++++++++++++++ 4 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Writer/Ods/Issue4537Test.php create mode 100644 tests/PhpSpreadsheetTests/Writer/Xlsx/Issue4537Test.php diff --git a/src/PhpSpreadsheet/Shared/XMLWriter.php b/src/PhpSpreadsheet/Shared/XMLWriter.php index 2703e98e9..f1800066e 100644 --- a/src/PhpSpreadsheet/Shared/XMLWriter.php +++ b/src/PhpSpreadsheet/Shared/XMLWriter.php @@ -91,6 +91,6 @@ class XMLWriter extends \XMLWriter $rawTextData = implode("\n", $rawTextData); } - return $this->writeRaw(htmlspecialchars($rawTextData ?? '')); + return $this->text($rawTextData ?? ''); } } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index 6437e2c19..9bb4f68f0 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -10,7 +10,6 @@ use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Namespaces; use PhpOffice\PhpSpreadsheet\RichText\RichText; -use PhpOffice\PhpSpreadsheet\Settings; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use PhpOffice\PhpSpreadsheet\Shared\XMLWriter; use PhpOffice\PhpSpreadsheet\Style\Conditional; @@ -1440,10 +1439,7 @@ class Worksheet extends WriterPart $objWriter->writeElement( 't', StringHelper::controlCharacterPHP2OOXML( - htmlspecialchars( - $cellValue, - Settings::htmlEntityFlags() - ) + $cellValue ) ); $objWriter->endElement(); diff --git a/tests/PhpSpreadsheetTests/Writer/Ods/Issue4537Test.php b/tests/PhpSpreadsheetTests/Writer/Ods/Issue4537Test.php new file mode 100644 index 000000000..e4a24707d --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Ods/Issue4537Test.php @@ -0,0 +1,64 @@ +outputFilename !== '') { + unlink($this->outputFilename); + $this->outputFilename = ''; + } + } + + public function testBackgroundImage(): void + { + $this->outputFilename = File::temporaryFilename(); + $testString = "\"He\": ''"; + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->getCell('A1')->setValueExplicit($testString, DataType::TYPE_INLINE); + $sheet->getCell('A2')->setValue($testString); + $richText = new RichText(); + $richText->addText(new TextElement($testString)); + $sheet->getCell('A3')->setValue($richText); + $writer = new OdsWriter($spreadsheet); + $writer->save($this->outputFilename); + $spreadsheet->disconnectWorksheets(); + + $reader = new OdsReader(); + $reloadedSpreadsheet = $reader->load($this->outputFilename); + $rsheet = $reloadedSpreadsheet->getActiveSheet(); + self::assertSame($testString, $rsheet->getCell('A1')->getValueString()); + self::assertSame($testString, $rsheet->getCell('A2')->getValueString()); + self::assertSame($testString, $rsheet->getCell('A3')->getValueString()); + $reloadedSpreadsheet->disconnectWorksheets(); + + $file = 'zip://'; + $file .= $this->outputFilename; + $file .= '#content.xml'; + $data = file_get_contents($file); + // expected does not escape apostrophes + $expected = '"He": \'<?>\''; + if ($data === false) { + self::fail('Unable to read content file'); + } else { + $count = substr_count($data, $expected); + self::assertSame(3, $count); + } + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Issue4537Test.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Issue4537Test.php new file mode 100644 index 000000000..7cbd86bb4 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Issue4537Test.php @@ -0,0 +1,76 @@ +outputFilename !== '') { + unlink($this->outputFilename); + $this->outputFilename = ''; + } + } + + public function testBackgroundImage(): void + { + $this->outputFilename = File::temporaryFilename(); + $testString = "\"He\": ''"; + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->getCell('A1')->setValueExplicit($testString, DataType::TYPE_INLINE); + $sheet->getCell('A2')->setValue($testString); + $richText = new RichText(); + $richText->addText(new TextElement($testString)); + $sheet->getCell('A3')->setValue($richText); + $writer = new XlsxWriter($spreadsheet); + $writer->save($this->outputFilename); + $spreadsheet->disconnectWorksheets(); + + $reader = new XlsxReader(); + $reloadedSpreadsheet = $reader->load($this->outputFilename); + $rsheet = $reloadedSpreadsheet->getActiveSheet(); + self::assertSame($testString, $rsheet->getCell('A1')->getValueString()); + self::assertSame($testString, $rsheet->getCell('A2')->getValueString()); + self::assertSame($testString, $rsheet->getCell('A3')->getValueString()); + $reloadedSpreadsheet->disconnectWorksheets(); + + $file = 'zip://'; + $file .= $this->outputFilename; + $file .= '#xl/worksheets/sheet1.xml'; + $data = file_get_contents($file); + // expected, and expected1/2 below, do not escape apostrophes + $expected = 't="inlineStr">"He": \'<?>\''; + if ($data === false) { + self::fail('Unable to read worksheets file'); + } else { + self::assertStringContainsString($expected, $data, 'inline string'); + } + + $file = 'zip://'; + $file .= $this->outputFilename; + $file .= '#xl/sharedStrings.xml'; + $data = file_get_contents($file); + $expected1 = '"He": \'<?>\''; + $expected2 = '"He": \'<?>\''; + if ($data === false) { + self::fail('Unable to read sharedStrings file'); + } else { + self::assertStringContainsString($expected1, $data, 'string'); + self::assertStringContainsString($expected2, $data, 'rich text'); + } + } +} From 5ca9c5a44e061869a3e0f8d45daffacebcbe8024 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 17 Jul 2025 21:29:26 -0700 Subject: [PATCH 24/30] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8534ef6..c50e622d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Fixed -- Nothing yet. +- Xlsx Reader use dynamic arrays if spreadsheet did so. [PR #4533](https://github.com/PHPOffice/PhpSpreadsheet/pull/4533) ## 2025-06-22 - 4.4.0 From 09f6a1df9f444bb49ac200c4240307c86155ef3c Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Thu, 17 Jul 2025 21:43:45 -0700 Subject: [PATCH 25/30] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8534ef6..47f472d8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Fixed -- Nothing yet. +- Writer Html/Pdf support RTL alignment of tables. [Issue #1104](https://github.com/PHPOffice/PhpSpreadsheet/issues/1104) [PR #4535](https://github.com/PHPOffice/PhpSpreadsheet/pull/4535) ## 2025-06-22 - 4.4.0 From f1477b130fe1548333b6d99f2b593cf201aa0b71 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Fri, 18 Jul 2025 22:01:59 -0700 Subject: [PATCH 26/30] Allow Replace of Dummy Function with Custom Function Custom functions were introduced recently, with a restriction that non-custom functions could not be replaced. However, reviewing a recent issue has led me to the conclusion that it might sometimes be impractical to implement an Excel function in PhpSpreadsheet for all users, but it could still be helpful to some users to offer an implementation anyhow. The Excel ASC function is currently unimplemented in PhpSpreadsheet, and is a candidate for replacement using the functionality added in this PR. This PR supersedes PR #4513, which I will now close, and its earlier incarnation PR #4511. For reasons discussed in 4513, I don't see a way forward for implementing the Excel ASC function in a way that would be generally usable. However, this PR would permit a user to implement ASC in a way which would satisfy the user's local requirements. See the new `testReplaceDummyFunction`; I think, or at least hope, that, despite the deficiencies of 4511 and 4513, this might satisfy the author's requirement. --- .../Calculation/CalculationBase.php | 19 +++++++++++++++- .../Calculation/CustomFunction.php | 22 +++++++++++++++++++ .../Calculation/CustomFunctionTest.php | 15 +++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/PhpSpreadsheet/Calculation/CalculationBase.php b/src/PhpSpreadsheet/Calculation/CalculationBase.php index 9dc67fbc2..5bef04d7e 100644 --- a/src/PhpSpreadsheet/Calculation/CalculationBase.php +++ b/src/PhpSpreadsheet/Calculation/CalculationBase.php @@ -30,7 +30,10 @@ class CalculationBase public static function addFunction(string $key, array $value): bool { $key = strtoupper($key); - if (array_key_exists($key, FunctionArray::$phpSpreadsheetFunctions)) { + if ( + array_key_exists($key, FunctionArray::$phpSpreadsheetFunctions) + && !self::isDummy($key) + ) { return false; } $value['custom'] = true; @@ -39,6 +42,20 @@ class CalculationBase return true; } + private static function isDummy(string $key): bool + { + // key is already known to exist + $functionCall = FunctionArray::$phpSpreadsheetFunctions[$key]['functionCall'] ?? null; + if (!is_array($functionCall)) { + return false; + } + if (($functionCall[1] ?? '') !== 'DUMMY') { + return false; + } + + return true; + } + public static function removeFunction(string $key): bool { $key = strtoupper($key); diff --git a/tests/PhpSpreadsheetTests/Calculation/CustomFunction.php b/tests/PhpSpreadsheetTests/Calculation/CustomFunction.php index 1ce5cf5d2..7e0fea0a7 100644 --- a/tests/PhpSpreadsheetTests/Calculation/CustomFunction.php +++ b/tests/PhpSpreadsheetTests/Calculation/CustomFunction.php @@ -6,6 +6,7 @@ namespace PhpOffice\PhpSpreadsheetTests\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalcException; use PhpOffice\PhpSpreadsheet\Calculation\MathTrig\Helpers; +use PhpOffice\PhpSpreadsheet\Shared\StringHelper; class CustomFunction { @@ -19,4 +20,25 @@ class CustomFunction return $number ** 4; } + + /** + * ASC. + * Converts full-width (double-byte) characters to half-width (single-byte) characters. + * There are many difficulties with implementing this into PhpSpreadsheet. + */ + public static function ASC(mixed $stringValue): string + { + /*if (is_array($stringValue)) { + return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $stringValue); + }*/ + $stringValue = StringHelper::convertToString($stringValue, convertBool: true); + if (function_exists('mb_convert_kana')) { + return mb_convert_kana($stringValue, 'a', 'UTF-8'); + } + // Fallback if mb_convert_kana is not available. + // PhpSpreadsheet heavily relies on mbstring, so this is more of a theoretical fallback. + // A comprehensive manual conversion is extensive. + + return $stringValue; + } } diff --git a/tests/PhpSpreadsheetTests/Calculation/CustomFunctionTest.php b/tests/PhpSpreadsheetTests/Calculation/CustomFunctionTest.php index 1c536fad4..03262dfc0 100644 --- a/tests/PhpSpreadsheetTests/Calculation/CustomFunctionTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/CustomFunctionTest.php @@ -31,4 +31,19 @@ class CustomFunctionTest extends TestCase self::assertSame('#NAME?', $calculation->calculateFormula('=FOURTHPOWER(3)')); self::assertFalse(Calculation::removeFunction('WHATEVER')); } + + public static function testReplaceDummyFunction(): void + { + $functions = Calculation::getFunctions(); + $key = 'ASC'; + $oldValue = $functions[$key] ?? null; + self::assertIsArray($oldValue); + $calculation = Calculation::getInstance(); + $value = $oldValue; + $value['functionCall'] = [CustomFunction::class, 'ASC']; + self::assertTrue(Calculation::addFunction($key, $value)); + self::assertSame('ABC', $calculation->calculateFormula('=ASC("ABC")')); + self::assertTrue(Calculation::removeFunction('ASC')); + self::assertTrue(Calculation::addFunction($key, $oldValue)); + } } From 8cbd7a201de56fe893174f82e75ccb6a23a98f5c Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Sat, 19 Jul 2025 16:36:50 -0700 Subject: [PATCH 27/30] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 467d3ee83..d248136cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). - Recognize application/x-empty mimetype. [Issue #4521](https://github.com/PHPOffice/PhpSpreadsheet/issues/4521) [PR #4524](https://github.com/PHPOffice/PhpSpreadsheet/pull/4524) - Micro-optimization in getSheetByName. [PR #4499](https://github.com/PHPOffice/PhpSpreadsheet/pull/4499) - Bug in resizeMatricesExtend. [Issue #4451](https://github.com/PHPOffice/PhpSpreadsheet/issues/4451) [PR #4474](https://github.com/PHPOffice/PhpSpreadsheet/pull/4474) +- Preserve 0x0a in Strings if Desired. [Issue #347](https://github.com/PHPOffice/PhpSpreadsheet/issues/347) [PR #4536](https://github.com/PHPOffice/PhpSpreadsheet/pull/4536) ## 2025-06-22 - 4.4.0 From 4e8bf60a288e06738040894af5b7caac099504b2 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Sun, 20 Jul 2025 18:02:36 -0700 Subject: [PATCH 28/30] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 467d3ee83..209ae0d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). - Recognize application/x-empty mimetype. [Issue #4521](https://github.com/PHPOffice/PhpSpreadsheet/issues/4521) [PR #4524](https://github.com/PHPOffice/PhpSpreadsheet/pull/4524) - Micro-optimization in getSheetByName. [PR #4499](https://github.com/PHPOffice/PhpSpreadsheet/pull/4499) - Bug in resizeMatricesExtend. [Issue #4451](https://github.com/PHPOffice/PhpSpreadsheet/issues/4451) [PR #4474](https://github.com/PHPOffice/PhpSpreadsheet/pull/4474) +- Allow Replace of Dummy Function with Custom Function. [PR #4544](https://github.com/PHPOffice/PhpSpreadsheet/pull/4544) ## 2025-06-22 - 4.4.0 From ba72dfe132d5252a0576c69e9813ac4a88eec53c Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Mon, 21 Jul 2025 20:51:12 -0700 Subject: [PATCH 29/30] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8534ef6..ecfdbd8cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Fixed -- Nothing yet. +- Don't use htmlspecialchars when formatting XML. [Issue #4537](https://github.com/PHPOffice/PhpSpreadsheet/issues/4537) [PR #4540](https://github.com/PHPOffice/PhpSpreadsheet/pull/4540) ## 2025-06-22 - 4.4.0 From e005fdb49ab4ad37f478c084a63e0bf5a7067cc1 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:09:19 -0700 Subject: [PATCH 30/30] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a69ed53a5..4d30c8034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Fixed -- Don't use htmlspecialchars when formatting XML. [Issue #4537](https://github.com/PHPOffice/PhpSpreadsheet/issues/4537) [PR #4540](https://github.com/PHPOffice/PhpSpreadsheet/pull/4540) +- Do not use htmlspecialchars when formatting XML. [Issue #4537](https://github.com/PHPOffice/PhpSpreadsheet/issues/4537) [PR #4540](https://github.com/PHPOffice/PhpSpreadsheet/pull/4540) - Writer Html/Pdf support RTL alignment of tables. [Issue #1104](https://github.com/PHPOffice/PhpSpreadsheet/issues/1104) [PR #4535](https://github.com/PHPOffice/PhpSpreadsheet/pull/4535) - Xlsx Reader use dynamic arrays if spreadsheet did so. [PR #4533](https://github.com/PHPOffice/PhpSpreadsheet/pull/4533) - Ods Reader Nested table-row. [Issue #4528](https://github.com/PHPOffice/PhpSpreadsheet/issues/4528) [Issue #2507](https://github.com/PHPOffice/PhpSpreadsheet/issues/2507) [PR #4531](https://github.com/PHPOffice/PhpSpreadsheet/pull/4531)