diff --git a/CHANGELOG.md b/CHANGELOG.md index df24ba1f..ca0ce021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Fixed +- Fix bug when deleting cells with hyperlinks, where the hyperlink was then being "inherited" by whatever cell moved to that cell address. - Fix bug in Conditional Formatting in the Xls Writer that resulted in a broken file when there were multiple conditional ranges in a worksheet. - Fix Conditional Formatting in the Xls Writer to work with rules that contain string literals, cell references and formulae. - Fix for setting Active Sheet to the first loaded worksheet when bookViews element isn't defined [Issue #2666](https://github.com/PHPOffice/PhpSpreadsheet/issues/2666) [PR #2669](https://github.com/PHPOffice/PhpSpreadsheet/pull/2669) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 1ba11328..eb20b3cf 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -3165,31 +3165,11 @@ parameters: count: 1 path: src/PhpSpreadsheet/ReferenceHelper.php - - - message: "#^Parameter \\#1 \\$index of method PhpOffice\\\\PhpSpreadsheet\\\\Worksheet\\\\RowDimension\\:\\:setRowIndex\\(\\) expects int, string given\\.$#" - count: 1 - path: src/PhpSpreadsheet/ReferenceHelper.php - - - - message: "#^Parameter \\#2 \\$callback of function uksort expects callable\\(\\(int\\|string\\), \\(int\\|string\\)\\)\\: int, array\\{'self', 'cellReverseSort'\\} given\\.$#" - count: 4 - path: src/PhpSpreadsheet/ReferenceHelper.php - - - - message: "#^Parameter \\#2 \\$callback of function uksort expects callable\\(\\(int\\|string\\), \\(int\\|string\\)\\)\\: int, array\\{'self', 'cellSort'\\} given\\.$#" - count: 4 - path: src/PhpSpreadsheet/ReferenceHelper.php - - message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, string\\|null given\\.$#" count: 1 path: src/PhpSpreadsheet/ReferenceHelper.php - - - message: "#^Static property PhpOffice\\\\PhpSpreadsheet\\\\ReferenceHelper\\:\\:\\$instance \\(PhpOffice\\\\PhpSpreadsheet\\\\ReferenceHelper\\) in isset\\(\\) is not nullable\\.$#" - count: 1 - path: src/PhpSpreadsheet/ReferenceHelper.php - - message: "#^Property PhpOffice\\\\PhpSpreadsheet\\\\RichText\\\\Run\\:\\:\\$font \\(PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\) does not accept PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" count: 1 diff --git a/src/PhpSpreadsheet/CellReferenceHelper.php b/src/PhpSpreadsheet/CellReferenceHelper.php new file mode 100644 index 00000000..24021109 --- /dev/null +++ b/src/PhpSpreadsheet/CellReferenceHelper.php @@ -0,0 +1,103 @@ +beforeCellAddress = str_replace('$', '', $beforeCellAddress); + $this->numberOfColumns = $numberOfColumns; + $this->numberOfRows = $numberOfRows; + + // Get coordinate of $beforeCellAddress + [$beforeColumn, $beforeRow] = Coordinate::coordinateFromString($beforeCellAddress); + $this->beforeColumn = (int) Coordinate::columnIndexFromString($beforeColumn); + $this->beforeRow = (int) $beforeRow; + } + + public function refreshRequired(string $beforeCellAddress, int $numberOfColumns, int $numberOfRows): bool + { + return $this->beforeCellAddress !== $beforeCellAddress || + $this->numberOfColumns !== $numberOfColumns || + $this->numberOfRows !== $numberOfRows; + } + + public function updateCellReference(string $cellReference = 'A1'): string + { + if (Coordinate::coordinateIsRange($cellReference)) { + throw new Exception('Only single cell references may be passed to this method.'); + } + + // Get coordinate of $cellReference + [$newColumn, $newRow] = Coordinate::coordinateFromString($cellReference); + $newColumnIndex = (int) Coordinate::columnIndexFromString(str_replace('$', '', $newColumn)); + $newRowIndex = (int) str_replace('$', '', $newRow); + + // Verify which parts should be updated + $updateColumn = (($newColumn[0] !== '$') && $newColumnIndex >= $this->beforeColumn); + $updateRow = (($newRow[0] !== '$') && $newRow >= $this->beforeRow); + + // Create new column reference + if ($updateColumn) { + $newColumn = Coordinate::stringFromColumnIndex($newColumnIndex + $this->numberOfColumns); + } + + // Create new row reference + if ($updateRow) { + $newRow = $newRowIndex + $this->numberOfRows; + } + + // Return new reference + return "{$newColumn}{$newRow}"; + } + + public function cellAddressInDeleteRange(string $cellAddress): bool + { + [$cellColumn, $cellRow] = Coordinate::coordinateFromString($cellAddress); + $cellColumnIndex = Coordinate::columnIndexFromString($cellColumn); + // Is cell within the range of rows/columns if we're deleting + if ( + $this->numberOfRows < 0 && + ($cellRow >= ($this->beforeRow + $this->numberOfRows)) && + ($cellRow < $this->beforeRow) + ) { + return true; + } elseif ( + $this->numberOfColumns < 0 && + ($cellColumnIndex >= ($this->beforeColumn + $this->numberOfColumns)) && + ($cellColumnIndex < $this->beforeColumn) + ) { + return true; + } + + return false; + } +} diff --git a/src/PhpSpreadsheet/ReferenceHelper.php b/src/PhpSpreadsheet/ReferenceHelper.php index baac9b69..e8fb9057 100644 --- a/src/PhpSpreadsheet/ReferenceHelper.php +++ b/src/PhpSpreadsheet/ReferenceHelper.php @@ -20,10 +20,15 @@ class ReferenceHelper /** * Instance of this class. * - * @var ReferenceHelper + * @var ?ReferenceHelper */ private static $instance; + /** + * @var CellReferenceHelper + */ + private $cellReferenceHelper; + /** * Get an instance of this class. * @@ -31,7 +36,7 @@ class ReferenceHelper */ public static function getInstance() { - if (!isset(self::$instance) || (self::$instance === null)) { + if (self::$instance === null) { self::$instance = new self(); } @@ -115,67 +120,32 @@ class ReferenceHelper return ($ar < $br) ? 1 : -1; } - /** - * Test whether a cell address falls within a defined range of cells. - * - * @param string $cellAddress Address of the cell we're testing - * @param int $beforeRow Number of the row we're inserting/deleting before - * @param int $numberOfRows Number of rows to insert/delete (negative values indicate deletion) - * @param int $beforeColumnIndex Index number of the column we're inserting/deleting before - * @param int $numberOfCols Number of columns to insert/delete (negative values indicate deletion) - * - * @return bool - */ - private static function cellAddressInDeleteRange($cellAddress, $beforeRow, $numberOfRows, $beforeColumnIndex, $numberOfCols) - { - [$cellColumn, $cellRow] = Coordinate::coordinateFromString($cellAddress); - $cellColumnIndex = Coordinate::columnIndexFromString($cellColumn); - // Is cell within the range of rows/columns if we're deleting - if ( - $numberOfRows < 0 && - ($cellRow >= ($beforeRow + $numberOfRows)) && - ($cellRow < $beforeRow) - ) { - return true; - } elseif ( - $numberOfCols < 0 && - ($cellColumnIndex >= ($beforeColumnIndex + $numberOfCols)) && - ($cellColumnIndex < $beforeColumnIndex) - ) { - return true; - } - - return false; - } - /** * Update page breaks when inserting/deleting rows/columns. * * @param Worksheet $worksheet The worksheet that we're editing - * @param string $beforeCellAddress Insert/Delete before this cell address (e.g. 'A1') - * @param int $beforeColumnIndex Index number of the column we're inserting/deleting before * @param int $numberOfColumns Number of columns to insert/delete (negative values indicate deletion) - * @param int $beforeRow Number of the row we're inserting/deleting before * @param int $numberOfRows Number of rows to insert/delete (negative values indicate deletion) */ - protected function adjustPageBreaks(Worksheet $worksheet, $beforeCellAddress, $beforeColumnIndex, $numberOfColumns, $beforeRow, $numberOfRows): void + protected function adjustPageBreaks(Worksheet $worksheet, $numberOfColumns, $numberOfRows): void { $aBreaks = $worksheet->getBreaks(); - ($numberOfColumns > 0 || $numberOfRows > 0) ? - uksort($aBreaks, ['self', 'cellReverseSort']) : uksort($aBreaks, ['self', 'cellSort']); + ($numberOfColumns > 0 || $numberOfRows > 0) + ? uksort($aBreaks, [self::class, 'cellReverseSort']) + : uksort($aBreaks, [self::class, 'cellSort']); - foreach ($aBreaks as $key => $value) { - if (self::cellAddressInDeleteRange($key, $beforeRow, $numberOfRows, $beforeColumnIndex, $numberOfColumns)) { + foreach ($aBreaks as $cellAddress => $value) { + if ($this->cellReferenceHelper->cellAddressInDeleteRange($cellAddress) === true) { // If we're deleting, then clear any defined breaks that are within the range // of rows/columns that we're deleting - $worksheet->setBreak($key, Worksheet::BREAK_NONE); + $worksheet->setBreak($cellAddress, Worksheet::BREAK_NONE); } else { // Otherwise update any affected breaks by inserting a new break at the appropriate point // and removing the old affected break - $newReference = $this->updateCellReference($key, $beforeCellAddress, $numberOfColumns, $numberOfRows); - if ($key != $newReference) { + $newReference = $this->updateCellReference($cellAddress); + if ($cellAddress !== $newReference) { $worksheet->setBreak($newReference, $value) - ->setBreak($key, Worksheet::BREAK_NONE); + ->setBreak($cellAddress, Worksheet::BREAK_NONE); } } } @@ -185,22 +155,17 @@ class ReferenceHelper * Update cell comments when inserting/deleting rows/columns. * * @param Worksheet $worksheet The worksheet that we're editing - * @param string $beforeCellAddress Insert/Delete before this cell address (e.g. 'A1') - * @param int $beforeColumnIndex Index number of the column we're inserting/deleting before - * @param int $numberOfColumns Number of columns to insert/delete (negative values indicate deletion) - * @param int $beforeRow Number of the row we're inserting/deleting before - * @param int $numberOfRows Number of rows to insert/delete (negative values indicate deletion) */ - protected function adjustComments($worksheet, $beforeCellAddress, $beforeColumnIndex, $numberOfColumns, $beforeRow, $numberOfRows): void + protected function adjustComments($worksheet): void { $aComments = $worksheet->getComments(); $aNewComments = []; // the new array of all comments - foreach ($aComments as $key => &$value) { + foreach ($aComments as $cellAddress => &$value) { // Any comments inside a deleted range will be ignored - if (!self::cellAddressInDeleteRange($key, $beforeRow, $numberOfRows, $beforeColumnIndex, $numberOfColumns)) { + if ($this->cellReferenceHelper->cellAddressInDeleteRange($cellAddress) === false) { // Otherwise build a new array of comments indexed by the adjusted cell reference - $newReference = $this->updateCellReference($key, $beforeCellAddress, $numberOfColumns, $numberOfRows); + $newReference = $this->updateCellReference($cellAddress); $aNewComments[$newReference] = $value; } } @@ -212,21 +177,23 @@ class ReferenceHelper * Update hyperlinks when inserting/deleting rows/columns. * * @param Worksheet $worksheet The worksheet that we're editing - * @param string $beforeCellAddress Insert/Delete before this cell address (e.g. 'A1') * @param int $numberOfColumns Number of columns to insert/delete (negative values indicate deletion) * @param int $numberOfRows Number of rows to insert/delete (negative values indicate deletion) */ - protected function adjustHyperlinks($worksheet, $beforeCellAddress, $numberOfColumns, $numberOfRows): void + protected function adjustHyperlinks($worksheet, $numberOfColumns, $numberOfRows): void { $aHyperlinkCollection = $worksheet->getHyperlinkCollection(); - ($numberOfColumns > 0 || $numberOfRows > 0) ? - uksort($aHyperlinkCollection, ['self', 'cellReverseSort']) : uksort($aHyperlinkCollection, ['self', 'cellSort']); + ($numberOfColumns > 0 || $numberOfRows > 0) + ? uksort($aHyperlinkCollection, [self::class, 'cellReverseSort']) + : uksort($aHyperlinkCollection, [self::class, 'cellSort']); - foreach ($aHyperlinkCollection as $key => $value) { - $newReference = $this->updateCellReference($key, $beforeCellAddress, $numberOfColumns, $numberOfRows); - if ($key != $newReference) { + foreach ($aHyperlinkCollection as $cellAddress => $value) { + $newReference = $this->updateCellReference($cellAddress); + if ($this->cellReferenceHelper->cellAddressInDeleteRange($cellAddress) === true) { + $worksheet->setHyperlink($cellAddress, null); + } elseif ($cellAddress !== $newReference) { $worksheet->setHyperlink($newReference, $value); - $worksheet->setHyperlink($key, null); + $worksheet->setHyperlink($cellAddress, null); } } } @@ -235,21 +202,21 @@ class ReferenceHelper * Update data validations when inserting/deleting rows/columns. * * @param Worksheet $worksheet The worksheet that we're editing - * @param string $before Insert/Delete before this cell address (e.g. 'A1') * @param int $numberOfColumns Number of columns to insert/delete (negative values indicate deletion) * @param int $numberOfRows Number of rows to insert/delete (negative values indicate deletion) */ - protected function adjustDataValidations(Worksheet $worksheet, $before, $numberOfColumns, $numberOfRows): void + protected function adjustDataValidations(Worksheet $worksheet, $numberOfColumns, $numberOfRows): void { $aDataValidationCollection = $worksheet->getDataValidationCollection(); - ($numberOfColumns > 0 || $numberOfRows > 0) ? - uksort($aDataValidationCollection, ['self', 'cellReverseSort']) : uksort($aDataValidationCollection, ['self', 'cellSort']); + ($numberOfColumns > 0 || $numberOfRows > 0) + ? uksort($aDataValidationCollection, [self::class, 'cellReverseSort']) + : uksort($aDataValidationCollection, [self::class, 'cellSort']); - foreach ($aDataValidationCollection as $key => $value) { - $newReference = $this->updateCellReference($key, $before, $numberOfColumns, $numberOfRows); - if ($key != $newReference) { + foreach ($aDataValidationCollection as $cellAddress => $value) { + $newReference = $this->updateCellReference($cellAddress); + if ($cellAddress !== $newReference) { $worksheet->setDataValidation($newReference, $value); - $worksheet->setDataValidation($key, null); + $worksheet->setDataValidation($cellAddress, null); } } } @@ -258,16 +225,13 @@ class ReferenceHelper * Update merged cells when inserting/deleting rows/columns. * * @param Worksheet $worksheet The worksheet that we're editing - * @param string $beforeCellAddress Insert/Delete before this cell address (e.g. 'A1') - * @param int $numberOfColumns Number of columns to insert/delete (negative values indicate deletion) - * @param int $numberOfRows Number of rows to insert/delete (negative values indicate deletion) */ - protected function adjustMergeCells(Worksheet $worksheet, $beforeCellAddress, $numberOfColumns, $numberOfRows): void + protected function adjustMergeCells(Worksheet $worksheet): void { $aMergeCells = $worksheet->getMergeCells(); $aNewMergeCells = []; // the new array of all merge cells - foreach ($aMergeCells as $key => &$value) { - $newReference = $this->updateCellReference($key, $beforeCellAddress, $numberOfColumns, $numberOfRows); + foreach ($aMergeCells as $cellAddress => &$value) { + $newReference = $this->updateCellReference($cellAddress); $aNewMergeCells[$newReference] = $newReference; } $worksheet->setMergeCells($aNewMergeCells); // replace the merge cells array @@ -277,20 +241,20 @@ class ReferenceHelper * Update protected cells when inserting/deleting rows/columns. * * @param Worksheet $worksheet The worksheet that we're editing - * @param string $beforeCellAddress Insert/Delete before this cell address (e.g. 'A1') * @param int $numberOfColumns Number of columns to insert/delete (negative values indicate deletion) * @param int $numberOfRows Number of rows to insert/delete (negative values indicate deletion) */ - protected function adjustProtectedCells(Worksheet $worksheet, $beforeCellAddress, $numberOfColumns, $numberOfRows): void + protected function adjustProtectedCells(Worksheet $worksheet, $numberOfColumns, $numberOfRows): void { $aProtectedCells = $worksheet->getProtectedCells(); - ($numberOfColumns > 0 || $numberOfRows > 0) ? - uksort($aProtectedCells, ['self', 'cellReverseSort']) : uksort($aProtectedCells, ['self', 'cellSort']); - foreach ($aProtectedCells as $key => $value) { - $newReference = $this->updateCellReference($key, $beforeCellAddress, $numberOfColumns, $numberOfRows); - if ($key != $newReference) { + ($numberOfColumns > 0 || $numberOfRows > 0) + ? uksort($aProtectedCells, [self::class, 'cellReverseSort']) + : uksort($aProtectedCells, [self::class, 'cellSort']); + foreach ($aProtectedCells as $cellAddress => $value) { + $newReference = $this->updateCellReference($cellAddress); + if ($cellAddress !== $newReference) { $worksheet->protectCells($newReference, $value, true); - $worksheet->unprotectCells($key); + $worksheet->unprotectCells($cellAddress); } } } @@ -299,18 +263,15 @@ class ReferenceHelper * Update column dimensions when inserting/deleting rows/columns. * * @param Worksheet $worksheet The worksheet that we're editing - * @param string $beforeCellAddress Insert/Delete before this cell address (e.g. 'A1') - * @param int $numberOfColumns Number of columns to insert/delete (negative values indicate deletion) - * @param int $numberOfRows Number of rows to insert/delete (negative values indicate deletion) */ - protected function adjustColumnDimensions(Worksheet $worksheet, $beforeCellAddress, $numberOfColumns, $numberOfRows): void + protected function adjustColumnDimensions(Worksheet $worksheet): void { $aColumnDimensions = array_reverse($worksheet->getColumnDimensions(), true); if (!empty($aColumnDimensions)) { foreach ($aColumnDimensions as $objColumnDimension) { - $newReference = $this->updateCellReference($objColumnDimension->getColumnIndex() . '1', $beforeCellAddress, $numberOfColumns, $numberOfRows); + $newReference = $this->updateCellReference($objColumnDimension->getColumnIndex() . '1'); [$newReference] = Coordinate::coordinateFromString($newReference); - if ($objColumnDimension->getColumnIndex() != $newReference) { + if ($objColumnDimension->getColumnIndex() !== $newReference) { $objColumnDimension->setColumnIndex($newReference); } } @@ -322,20 +283,19 @@ class ReferenceHelper * Update row dimensions when inserting/deleting rows/columns. * * @param Worksheet $worksheet The worksheet that we're editing - * @param string $beforeCellAddress Insert/Delete before this cell address (e.g. 'A1') - * @param int $numberOfColumns Number of columns to insert/delete (negative values indicate deletion) * @param int $beforeRow Number of the row we're inserting/deleting before * @param int $numberOfRows Number of rows to insert/delete (negative values indicate deletion) */ - protected function adjustRowDimensions(Worksheet $worksheet, $beforeCellAddress, $numberOfColumns, $beforeRow, $numberOfRows): void + protected function adjustRowDimensions(Worksheet $worksheet, $beforeRow, $numberOfRows): void { $aRowDimensions = array_reverse($worksheet->getRowDimensions(), true); if (!empty($aRowDimensions)) { foreach ($aRowDimensions as $objRowDimension) { - $newReference = $this->updateCellReference('A' . $objRowDimension->getRowIndex(), $beforeCellAddress, $numberOfColumns, $numberOfRows); + $newReference = $this->updateCellReference('A' . $objRowDimension->getRowIndex()); [, $newReference] = Coordinate::coordinateFromString($newReference); - if ($objRowDimension->getRowIndex() != $newReference) { - $objRowDimension->setRowIndex($newReference); + $newRoweference = (int) $newReference; + if ($objRowDimension->getRowIndex() !== $newRoweference) { + $objRowDimension->setRowIndex($newRoweference); } } $worksheet->refreshRowDimensions(); @@ -368,6 +328,13 @@ class ReferenceHelper $remove = ($numberOfColumns < 0 || $numberOfRows < 0); $allCoordinates = $worksheet->getCoordinates(); + if ( + $this->cellReferenceHelper === null || + $this->cellReferenceHelper->refreshRequired($beforeCellAddress, $numberOfColumns, $numberOfRows) + ) { + $this->cellReferenceHelper = new CellReferenceHelper($beforeCellAddress, $numberOfColumns, $numberOfRows); + } + // Get coordinate of $beforeCellAddress [$beforeColumn, $beforeRow] = Coordinate::indexesFromString($beforeCellAddress); @@ -427,7 +394,7 @@ class ReferenceHelper $worksheet->getCell($newCoordinate)->setXfIndex($cell->getXfIndex()); // Insert this cell at its new location - if ($cell->getDataType() == DataType::TYPE_FORMULA) { + if ($cell->getDataType() === DataType::TYPE_FORMULA) { // Formula should be adjusted $worksheet->getCell($newCoordinate) ->setValue($this->updateFormulaReferences($cell->getValue(), $beforeCellAddress, $numberOfColumns, $numberOfRows, $worksheet->getTitle())); @@ -441,7 +408,7 @@ class ReferenceHelper } else { /* We don't need to update styles for rows/columns before our insertion position, but we do still need to adjust any formulae in those cells */ - if ($cell->getDataType() == DataType::TYPE_FORMULA) { + if ($cell->getDataType() === DataType::TYPE_FORMULA) { // Formula should be adjusted $cell->setValue($this->updateFormulaReferences($cell->getValue(), $beforeCellAddress, $numberOfColumns, $numberOfRows, $worksheet->getTitle())); } @@ -461,39 +428,39 @@ class ReferenceHelper } // Update worksheet: column dimensions - $this->adjustColumnDimensions($worksheet, $beforeCellAddress, $numberOfColumns, $numberOfRows); + $this->adjustColumnDimensions($worksheet); // Update worksheet: row dimensions - $this->adjustRowDimensions($worksheet, $beforeCellAddress, $numberOfColumns, $beforeRow, $numberOfRows); + $this->adjustRowDimensions($worksheet, $beforeRow, $numberOfRows); // Update worksheet: page breaks - $this->adjustPageBreaks($worksheet, $beforeCellAddress, $beforeColumn, $numberOfColumns, $beforeRow, $numberOfRows); + $this->adjustPageBreaks($worksheet, $numberOfColumns, $numberOfRows); // Update worksheet: comments - $this->adjustComments($worksheet, $beforeCellAddress, $beforeColumn, $numberOfColumns, $beforeRow, $numberOfRows); + $this->adjustComments($worksheet); // Update worksheet: hyperlinks - $this->adjustHyperlinks($worksheet, $beforeCellAddress, $numberOfColumns, $numberOfRows); + $this->adjustHyperlinks($worksheet, $numberOfColumns, $numberOfRows); // Update worksheet: data validations - $this->adjustDataValidations($worksheet, $beforeCellAddress, $numberOfColumns, $numberOfRows); + $this->adjustDataValidations($worksheet, $numberOfColumns, $numberOfRows); // Update worksheet: merge cells - $this->adjustMergeCells($worksheet, $beforeCellAddress, $numberOfColumns, $numberOfRows); + $this->adjustMergeCells($worksheet); // Update worksheet: protected cells - $this->adjustProtectedCells($worksheet, $beforeCellAddress, $numberOfColumns, $numberOfRows); + $this->adjustProtectedCells($worksheet, $numberOfColumns, $numberOfRows); // Update worksheet: autofilter - $this->adjustAutoFilter($worksheet, $beforeCellAddress, $numberOfColumns, $numberOfRows); + $this->adjustAutoFilter($worksheet, $beforeCellAddress, $numberOfColumns); // Update worksheet: freeze pane if ($worksheet->getFreezePane()) { $splitCell = $worksheet->getFreezePane() ?? ''; $topLeftCell = $worksheet->getTopLeftCell() ?? ''; - $splitCell = $this->updateCellReference($splitCell, $beforeCellAddress, $numberOfColumns, $numberOfRows); - $topLeftCell = $this->updateCellReference($topLeftCell, $beforeCellAddress, $numberOfColumns, $numberOfRows); + $splitCell = $this->updateCellReference($splitCell); + $topLeftCell = $this->updateCellReference($topLeftCell); $worksheet->freezePane($splitCell, $topLeftCell); } @@ -501,14 +468,14 @@ class ReferenceHelper // Page setup if ($worksheet->getPageSetup()->isPrintAreaSet()) { $worksheet->getPageSetup()->setPrintArea( - $this->updateCellReference($worksheet->getPageSetup()->getPrintArea(), $beforeCellAddress, $numberOfColumns, $numberOfRows) + $this->updateCellReference($worksheet->getPageSetup()->getPrintArea()) ); } // Update worksheet: drawings $aDrawings = $worksheet->getDrawingCollection(); foreach ($aDrawings as $objDrawing) { - $newReference = $this->updateCellReference($objDrawing->getCoordinates(), $beforeCellAddress, $numberOfColumns, $numberOfRows); + $newReference = $this->updateCellReference($objDrawing->getCoordinates()); if ($objDrawing->getCoordinates() != $newReference) { $objDrawing->setCoordinates($newReference); } @@ -518,7 +485,7 @@ class ReferenceHelper if (count($worksheet->getParent()->getDefinedNames()) > 0) { foreach ($worksheet->getParent()->getDefinedNames() as $definedName) { if ($definedName->getWorksheet() !== null && $definedName->getWorksheet()->getHashCode() === $worksheet->getHashCode()) { - $definedName->setValue($this->updateCellReference($definedName->getValue(), $beforeCellAddress, $numberOfColumns, $numberOfRows)); + $definedName->setValue($this->updateCellReference($definedName->getValue())); } } } @@ -540,6 +507,13 @@ class ReferenceHelper */ public function updateFormulaReferences($formula = '', $beforeCellAddress = 'A1', $numberOfColumns = 0, $numberOfRows = 0, $worksheetName = '') { + if ( + $this->cellReferenceHelper === null || + $this->cellReferenceHelper->refreshRequired($beforeCellAddress, $numberOfColumns, $numberOfRows) + ) { + $this->cellReferenceHelper = new CellReferenceHelper($beforeCellAddress, $numberOfColumns, $numberOfRows); + } + // Update cell references in the formula $formulaBlocks = explode('"', $formula); $i = false; @@ -549,13 +523,13 @@ class ReferenceHelper $adjustCount = 0; $newCellTokens = $cellTokens = []; // Search for row ranges (e.g. 'Sheet1'!3:5 or 3:5) with or without $ absolutes (e.g. $3:5) - $matchCount = preg_match_all('/' . self::REFHELPER_REGEXP_ROWRANGE . '/i', ' ' . $formulaBlock . ' ', $matches, PREG_SET_ORDER); + $matchCount = preg_match_all('/' . self::REFHELPER_REGEXP_ROWRANGE . '/mui', ' ' . $formulaBlock . ' ', $matches, PREG_SET_ORDER); if ($matchCount > 0) { foreach ($matches as $match) { $fromString = ($match[2] > '') ? $match[2] . '!' : ''; $fromString .= $match[3] . ':' . $match[4]; - $modified3 = substr($this->updateCellReference('$A' . $match[3], $beforeCellAddress, $numberOfColumns, $numberOfRows), 2); - $modified4 = substr($this->updateCellReference('$A' . $match[4], $beforeCellAddress, $numberOfColumns, $numberOfRows), 2); + $modified3 = substr($this->updateCellReference('$A' . $match[3]), 2); + $modified4 = substr($this->updateCellReference('$A' . $match[4]), 2); if ($match[3] . ':' . $match[4] !== $modified3 . ':' . $modified4) { if (($match[2] == '') || (trim($match[2], "'") == $worksheetName)) { @@ -574,13 +548,13 @@ class ReferenceHelper } } // Search for column ranges (e.g. 'Sheet1'!C:E or C:E) with or without $ absolutes (e.g. $C:E) - $matchCount = preg_match_all('/' . self::REFHELPER_REGEXP_COLRANGE . '/i', ' ' . $formulaBlock . ' ', $matches, PREG_SET_ORDER); + $matchCount = preg_match_all('/' . self::REFHELPER_REGEXP_COLRANGE . '/mui', ' ' . $formulaBlock . ' ', $matches, PREG_SET_ORDER); if ($matchCount > 0) { foreach ($matches as $match) { $fromString = ($match[2] > '') ? $match[2] . '!' : ''; $fromString .= $match[3] . ':' . $match[4]; - $modified3 = substr($this->updateCellReference($match[3] . '$1', $beforeCellAddress, $numberOfColumns, $numberOfRows), 0, -2); - $modified4 = substr($this->updateCellReference($match[4] . '$1', $beforeCellAddress, $numberOfColumns, $numberOfRows), 0, -2); + $modified3 = substr($this->updateCellReference($match[3] . '$1'), 0, -2); + $modified4 = substr($this->updateCellReference($match[4] . '$1'), 0, -2); if ($match[3] . ':' . $match[4] !== $modified3 . ':' . $modified4) { if (($match[2] == '') || (trim($match[2], "'") == $worksheetName)) { @@ -599,13 +573,13 @@ class ReferenceHelper } } // Search for cell ranges (e.g. 'Sheet1'!A3:C5 or A3:C5) with or without $ absolutes (e.g. $A1:C$5) - $matchCount = preg_match_all('/' . self::REFHELPER_REGEXP_CELLRANGE . '/i', ' ' . $formulaBlock . ' ', $matches, PREG_SET_ORDER); + $matchCount = preg_match_all('/' . self::REFHELPER_REGEXP_CELLRANGE . '/mui', ' ' . $formulaBlock . ' ', $matches, PREG_SET_ORDER); if ($matchCount > 0) { foreach ($matches as $match) { $fromString = ($match[2] > '') ? $match[2] . '!' : ''; $fromString .= $match[3] . ':' . $match[4]; - $modified3 = $this->updateCellReference($match[3], $beforeCellAddress, $numberOfColumns, $numberOfRows); - $modified4 = $this->updateCellReference($match[4], $beforeCellAddress, $numberOfColumns, $numberOfRows); + $modified3 = $this->updateCellReference($match[3]); + $modified4 = $this->updateCellReference($match[4]); if ($match[3] . $match[4] !== $modified3 . $modified4) { if (($match[2] == '') || (trim($match[2], "'") == $worksheetName)) { @@ -625,14 +599,14 @@ class ReferenceHelper } } // Search for cell references (e.g. 'Sheet1'!A3 or C5) with or without $ absolutes (e.g. $A1 or C$5) - $matchCount = preg_match_all('/' . self::REFHELPER_REGEXP_CELLREF . '/i', ' ' . $formulaBlock . ' ', $matches, PREG_SET_ORDER); + $matchCount = preg_match_all('/' . self::REFHELPER_REGEXP_CELLREF . '/mui', ' ' . $formulaBlock . ' ', $matches, PREG_SET_ORDER); if ($matchCount > 0) { foreach ($matches as $match) { $fromString = ($match[2] > '') ? $match[2] . '!' : ''; $fromString .= $match[3]; - $modified3 = $this->updateCellReference($match[3], $beforeCellAddress, $numberOfColumns, $numberOfRows); + $modified3 = $this->updateCellReference($match[3]); if ($match[3] !== $modified3) { if (($match[2] == '') || (trim($match[2], "'") == $worksheetName)) { $toString = ($match[2] > '') ? $match[2] . '!' : ''; @@ -809,13 +783,10 @@ class ReferenceHelper * Update cell reference. * * @param string $cellReference Cell address or range of addresses - * @param string $beforeCellAddress Insert before this one - * @param int $numberOfColumns Number of columns to increment - * @param int $numberOfRows Number of rows to increment * * @return string Updated cell range */ - public function updateCellReference($cellReference = 'A1', $beforeCellAddress = 'A1', $numberOfColumns = 0, $numberOfRows = 0) + private function updateCellReference($cellReference = 'A1') { // Is it in another worksheet? Will not have to update anything. if (strpos($cellReference, '!') !== false) { @@ -823,10 +794,10 @@ class ReferenceHelper // Is it a range or a single cell? } elseif (!Coordinate::coordinateIsRange($cellReference)) { // Single cell - return $this->updateSingleCellReference($cellReference, $beforeCellAddress, $numberOfColumns, $numberOfRows); + return $this->cellReferenceHelper->updateCellReference($cellReference); } elseif (Coordinate::coordinateIsRange($cellReference)) { // Range - return $this->updateCellRange($cellReference, $beforeCellAddress, $numberOfColumns, $numberOfRows); + return $this->updateCellRange($cellReference); } // Return original @@ -865,13 +836,10 @@ class ReferenceHelper * Update cell range. * * @param string $cellRange Cell range (e.g. 'B2:D4', 'B:C' or '2:3') - * @param string $beforeCellAddress Insert before this one - * @param int $numberOfColumns Number of columns to increment - * @param int $numberOfRows Number of rows to increment * * @return string Updated cell range */ - private function updateCellRange($cellRange = 'A1:A1', $beforeCellAddress = 'A1', $numberOfColumns = 0, $numberOfRows = 0) + private function updateCellRange(string $cellRange = 'A1:A1'): string { if (!Coordinate::coordinateIsRange($cellRange)) { throw new Exception('Only cell ranges may be passed to this method.'); @@ -884,13 +852,15 @@ class ReferenceHelper $jc = count($range[$i]); for ($j = 0; $j < $jc; ++$j) { if (ctype_alpha($range[$i][$j])) { - $r = Coordinate::coordinateFromString($this->updateSingleCellReference($range[$i][$j] . '1', $beforeCellAddress, $numberOfColumns, $numberOfRows)); - $range[$i][$j] = $r[0]; + $range[$i][$j] = Coordinate::coordinateFromString( + $this->cellReferenceHelper->updateCellReference($range[$i][$j] . '1') + )[0]; } elseif (ctype_digit($range[$i][$j])) { - $r = Coordinate::coordinateFromString($this->updateSingleCellReference('A' . $range[$i][$j], $beforeCellAddress, $numberOfColumns, $numberOfRows)); - $range[$i][$j] = $r[1]; + $range[$i][$j] = Coordinate::coordinateFromString( + $this->cellReferenceHelper->updateCellReference('A' . $range[$i][$j]) + )[1]; } else { - $range[$i][$j] = $this->updateSingleCellReference($range[$i][$j], $beforeCellAddress, $numberOfColumns, $numberOfRows); + $range[$i][$j] = $this->cellReferenceHelper->updateCellReference($range[$i][$j]); } } } @@ -899,46 +869,6 @@ class ReferenceHelper return Coordinate::buildRange($range); } - /** - * Update single cell reference. - * - * @param string $cellReference Single cell reference - * @param string $beforeCellAddress Insert before this one - * @param int $numberOfColumns Number of columns to increment - * @param int $numberOfRows Number of rows to increment - * - * @return string Updated cell reference - */ - private function updateSingleCellReference($cellReference = 'A1', $beforeCellAddress = 'A1', $numberOfColumns = 0, $numberOfRows = 0) - { - if (Coordinate::coordinateIsRange($cellReference)) { - throw new Exception('Only single cell references may be passed to this method.'); - } - - // Get coordinate of $beforeCellAddress - [$beforeColumn, $beforeRow] = Coordinate::coordinateFromString($beforeCellAddress); - - // Get coordinate of $cellReference - [$newColumn, $newRow] = Coordinate::coordinateFromString($cellReference); - - // Verify which parts should be updated - $updateColumn = (($newColumn[0] != '$') && ($beforeColumn[0] != '$') && (Coordinate::columnIndexFromString($newColumn) >= Coordinate::columnIndexFromString($beforeColumn))); - $updateRow = (($newRow[0] != '$') && ($beforeRow[0] != '$') && $newRow >= $beforeRow); - - // Create new column reference - if ($updateColumn) { - $newColumn = Coordinate::stringFromColumnIndex(Coordinate::columnIndexFromString($newColumn) + $numberOfColumns); - } - - // Create new row reference - if ($updateRow) { - $newRow = (int) $newRow + $numberOfRows; - } - - // Return new reference - return $newColumn . $newRow; - } - private function clearColumnStrips(int $highestRow, int $beforeColumn, int $numberOfColumns, Worksheet $worksheet): void { for ($i = 1; $i <= $highestRow - 1; ++$i) { @@ -969,7 +899,7 @@ class ReferenceHelper } } - private function adjustAutoFilter(Worksheet $worksheet, string $beforeCellAddress, int $numberOfColumns, int $numberOfRows): void + private function adjustAutoFilter(Worksheet $worksheet, string $beforeCellAddress, int $numberOfColumns): void { $autoFilter = $worksheet->getAutoFilter(); $autoFilterRange = $autoFilter->getRange(); @@ -999,7 +929,7 @@ class ReferenceHelper } $worksheet->setAutoFilter( - $this->updateCellReference($autoFilterRange, $beforeCellAddress, $numberOfColumns, $numberOfRows) + $this->updateCellReference($autoFilterRange) ); } } diff --git a/tests/PhpSpreadsheetTests/ReferenceHelperTest.php b/tests/PhpSpreadsheetTests/ReferenceHelperTest.php index 2c016b0b..e38ba286 100644 --- a/tests/PhpSpreadsheetTests/ReferenceHelperTest.php +++ b/tests/PhpSpreadsheetTests/ReferenceHelperTest.php @@ -3,8 +3,11 @@ namespace PhpOffice\PhpSpreadsheetTests; use PhpOffice\PhpSpreadsheet\Cell\DataType; +use PhpOffice\PhpSpreadsheet\Cell\Hyperlink; +use PhpOffice\PhpSpreadsheet\Comment; use PhpOffice\PhpSpreadsheet\ReferenceHelper; use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PHPUnit\Framework\TestCase; class ReferenceHelperTest extends TestCase @@ -177,4 +180,120 @@ class ReferenceHelperTest extends TestCase self::assertNull($cells[1][1]); self::assertArrayNotHasKey(2, $cells[1]); } + + public function testInsertRowsWithPageBreaks(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->fromArray([[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]], null, 'A1', true); + $sheet->setBreak('A2', Worksheet::BREAK_ROW); + $sheet->setBreak('A5', Worksheet::BREAK_ROW); + + $sheet->insertNewRowBefore(2, 2); + + $breaks = $sheet->getBreaks(); + ksort($breaks); + self::assertSame(['A4' => Worksheet::BREAK_ROW, 'A7' => Worksheet::BREAK_ROW], $breaks); + } + + public function testDeleteRowsWithPageBreaks(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->fromArray([[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]], null, 'A1', true); + $sheet->setBreak('A2', Worksheet::BREAK_ROW); + $sheet->setBreak('A5', Worksheet::BREAK_ROW); + + $sheet->removeRow(2, 2); + + $breaks = $sheet->getBreaks(); + self::assertSame(['A3' => Worksheet::BREAK_ROW], $breaks); + } + + public function testInsertRowsWithComments(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->fromArray([[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]], null, 'A1', true); + $sheet->getComment('A2')->getText()->createText('First Comment'); + $sheet->getComment('A5')->getText()->createText('Second Comment'); + + $sheet->insertNewRowBefore(2, 2); + + $comments = array_map( + function (Comment $value) { + return $value->getText()->getPlainText(); + }, + $sheet->getComments() + ); + + self::assertSame(['A4' => 'First Comment', 'A7' => 'Second Comment'], $comments); + } + + public function testDeleteRowsWithComments(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->fromArray([[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]], null, 'A1', true); + $sheet->getComment('A2')->getText()->createText('First Comment'); + $sheet->getComment('A5')->getText()->createText('Second Comment'); + + $sheet->removeRow(2, 2); + + $comments = array_map( + function (Comment $value) { + return $value->getText()->getPlainText(); + }, + $sheet->getComments() + ); + + self::assertSame(['A3' => 'Second Comment'], $comments); + } + + public function testInsertRowsWithHyperlinks(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->fromArray([[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]], null, 'A1', true); + $sheet->getCell('A2')->getHyperlink()->setUrl('https://github.com/PHPOffice/PhpSpreadsheet'); + $sheet->getCell('A5')->getHyperlink()->setUrl('https://phpspreadsheet.readthedocs.io/en/latest/'); + + $sheet->insertNewRowBefore(2, 2); + + $hyperlinks = array_map( + function (Hyperlink $value) { + return $value->getUrl(); + }, + $sheet->getHyperlinkCollection() + ); + ksort($hyperlinks); + + self::assertSame( + [ + 'A4' => 'https://github.com/PHPOffice/PhpSpreadsheet', + 'A7' => 'https://phpspreadsheet.readthedocs.io/en/latest/', + ], + $hyperlinks + ); + } + + public function testDeleteRowsWithHyperlinks(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->fromArray([[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]], null, 'A1', true); + $sheet->getCell('A2')->getHyperlink()->setUrl('https://github.com/PHPOffice/PhpSpreadsheet'); + $sheet->getCell('A5')->getHyperlink()->setUrl('https://phpspreadsheet.readthedocs.io/en/latest/'); + + $sheet->removeRow(2, 2); + + $hyperlinks = array_map( + function (Hyperlink $value) { + return $value->getUrl(); + }, + $sheet->getHyperlinkCollection() + ); + + self::assertSame(['A3' => 'https://phpspreadsheet.readthedocs.io/en/latest/'], $hyperlinks); + } } diff --git a/tests/data/ReferenceHelperFormulaUpdates.php b/tests/data/ReferenceHelperFormulaUpdates.php index d0835603..ef563f31 100644 --- a/tests/data/ReferenceHelperFormulaUpdates.php +++ b/tests/data/ReferenceHelperFormulaUpdates.php @@ -22,6 +22,34 @@ return [ '2020', '=SUM(A1:C3)', ], + 'column range' => [ + '=SUM(B:C)', + 2, + 0, + '2020', + '=SUM(D:E)', + ], + 'column range with absolute' => [ + '=SUM($B:C)', + 2, + 0, + '2020', + '=SUM($B:E)', + ], + 'row range' => [ + '=SUM(2:3)', + 0, + 2, + '2020', + '=SUM(4:5)', + ], + 'row range with absolute' => [ + '=SUM($2:3)', + 0, + 2, + '2020', + '=SUM($2:5)', + ], [ '=SUM(2020!C3:E5,2019!C3:E5)', -2,