diff --git a/CHANGELOG.md b/CHANGELOG.md index dc17d852..f15e9d5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Changed -- Nothing +- Allow variant behaviour when merging cells [Issue #3065](https://github.com/PHPOffice/PhpSpreadsheet/issues/3065) + - Merge methods now allow an additional `$behaviour` argument. Permitted values are: + - Worksheet::MERGE_CELL_CONTENT_EMPTY - Empty the content of the hidden cells (the default behaviour) + - Worksheet::MERGE_CELL_CONTENT_HIDE - Keep the content of the hidden cells + - Worksheet::MERGE_CELL_CONTENT_MERGE - Move the content of the hidden cells into the first cell ### Deprecated diff --git a/docs/topics/images/12-01-MergeCells-Options-2.png b/docs/topics/images/12-01-MergeCells-Options-2.png new file mode 100644 index 00000000..5e745fc9 Binary files /dev/null and b/docs/topics/images/12-01-MergeCells-Options-2.png differ diff --git a/docs/topics/images/12-01-MergeCells-Options-3.png b/docs/topics/images/12-01-MergeCells-Options-3.png new file mode 100644 index 00000000..30ad346e Binary files /dev/null and b/docs/topics/images/12-01-MergeCells-Options-3.png differ diff --git a/docs/topics/images/12-01-MergeCells-Options.png b/docs/topics/images/12-01-MergeCells-Options.png new file mode 100644 index 00000000..34c8f5e3 Binary files /dev/null and b/docs/topics/images/12-01-MergeCells-Options.png differ diff --git a/docs/topics/recipes.md b/docs/topics/recipes.md index afa3dcc8..04a4e6aa 100644 --- a/docs/topics/recipes.md +++ b/docs/topics/recipes.md @@ -1332,22 +1332,72 @@ rows (default), or above. The following code adds the summary above: $spreadsheet->getActiveSheet()->setShowSummaryBelow(false); ``` -## Merge/unmerge cells +## Merge/Unmerge cells -If you have a big piece of data you want to display in a worksheet, you -can merge two or more cells together, to become one cell. This can be -done using the following code: +If you have a big piece of data you want to display in a worksheet, or a +heading that needs to span multiple sub-heading columns, you can merge +two or more cells together, to become one cell. This can be done using +the following code: ```php $spreadsheet->getActiveSheet()->mergeCells('A18:E22'); ``` -Removing a merge can be done using the unmergeCells method: +Removing a merge can be done using the `unmergeCells()` method: ```php $spreadsheet->getActiveSheet()->unmergeCells('A18:E22'); ``` +MS Excel itself doesn't yet offer the functionality to simply hide the merged cells, or to merge the content of cells into a single cell, but it is available in Open/Libre Office. + +### Merge with MERGE_CELL_CONTENT_EMPTY + +The default behaviour is to empty all cells except for the top-left corner cell in the merge range; and this is also the default behaviour for the `mergeCells()` method in PhpSpreadsheet. +When this behaviour is applied, those cell values will be set to null; and if they are subsequently Unmerged, they will be empty cells. + +Passing an extra flag value to the `mergeCells()` method in PhpSpreadsheet can change this behaviour. + +![12-01-MergeCells-Options.png](./images/12-01-MergeCells-Options.png) + +Possible flag values are: +- Worksheet::MERGE_CELL_CONTENT_EMPTY (the default) +- Worksheet::MERGE_CELL_CONTENT_HIDE +- Worksheet::MERGE_CELL_CONTENT_MERGE + +### Merge with MERGE_CELL_CONTENT_HIDE + +The first alternative, available only in OpenOffice, is to hide those cells, but to leave their content intact. +When a file saved as `Xlsx` in those applications is opened in MS Excel, and those cells are unmerged, the original content will still be present. + +```php +$spreadsheet->getActiveSheet()->mergeCells('A1:C3', Worksheet::MERGE_CELL_CONTENT_HIDE); +``` + +Will replicate that behaviour. + +### Merge with MERGE_CELL_CONTENT_MERGE + +The second alternative, available in both OpenOffice and LibreOffice is to merge the content of every cell in the merge range into the top-left cell, while setting those hidden cells to empty. + +```php +$spreadsheet->getActiveSheet()->mergeCells('A1:C3', Worksheet::MERGE_CELL_CONTENT_MERGE); +``` + +Particularly when the merged cells contain formulae, the logic for this merge seems strange: +walking through the merge range, each cell is calculated in turn, and appended to the "master" cell, then it is emptied, so any subsequent calculations that reference the cell see an empty cell, not the pre-merge value. +For example, suppose our spreadsheet contains + +![12-01-MergeCells-Options-2.png](./images/12-01-MergeCells-Options-2.png) + +where `B2` is the formula `=5-B1` and `C2` is the formula `=A2/B2`, +and we want to merge cells `A2` to `C2` with all the cell values merged. +The result is: + +![12-01-MergeCells-Options-3.png](./images/12-01-MergeCells-Options-3.png) + +The cell value `12` from cell `A2` is fixed; the value from `B2` is the result of the formula `=5-B1` (`4`, which is appended to our merged value), and cell `B2` is then emptied, so when we evaluate cell `C2` with the formula `=A2/B2` it gives us `12 / 0` which results in a `#DIV/0!` error (so the error `#DIV/0!` is appended to our merged value rather than the original calculation result of `3`). + ## Inserting or Removing rows/columns You can insert/remove rows/columns at a specific position. The following diff --git a/src/PhpSpreadsheet/Reader/Gnumeric.php b/src/PhpSpreadsheet/Reader/Gnumeric.php index ca087e61..1dcb0a12 100644 --- a/src/PhpSpreadsheet/Reader/Gnumeric.php +++ b/src/PhpSpreadsheet/Reader/Gnumeric.php @@ -363,7 +363,7 @@ class Gnumeric extends BaseReader if ($sheet !== null && isset($sheet->MergedRegions)) { foreach ($sheet->MergedRegions->Merge as $mergeCells) { if (strpos((string) $mergeCells, ':') !== false) { - $this->spreadsheet->getActiveSheet()->mergeCells($mergeCells); + $this->spreadsheet->getActiveSheet()->mergeCells($mergeCells, Worksheet::MERGE_CELL_CONTENT_HIDE); } } } diff --git a/src/PhpSpreadsheet/Reader/Ods.php b/src/PhpSpreadsheet/Reader/Ods.php index 7e776ab7..e3de4731 100644 --- a/src/PhpSpreadsheet/Reader/Ods.php +++ b/src/PhpSpreadsheet/Reader/Ods.php @@ -20,6 +20,7 @@ use PhpOffice\PhpSpreadsheet\Shared\Date; use PhpOffice\PhpSpreadsheet\Shared\File; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Style\NumberFormat; +use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use Throwable; use XMLReader; use ZipArchive; @@ -759,7 +760,7 @@ class Ods extends BaseReader } $cellRange = $columnID . $rowID . ':' . $columnTo . $rowTo; - $spreadsheet->getActiveSheet()->mergeCells($cellRange); + $spreadsheet->getActiveSheet()->mergeCells($cellRange, Worksheet::MERGE_CELL_CONTENT_HIDE); } } } diff --git a/src/PhpSpreadsheet/Reader/Xls.php b/src/PhpSpreadsheet/Reader/Xls.php index 71496ece..a8de5228 100644 --- a/src/PhpSpreadsheet/Reader/Xls.php +++ b/src/PhpSpreadsheet/Reader/Xls.php @@ -4585,7 +4585,7 @@ class Xls extends BaseReader (strpos($cellRangeAddress, ':') !== false) && ($this->includeCellRangeFiltered($cellRangeAddress)) ) { - $this->phpSheet->mergeCells($cellRangeAddress); + $this->phpSheet->mergeCells($cellRangeAddress, Worksheet::MERGE_CELL_CONTENT_HIDE); } } } diff --git a/src/PhpSpreadsheet/Reader/Xlsx.php b/src/PhpSpreadsheet/Reader/Xlsx.php index fc38375a..26cd1af3 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/src/PhpSpreadsheet/Reader/Xlsx.php @@ -914,7 +914,7 @@ class Xlsx extends BaseReader foreach ($xmlSheet->mergeCells->mergeCell as $mergeCell) { $mergeRef = (string) $mergeCell['ref']; if (strpos($mergeRef, ':') !== false) { - $docSheet->mergeCells((string) $mergeCell['ref']); + $docSheet->mergeCells((string) $mergeCell['ref'], Worksheet::MERGE_CELL_CONTENT_HIDE); } } } diff --git a/src/PhpSpreadsheet/Reader/Xml.php b/src/PhpSpreadsheet/Reader/Xml.php index 0b5e0966..d8f0d9dc 100644 --- a/src/PhpSpreadsheet/Reader/Xml.php +++ b/src/PhpSpreadsheet/Reader/Xml.php @@ -18,6 +18,7 @@ use PhpOffice\PhpSpreadsheet\Shared\Date; use PhpOffice\PhpSpreadsheet\Shared\File; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use SimpleXMLElement; /** @@ -364,7 +365,7 @@ class Xml extends BaseReader $rowTo = $rowTo + $cell_ss['MergeDown']; } $cellRange .= ':' . $columnTo . $rowTo; - $spreadsheet->getActiveSheet()->mergeCells($cellRange); + $spreadsheet->getActiveSheet()->mergeCells($cellRange, Worksheet::MERGE_CELL_CONTENT_HIDE); } $hasCalculatedValue = false; diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index d13d4141..55327932 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -41,6 +41,10 @@ class Worksheet implements IComparable public const SHEETSTATE_HIDDEN = 'hidden'; public const SHEETSTATE_VERYHIDDEN = 'veryHidden'; + public const MERGE_CELL_CONTENT_EMPTY = 'empty'; + public const MERGE_CELL_CONTENT_HIDE = 'hide'; + public const MERGE_CELL_CONTENT_MERGE = 'merge'; + protected const SHEET_NAME_REQUIRES_NO_QUOTES = '/^[_\p{L}][_\p{L}\p{N}]*$/mui'; /** @@ -1758,10 +1762,15 @@ class Worksheet implements IComparable * @param AddressRange|array|string $range A simple string containing a Cell range like 'A1:E10' * or passing in an array of [$fromColumnIndex, $fromRow, $toColumnIndex, $toRow] (e.g. [3, 5, 6, 8]), * or an AddressRange. + * @param string $behaviour How the merged cells should behave. + * Possible values are: + * MERGE_CELL_CONTENT_EMPTY - Empty the content of the hidden cells + * MERGE_CELL_CONTENT_HIDE - Keep the content of the hidden cells + * MERGE_CELL_CONTENT_MERGE - Move the content of the hidden cells into the first cell * * @return $this */ - public function mergeCells($range) + public function mergeCells($range, $behaviour = self::MERGE_CELL_CONTENT_EMPTY) { $range = Functions::trimSheetFromCellReference(Validations::validateCellRange($range)); @@ -1793,18 +1802,22 @@ class Worksheet implements IComparable $this->getCell($upperLeft)->setValueExplicit(null, DataType::TYPE_NULL); } - // Blank out the rest of the cells in the range (if they exist) - if ($numberRows > $numberColumns) { - $this->clearMergeCellsByColumn($firstColumn, $lastColumn, $firstRow, $lastRow, $upperLeft); - } else { - $this->clearMergeCellsByRow($firstColumn, $lastColumnIndex, $firstRow, $lastRow, $upperLeft); + if ($behaviour !== self::MERGE_CELL_CONTENT_HIDE) { + // Blank out the rest of the cells in the range (if they exist) + if ($numberRows > $numberColumns) { + $this->clearMergeCellsByColumn($firstColumn, $lastColumn, $firstRow, $lastRow, $upperLeft, $behaviour); + } else { + $this->clearMergeCellsByRow($firstColumn, $lastColumnIndex, $firstRow, $lastRow, $upperLeft, $behaviour); + } } return $this; } - private function clearMergeCellsByColumn(string $firstColumn, string $lastColumn, int $firstRow, int $lastRow, string $upperLeft): void + private function clearMergeCellsByColumn(string $firstColumn, string $lastColumn, int $firstRow, int $lastRow, string $upperLeft, string $behaviour): void { + $leftCellValue = [$this->getCell($upperLeft)->getFormattedValue()]; + foreach ($this->getColumnIterator($firstColumn, $lastColumn) as $column) { $iterator = $column->getCellIterator($firstRow); $iterator->setIterateOnlyExistingCells(true); @@ -1814,17 +1827,21 @@ class Worksheet implements IComparable if ($row > $lastRow) { break; } - $thisCell = $cell->getColumn() . $row; - if ($upperLeft !== $thisCell) { - $cell->setValueExplicit(null, DataType::TYPE_NULL); - } + $leftCellValue = $this->mergeCellBehaviour($cell, $upperLeft, $behaviour, $leftCellValue); } } } + + $leftCellValue = implode(' ', $leftCellValue); + if ($behaviour === self::MERGE_CELL_CONTENT_MERGE) { + $this->getCell($upperLeft)->setValueExplicit($leftCellValue, DataType::TYPE_STRING); + } } - private function clearMergeCellsByRow(string $firstColumn, int $lastColumnIndex, int $firstRow, int $lastRow, string $upperLeft): void + private function clearMergeCellsByRow(string $firstColumn, int $lastColumnIndex, int $firstRow, int $lastRow, string $upperLeft, string $behaviour): void { + $leftCellValue = [$this->getCell($upperLeft)->getFormattedValue()]; + foreach ($this->getRowIterator($firstRow, $lastRow) as $row) { $iterator = $row->getCellIterator($firstColumn); $iterator->setIterateOnlyExistingCells(true); @@ -1835,13 +1852,31 @@ class Worksheet implements IComparable if ($columnIndex > $lastColumnIndex) { break; } - $thisCell = $column . $cell->getRow(); - if ($upperLeft !== $thisCell) { - $cell->setValueExplicit(null, DataType::TYPE_NULL); - } + $leftCellValue = $this->mergeCellBehaviour($cell, $upperLeft, $behaviour, $leftCellValue); } } } + + $leftCellValue = implode(' ', $leftCellValue); + if ($behaviour === self::MERGE_CELL_CONTENT_MERGE) { + $this->getCell($upperLeft)->setValueExplicit($leftCellValue, DataType::TYPE_STRING); + } + } + + public function mergeCellBehaviour(Cell $cell, string $upperLeft, string $behaviour, array $leftCellValue): array + { + if ($cell->getCoordinate() !== $upperLeft) { + Calculation::getInstance($cell->getWorksheet()->getParent())->flushInstance(); + if ($behaviour === self::MERGE_CELL_CONTENT_MERGE) { + $cellValue = $cell->getFormattedValue(); + if ($cellValue !== '') { + $leftCellValue[] = $cellValue; + } + } + $cell->setValueExplicit(null, DataType::TYPE_NULL); + } + + return $leftCellValue; } /** @@ -1856,17 +1891,22 @@ class Worksheet implements IComparable * @param int $row1 Numeric row coordinate of the first cell * @param int $columnIndex2 Numeric column coordinate of the last cell * @param int $row2 Numeric row coordinate of the last cell + * @param string $behaviour How the merged cells should behave. + * Possible values are: + * MERGE_CELL_CONTENT_EMPTY - Empty the content of the hidden cells + * MERGE_CELL_CONTENT_HIDE - Keep the content of the hidden cells + * MERGE_CELL_CONTENT_MERGE - Move the content of the hidden cells into the first cell * * @return $this */ - public function mergeCellsByColumnAndRow($columnIndex1, $row1, $columnIndex2, $row2) + public function mergeCellsByColumnAndRow($columnIndex1, $row1, $columnIndex2, $row2, $behaviour = self::MERGE_CELL_CONTENT_EMPTY) { $cellRange = new CellRange( CellAddress::fromColumnAndRow($columnIndex1, $row1), CellAddress::fromColumnAndRow($columnIndex2, $row2) ); - return $this->mergeCells($cellRange); + return $this->mergeCells($cellRange, $behaviour); } /** diff --git a/tests/PhpSpreadsheetTests/Worksheet/MergeBehaviourTest.php b/tests/PhpSpreadsheetTests/Worksheet/MergeBehaviourTest.php new file mode 100644 index 00000000..68c9d87a --- /dev/null +++ b/tests/PhpSpreadsheetTests/Worksheet/MergeBehaviourTest.php @@ -0,0 +1,166 @@ +getActiveSheet(); + $worksheet->fromArray($this->testDataRaw, null, 'A1', true); + $worksheet->mergeCells($mergeRange); + + $mergeResult = $worksheet->toArray(null, true, true, false); + self::assertSame($expectedResult, $mergeResult); + } + + public function testMergeCellsDefaultBehaviourFormatted(): void + { + $expectedResult = [ + ['1960-12-19', null], + ]; + + $mergeRange = 'A1:B1'; + $spreadsheet = new Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + $worksheet->fromArray($this->testDataFormatted, null, 'A1', true); + $worksheet->getStyle($mergeRange)->getNumberFormat()->setFormatCode('yyyy-mm-dd'); + $worksheet->mergeCells($mergeRange); + + $mergeResult = $worksheet->toArray(null, true, true, false); + self::assertSame($expectedResult, $mergeResult); + } + + public function testMergeCellsHideBehaviour(): void + { + $expectedResult = [ + [1.1, 2.2, 3.3], + [4.4, 5.5, 9.9], + [5.5, 7.7, 13.2], + ]; + + $mergeRange = 'A1:C3'; + $spreadsheet = new Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + $worksheet->fromArray($this->testDataRaw, null, 'A1', true); + $worksheet->mergeCells($mergeRange, Worksheet::MERGE_CELL_CONTENT_HIDE); + + $mergeResult = $worksheet->toArray(null, true, true, false); + self::assertSame($expectedResult, $mergeResult); + } + + public function testMergeCellsHideBehaviourFormatted(): void + { + $expectedResult = [ + ['1960-12-19', '2022-09-15'], + ]; + + $mergeRange = 'A1:B1'; + $spreadsheet = new Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + $worksheet->fromArray($this->testDataFormatted, null, 'A1', true); + $worksheet->getStyle($mergeRange)->getNumberFormat()->setFormatCode('yyyy-mm-dd'); + $worksheet->mergeCells($mergeRange, Worksheet::MERGE_CELL_CONTENT_HIDE); + + $mergeResult = $worksheet->toArray(null, true, true, false); + self::assertSame($expectedResult, $mergeResult); + } + + /** + * @dataProvider mergeCellsMergeBehaviourProvider + */ + public function testMergeCellsMergeBehaviour(array $testData, string $mergeRange, array $expectedResult): void + { + $spreadsheet = new Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + $worksheet->fromArray($testData, null, 'A1', true); + // Force a precalculation to populate the calculation cache, so that we can verify that it is being cleared + $worksheet->toArray(); + $worksheet->mergeCells($mergeRange, Worksheet::MERGE_CELL_CONTENT_MERGE); + + $mergeResult = $worksheet->toArray(null, true, true, false); + self::assertSame($expectedResult, $mergeResult); + } + + public function mergeCellsMergeBehaviourProvider(): array + { + return [ + 'With Calculated Values' => [ + $this->testDataRaw, + 'A1:C3', + [ + ['1.1 2.2 1.1 4.4 5.5 0 1.1 0 0', null, null], + [null, null, null], + [null, null, null], + ], + ], + 'With Empty Cells' => [ + [ + [1, '', 2], + [null, 3, null], + [4, null, 5], + ], + 'A1:C3', + [ + ['1 2 3 4 5', null, null], + [null, null, null], + [null, null, null], + ], + ], + [ + [ + [12, '=5+1', '=A1/A2'], + ], + 'A1:C1', + [ + ['12 6 #DIV/0!', null, null], + ], + ], + ]; + } + + public function testMergeCellsMergeBehaviourFormatted(): void + { + $expectedResult = [ + ['1960-12-19 2022-09-15', null], + ]; + + $mergeRange = 'A1:B1'; + $spreadsheet = new Spreadsheet(); + $worksheet = $spreadsheet->getActiveSheet(); + $worksheet->fromArray($this->testDataFormatted, null, 'A1', true); + $worksheet->getStyle($mergeRange)->getNumberFormat()->setFormatCode('yyyy-mm-dd'); + $worksheet->mergeCells($mergeRange, Worksheet::MERGE_CELL_CONTENT_MERGE); + + $mergeResult = $worksheet->toArray(null, true, true, false); + self::assertSame($expectedResult, $mergeResult); + } +}