From b6bd822b9cd7c3578aea4b54226a565bb062c37f Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Sun, 23 Jan 2022 10:44:09 -0800 Subject: [PATCH] Xlsx Reader Merge Range For Entire Column(s) or Row(s) (#2504) * Xlsx Reader Merge Range For Entire Column(s) or Row(s) Fix #2501. Merge range can be supplied as entire rows or columns, e.g. `1:1` or `A:C`. PhpSpreadsheet is expecting a row and a column to be specified for both parts of the range, and fails when the unexpected format shows up. The code to clear cells within the merge range is very inefficient in terms of both memory and time, especially when the range is large (e.g. for an entire row or column). More efficient code is substituted. It is possible that we can get even more efficient by deleting the cleared cells rather than setting them to null. However, that needs more research, and there is no reason to delay this fix while I am researching. When Xlsx Writer encounters a null cell, it writes it to the output file. For cell merges (especially involving whole rows or columns), this results in a lot of useless output. It is changed to skip the output of null cells when (a) the cell style matches its row's style, or (b) the row style is not specified and the cell style matches its column's style. * Scrutinizer See if these changes appease it. * Improved CellIterators Finally figured out how to improve efficiency here, meaning that there is no longer a reason to change Writer/Xlsx, so restore that. * No Change for CellIterator I had thought a change was needed for CellIterator, but it isn't. --- src/PhpSpreadsheet/Worksheet/Worksheet.php | 71 ++++++++++-- .../Calculation/MergedCellTest.php | 108 ++++++++++++++---- .../Reader/Xlsx/Issue2501Test.php | 70 ++++++++++++ tests/data/Reader/XLSX/issue.2501.b.xlsx | Bin 0 -> 9137 bytes 4 files changed, 217 insertions(+), 32 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Reader/Xlsx/Issue2501Test.php create mode 100644 tests/data/Reader/XLSX/issue.2501.b.xlsx diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 896b4e9c..51cf3c52 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -1698,27 +1698,33 @@ class Worksheet implements IComparable { // Uppercase coordinate $range = strtoupper($range); + // Convert 'A:C' to 'A1:C1048576' + $range = self::pregReplace('/^([A-Z]+):([A-Z]+)$/', '${1}1:${2}1048576', $range); + // Convert '1:3' to 'A1:XFD3' + $range = self::pregReplace('/^(\\d+):(\\d+)$/', 'A${1}:XFD${2}', $range); - if (strpos($range, ':') !== false) { + if (preg_match('/^([A-Z]+)(\\d+):([A-Z]+)(\\d+)$/', $range, $matches) === 1) { $this->mergeCells[$range] = $range; - - // make sure cells are created - - // get the cells in the range - $aReferences = Coordinate::extractAllCellReferencesInRange($range); + $firstRow = (int) $matches[2]; + $lastRow = (int) $matches[4]; + $firstColumn = $matches[1]; + $lastColumn = $matches[3]; + $firstColumnIndex = Coordinate::columnIndexFromString($firstColumn); + $lastColumnIndex = Coordinate::columnIndexFromString($lastColumn); + $numberRows = $lastRow - $firstRow; + $numberColumns = $lastColumnIndex - $firstColumnIndex; // create upper left cell if it does not already exist - $upperLeft = $aReferences[0]; + $upperLeft = "$firstColumn$firstRow"; if (!$this->cellExists($upperLeft)) { $this->getCell($upperLeft)->setValueExplicit(null, DataType::TYPE_NULL); } // Blank out the rest of the cells in the range (if they exist) - $count = count($aReferences); - for ($i = 1; $i < $count; ++$i) { - if ($this->cellExists($aReferences[$i])) { - $this->getCell($aReferences[$i])->setValueExplicit(null, DataType::TYPE_NULL); - } + if ($numberRows > $numberColumns) { + $this->clearMergeCellsByColumn($firstColumn, $lastColumn, $firstRow, $lastRow, $upperLeft); + } else { + $this->clearMergeCellsByRow($firstColumn, $lastColumnIndex, $firstRow, $lastRow, $upperLeft); } } else { throw new Exception('Merge must be set on a range of cells.'); @@ -1727,6 +1733,47 @@ class Worksheet implements IComparable return $this; } + private function clearMergeCellsByColumn(string $firstColumn, string $lastColumn, int $firstRow, int $lastRow, string $upperLeft): void + { + foreach ($this->getColumnIterator($firstColumn, $lastColumn) as $column) { + $iterator = $column->getCellIterator($firstRow); + $iterator->setIterateOnlyExistingCells(true); + foreach ($iterator as $cell) { + if ($cell !== null) { + $row = $cell->getRow(); + if ($row > $lastRow) { + break; + } + $thisCell = $cell->getColumn() . $row; + if ($upperLeft !== $thisCell) { + $cell->setValueExplicit(null, DataType::TYPE_NULL); + } + } + } + } + } + + private function clearMergeCellsByRow(string $firstColumn, int $lastColumnIndex, int $firstRow, int $lastRow, string $upperLeft): void + { + foreach ($this->getRowIterator($firstRow, $lastRow) as $row) { + $iterator = $row->getCellIterator($firstColumn); + $iterator->setIterateOnlyExistingCells(true); + foreach ($iterator as $cell) { + if ($cell !== null) { + $column = $cell->getColumn(); + $columnIndex = Coordinate::columnIndexFromString($column); + if ($columnIndex > $lastColumnIndex) { + break; + } + $thisCell = $column . $cell->getRow(); + if ($upperLeft !== $thisCell) { + $cell->setValueExplicit(null, DataType::TYPE_NULL); + } + } + } + } + } + /** * Set merge on a cell range by using numeric cell coordinates. * diff --git a/tests/PhpSpreadsheetTests/Calculation/MergedCellTest.php b/tests/PhpSpreadsheetTests/Calculation/MergedCellTest.php index 6a710436..f6e98474 100644 --- a/tests/PhpSpreadsheetTests/Calculation/MergedCellTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/MergedCellTest.php @@ -3,43 +3,43 @@ namespace PhpOffice\PhpSpreadsheetTests\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\Functions; +use PhpOffice\PhpSpreadsheet\Exception as SpreadException; use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PHPUnit\Framework\TestCase; class MergedCellTest extends TestCase { - /** - * @var Spreadsheet - */ - protected $spreadSheet; - - protected function setUp(): void - { - $this->spreadSheet = new Spreadsheet(); - - $dataSheet = $this->spreadSheet->getActiveSheet(); - $dataSheet->setCellValue('A1', 1.1); - $dataSheet->setCellValue('A2', 2.2); - $dataSheet->mergeCells('A2:A4'); - $dataSheet->setCellValue('A5', 3.3); - } - /** * @param mixed $expectedResult * - * @dataProvider providerWorksheetFormulae + * @dataProvider providerWorksheetFormulaeColumns */ - public function testMergedCellBehaviour(string $formula, $expectedResult): void + public function testMergedCellColumns(string $formula, $expectedResult): void { - $worksheet = $this->spreadSheet->getActiveSheet(); + $spreadSheet = new Spreadsheet(); + + $dataSheet = $spreadSheet->getActiveSheet(); + $dataSheet->setCellValue('A5', 3.3); + $dataSheet->setCellValue('A3', 3.3); + $dataSheet->setCellValue('A2', 2.2); + $dataSheet->setCellValue('A1', 1.1); + $dataSheet->setCellValue('B2', 2.2); + $dataSheet->setCellValue('B1', 1.1); + $dataSheet->setCellValue('C2', 4.4); + $dataSheet->setCellValue('C1', 3.3); + $dataSheet->mergeCells('A2:A4'); + $dataSheet->mergeCells('B:B'); + $worksheet = $spreadSheet->getActiveSheet(); $worksheet->setCellValue('A7', $formula); $result = $worksheet->getCell('A7')->getCalculatedValue(); self::assertSame($expectedResult, $result); + $spreadSheet->disconnectWorksheets(); } - public function providerWorksheetFormulae(): array + public function providerWorksheetFormulaeColumns(): array { return [ ['=SUM(A1:A5)', 6.6], @@ -48,6 +48,74 @@ class MergedCellTest extends TestCase ['=SUM(A3:A4)', 0], ['=A2+A3+A4', 2.2], ['=A2/A3', Functions::DIV0()], + ['=SUM(B1:C2)', 8.8], ]; } + + /** + * @param mixed $expectedResult + * + * @dataProvider providerWorksheetFormulaeRows + */ + public function testMergedCellRows(string $formula, $expectedResult): void + { + $spreadSheet = new Spreadsheet(); + + $dataSheet = $spreadSheet->getActiveSheet(); + $dataSheet->setCellValue('A1', 1.1); + $dataSheet->setCellValue('B1', 2.2); + $dataSheet->setCellValue('C1', 3.3); + $dataSheet->setCellValue('E1', 3.3); + $dataSheet->setCellValue('A2', 1.1); + $dataSheet->setCellValue('B2', 2.2); + $dataSheet->setCellValue('A3', 3.3); + $dataSheet->setCellValue('B3', 4.4); + $dataSheet->mergeCells('B1:D1'); + $dataSheet->mergeCells('A2:B2'); + $worksheet = $spreadSheet->getActiveSheet(); + + $worksheet->setCellValue('A7', $formula); + + $result = $worksheet->getCell('A7')->getCalculatedValue(); + self::assertSame($expectedResult, $result); + $spreadSheet->disconnectWorksheets(); + } + + public function providerWorksheetFormulaeRows(): array + { + return [ + ['=SUM(A1:E1)', 6.6], + ['=COUNT(A1:E1)', 3], + ['=COUNTA(A1:E1)', 3], + ['=SUM(C1:D1)', 0], + ['=B1+C1+D1', 2.2], + ['=B1/C1', Functions::DIV0()], + ['=SUM(A2:B3)', 8.8], + ]; + } + + private function setBadRange(Worksheet $sheet, string $range): void + { + try { + $sheet->mergeCells($range); + self::fail("Expected invalid merge range $range"); + } catch (SpreadException $e) { + self::assertSame('Merge must be set on a range of cells.', $e->getMessage()); + } + } + + public function testMergedBadRange(): void + { + $spreadSheet = new Spreadsheet(); + + $dataSheet = $spreadSheet->getActiveSheet(); + $this->setBadRange($dataSheet, 'B1'); + $this->setBadRange($dataSheet, 'Invalid'); + $this->setBadRange($dataSheet, '1'); + $this->setBadRange($dataSheet, 'C'); + $this->setBadRange($dataSheet, 'B1:C'); + $this->setBadRange($dataSheet, 'B:C2'); + + $spreadSheet->disconnectWorksheets(); + } } diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/Issue2501Test.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/Issue2501Test.php new file mode 100644 index 00000000..6b090fe8 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/Issue2501Test.php @@ -0,0 +1,70 @@ +', $data); + } + $file = 'zip://'; + $file .= self::$testbook; + $file .= '#xl/worksheets/sheet2.xml'; + $data = file_get_contents($file); + // confirm that file contains expected merged ranges + if ($data === false) { + self::fail('Unable to read file'); + } else { + self::assertStringContainsString('', $data); + } + } + + public function testIssue2501(): void + { + // Merged cell range specified as 1:1" + $filename = self::$testbook; + $reader = IOFactory::createReader('Xlsx'); + $spreadsheet = $reader->load($filename); + $sheet = $spreadsheet->getSheetByName('Columns'); + $expected = [ + 'A1:A1048576', + 'B1:D1048576', + 'E2:E4', + ]; + if ($sheet === null) { + self::fail('Unable to find sheet Columns'); + } else { + self::assertSame($expected, array_values($sheet->getMergeCells())); + } + $sheet = $spreadsheet->getSheetByName('Rows'); + $expected = [ + 'A1:XFD1', + 'A2:XFD4', + 'B5:D5', + ]; + if ($sheet === null) { + self::fail('Unable to find sheet Rows'); + } else { + self::assertSame($expected, array_values($sheet->getMergeCells())); + } + + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/data/Reader/XLSX/issue.2501.b.xlsx b/tests/data/Reader/XLSX/issue.2501.b.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..fddf197bd84ce9c1ac9cdac674217274d50ecd3d GIT binary patch literal 9137 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