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.
This commit is contained in:
parent
3b81917760
commit
b6bd822b9c
|
|
@ -1698,27 +1698,33 @@ class Worksheet implements IComparable
|
||||||
{
|
{
|
||||||
// Uppercase coordinate
|
// Uppercase coordinate
|
||||||
$range = strtoupper($range);
|
$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;
|
$this->mergeCells[$range] = $range;
|
||||||
|
$firstRow = (int) $matches[2];
|
||||||
// make sure cells are created
|
$lastRow = (int) $matches[4];
|
||||||
|
$firstColumn = $matches[1];
|
||||||
// get the cells in the range
|
$lastColumn = $matches[3];
|
||||||
$aReferences = Coordinate::extractAllCellReferencesInRange($range);
|
$firstColumnIndex = Coordinate::columnIndexFromString($firstColumn);
|
||||||
|
$lastColumnIndex = Coordinate::columnIndexFromString($lastColumn);
|
||||||
|
$numberRows = $lastRow - $firstRow;
|
||||||
|
$numberColumns = $lastColumnIndex - $firstColumnIndex;
|
||||||
|
|
||||||
// create upper left cell if it does not already exist
|
// create upper left cell if it does not already exist
|
||||||
$upperLeft = $aReferences[0];
|
$upperLeft = "$firstColumn$firstRow";
|
||||||
if (!$this->cellExists($upperLeft)) {
|
if (!$this->cellExists($upperLeft)) {
|
||||||
$this->getCell($upperLeft)->setValueExplicit(null, DataType::TYPE_NULL);
|
$this->getCell($upperLeft)->setValueExplicit(null, DataType::TYPE_NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blank out the rest of the cells in the range (if they exist)
|
// Blank out the rest of the cells in the range (if they exist)
|
||||||
$count = count($aReferences);
|
if ($numberRows > $numberColumns) {
|
||||||
for ($i = 1; $i < $count; ++$i) {
|
$this->clearMergeCellsByColumn($firstColumn, $lastColumn, $firstRow, $lastRow, $upperLeft);
|
||||||
if ($this->cellExists($aReferences[$i])) {
|
} else {
|
||||||
$this->getCell($aReferences[$i])->setValueExplicit(null, DataType::TYPE_NULL);
|
$this->clearMergeCellsByRow($firstColumn, $lastColumnIndex, $firstRow, $lastRow, $upperLeft);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Exception('Merge must be set on a range of cells.');
|
throw new Exception('Merge must be set on a range of cells.');
|
||||||
|
|
@ -1727,6 +1733,47 @@ class Worksheet implements IComparable
|
||||||
return $this;
|
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.
|
* Set merge on a cell range by using numeric cell coordinates.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -3,43 +3,43 @@
|
||||||
namespace PhpOffice\PhpSpreadsheetTests\Calculation;
|
namespace PhpOffice\PhpSpreadsheetTests\Calculation;
|
||||||
|
|
||||||
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
|
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Exception as SpreadException;
|
||||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class MergedCellTest extends 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
|
* @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);
|
$worksheet->setCellValue('A7', $formula);
|
||||||
|
|
||||||
$result = $worksheet->getCell('A7')->getCalculatedValue();
|
$result = $worksheet->getCell('A7')->getCalculatedValue();
|
||||||
self::assertSame($expectedResult, $result);
|
self::assertSame($expectedResult, $result);
|
||||||
|
$spreadSheet->disconnectWorksheets();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function providerWorksheetFormulae(): array
|
public function providerWorksheetFormulaeColumns(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
['=SUM(A1:A5)', 6.6],
|
['=SUM(A1:A5)', 6.6],
|
||||||
|
|
@ -48,6 +48,74 @@ class MergedCellTest extends TestCase
|
||||||
['=SUM(A3:A4)', 0],
|
['=SUM(A3:A4)', 0],
|
||||||
['=A2+A3+A4', 2.2],
|
['=A2+A3+A4', 2.2],
|
||||||
['=A2/A3', Functions::DIV0()],
|
['=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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace PhpOffice\PhpSpreadsheetTests\Reader\Xlsx;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class Issue2501Test extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private static $testbook = 'tests/data/Reader/XLSX/issue.2501.b.xlsx';
|
||||||
|
|
||||||
|
public function testPreliminaries(): void
|
||||||
|
{
|
||||||
|
$file = 'zip://';
|
||||||
|
$file .= self::$testbook;
|
||||||
|
$file .= '#xl/worksheets/sheet1.xml';
|
||||||
|
$data = file_get_contents($file);
|
||||||
|
// confirm that file contains expected merge ranges
|
||||||
|
if ($data === false) {
|
||||||
|
self::fail('Unable to read file');
|
||||||
|
} else {
|
||||||
|
self::assertStringContainsString('<mergeCells count="3"><mergeCell ref="A:A"/><mergeCell ref="B:D"/><mergeCell ref="E2:E4"/></mergeCells>', $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('<mergeCells count="3"><mergeCell ref="1:1"/><mergeCell ref="2:4"/><mergeCell ref="B5:D5"/></mergeCells>', $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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Loading…
Reference in New Issue