Formula Misidentifying Text as Cell After Insertion/Deletion

Fix #3907. After row/column insertion/deletion, PhpSpreadsheet updates formulas which include cells which have moved. However, it can mis-identify cell addresses within the formula. Examples:
- `=SUM(A2,'F1 (SETTINGS)'!A1:B1)` It identifes F1 as a cell address.
- `=SUM(A2,'x F1 (SETTINGS)'!A1:B1)` It identifes F1 as a cell address. (This looks the same as the above, but, for technical reasons, it's different.)
- `=SUM(A2,definedname1A1)` It identifes A1 as a cell address.
- Sheet names in formulas are compared case-sensitively, and should be compared insensitively. This can make a difference if the formula includes its own sheet name, e.g. on sheet `Data`, formula `=SUM(DATA!A1:A2)` might have to change, but it will not do so with the existing logic.

The defined name part is fairly straightforward. The regular expressions that identify a cell address just have to be a bit more robust. It was doing a negative look-behind for an alphabetic character or dollar sign; underscore, period, and digits, all of which can be part of a defined name, need to be added to that list.

The other situations need a bit of a kludge, but not one so bad that I'm ashamed of it. The formulas will be altered before analysis so that sheet names are replaced with Unicode FFFD (sheetname does not match current sheet), FFFC (sheetname, enclosed in apostrophes, matches current sheet), and FFFB (sheetname, not enclosed in apostrophes, matches current sheet). This prevents the existing regular expressions from finding a cell address within a sheet name, and makes it easy to restore the original, with or without apostrophes, when the sheet name matches the current sheet and the cell(s) which it qualifies have to be changed.

Tests are added for all the situations mentioned above. No existing tests required changes.
This commit is contained in:
oleibman
2024-02-21 22:10:58 -08:00
parent d620497511
commit a096fcc77d
2 changed files with 88 additions and 28 deletions
+40 -28
View File
@@ -15,10 +15,12 @@ class ReferenceHelper
{
/** Constants */
/** Regular Expressions */
const REFHELPER_REGEXP_CELLREF = '((\w*|\'[^!]*\')!)?(?<![:a-z\$])(\$?[a-z]{1,3}\$?\d+)(?=[^:!\d\'])';
const REFHELPER_REGEXP_CELLRANGE = '((\w*|\'[^!]*\')!)?(\$?[a-z]{1,3}\$?\d+):(\$?[a-z]{1,3}\$?\d+)';
const REFHELPER_REGEXP_ROWRANGE = '((\w*|\'[^!]*\')!)?(\$?\d+):(\$?\d+)';
const REFHELPER_REGEXP_COLRANGE = '((\w*|\'[^!]*\')!)?(\$?[a-z]{1,3}):(\$?[a-z]{1,3})';
private const SHEETNAME_PART = '((\w*|\'[^!]*\')!)';
private const SHEETNAME_PART_WITH_SLASHES = '/' . self::SHEETNAME_PART . '/';
const REFHELPER_REGEXP_CELLREF = self::SHEETNAME_PART . '?(?<![:a-z1-9_\.\$])(\$?[a-z]{1,3}\$?\d+)(?=[^:!\d\'])';
const REFHELPER_REGEXP_CELLRANGE = self::SHEETNAME_PART . '?(\$?[a-z]{1,3}\$?\d+):(\$?[a-z]{1,3}\$?\d+)';
const REFHELPER_REGEXP_ROWRANGE = self::SHEETNAME_PART . '?(\$?\d+):(\$?\d+)';
const REFHELPER_REGEXP_COLRANGE = self::SHEETNAME_PART . '?(\$?[a-z]{1,3}):(\$?[a-z]{1,3})';
/**
* Instance of this class.
@@ -545,6 +547,18 @@ class ReferenceHelper
$worksheet->garbageCollect();
}
private static function matchSheetName(?string $match, string $worksheetName): bool
{
return $match === null || $match === '' || $match === "'\u{fffc}'" || $match === "'\u{fffb}'" || strcasecmp(trim($match, "'"), $worksheetName) === 0;
}
private static function sheetnameBeforeCells(string $match, string $worksheetName, string $cells): string
{
$toString = ($match > '') ? "$match!" : '';
return str_replace(["\u{fffc}", "'\u{fffb}'"], $worksheetName, $toString) . $cells;
}
/**
* Update references within formulas.
*
@@ -565,6 +579,7 @@ class ReferenceHelper
bool $includeAbsoluteReferences = false,
bool $onlyAbsoluteReferences = false
): string {
$callback = fn (array $matches): string => (strcasecmp(trim($matches[2], "'"), $worksheetName) === 0) ? (($matches[2][0] === "'") ? "'\u{fffc}'!" : "'\u{fffb}'!") : "'\u{fffd}'!";
if (
$this->cellReferenceHelper === null
|| $this->cellReferenceHelper->refreshRequired($beforeCellAddress, $numberOfColumns, $numberOfRows)
@@ -582,18 +597,17 @@ 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 . '/mui', ' ' . $formulaBlock . ' ', $matches, PREG_SET_ORDER);
$formulaBlockx = ' ' . (preg_replace_callback(self::SHEETNAME_PART_WITH_SLASHES, $callback, $formulaBlock) ?? $formulaBlock) . ' ';
$matchCount = preg_match_all('/' . self::REFHELPER_REGEXP_ROWRANGE . '/mui', $formulaBlockx, $matches, PREG_SET_ORDER);
if ($matchCount > 0) {
foreach ($matches as $match) {
$fromString = ($match[2] > '') ? $match[2] . '!' : '';
$fromString .= $match[3] . ':' . $match[4];
$fromString = self::sheetnameBeforeCells($match[2], $worksheetName, "{$match[3]}:{$match[4]}");
$modified3 = substr($this->updateCellReference('$A' . $match[3], $includeAbsoluteReferences, $onlyAbsoluteReferences), 2);
$modified4 = substr($this->updateCellReference('$A' . $match[4], $includeAbsoluteReferences, $onlyAbsoluteReferences), 2);
if ($match[3] . ':' . $match[4] !== $modified3 . ':' . $modified4) {
if (($match[2] == '') || (trim($match[2], "'") == $worksheetName)) {
$toString = ($match[2] > '') ? $match[2] . '!' : '';
$toString .= $modified3 . ':' . $modified4;
if (self::matchSheetName($match[2], $worksheetName)) {
$toString = self::sheetnameBeforeCells($match[2], $worksheetName, "$modified3:$modified4");
// Max worksheet size is 1,048,576 rows by 16,384 columns in Excel 2007, so our adjustments need to be at least one digit more
$column = 100000;
$row = 10000000 + (int) trim($match[3], '$');
@@ -607,18 +621,17 @@ 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 . '/mui', ' ' . $formulaBlock . ' ', $matches, PREG_SET_ORDER);
$formulaBlockx = ' ' . (preg_replace_callback(self::SHEETNAME_PART_WITH_SLASHES, $callback, $formulaBlock) ?? $formulaBlock) . ' ';
$matchCount = preg_match_all('/' . self::REFHELPER_REGEXP_COLRANGE . '/mui', $formulaBlockx, $matches, PREG_SET_ORDER);
if ($matchCount > 0) {
foreach ($matches as $match) {
$fromString = ($match[2] > '') ? $match[2] . '!' : '';
$fromString .= $match[3] . ':' . $match[4];
$fromString = self::sheetnameBeforeCells($match[2], $worksheetName, "{$match[3]}:{$match[4]}");
$modified3 = substr($this->updateCellReference($match[3] . '$1', $includeAbsoluteReferences, $onlyAbsoluteReferences), 0, -2);
$modified4 = substr($this->updateCellReference($match[4] . '$1', $includeAbsoluteReferences, $onlyAbsoluteReferences), 0, -2);
if ($match[3] . ':' . $match[4] !== $modified3 . ':' . $modified4) {
if (($match[2] == '') || (trim($match[2], "'") == $worksheetName)) {
$toString = ($match[2] > '') ? $match[2] . '!' : '';
$toString .= $modified3 . ':' . $modified4;
if (self::matchSheetName($match[2], $worksheetName)) {
$toString = self::sheetnameBeforeCells($match[2], $worksheetName, "$modified3:$modified4");
// Max worksheet size is 1,048,576 rows by 16,384 columns in Excel 2007, so our adjustments need to be at least one digit more
$column = Coordinate::columnIndexFromString(trim($match[3], '$')) + 100000;
$row = 10000000;
@@ -632,18 +645,17 @@ 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 . '/mui', ' ' . $formulaBlock . ' ', $matches, PREG_SET_ORDER);
$formulaBlockx = ' ' . (preg_replace_callback(self::SHEETNAME_PART_WITH_SLASHES, $callback, "$formulaBlock") ?? "$formulaBlock") . ' ';
$matchCount = preg_match_all('/' . self::REFHELPER_REGEXP_CELLRANGE . '/mui', $formulaBlockx, $matches, PREG_SET_ORDER);
if ($matchCount > 0) {
foreach ($matches as $match) {
$fromString = ($match[2] > '') ? $match[2] . '!' : '';
$fromString .= $match[3] . ':' . $match[4];
$fromString = self::sheetnameBeforeCells($match[2], $worksheetName, "{$match[3]}:{$match[4]}");
$modified3 = $this->updateCellReference($match[3], $includeAbsoluteReferences, $onlyAbsoluteReferences);
$modified4 = $this->updateCellReference($match[4], $includeAbsoluteReferences, $onlyAbsoluteReferences);
if ($match[3] . $match[4] !== $modified3 . $modified4) {
if (($match[2] == '') || (trim($match[2], "'") == $worksheetName)) {
$toString = ($match[2] > '') ? $match[2] . '!' : '';
$toString .= $modified3 . ':' . $modified4;
if (self::matchSheetName($match[2], $worksheetName)) {
$toString = self::sheetnameBeforeCells($match[2], $worksheetName, "$modified3:$modified4");
[$column, $row] = Coordinate::coordinateFromString($match[3]);
// Max worksheet size is 1,048,576 rows by 16,384 columns in Excel 2007, so our adjustments need to be at least one digit more
$column = Coordinate::columnIndexFromString(trim($column, '$')) + 100000;
@@ -658,18 +670,18 @@ 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 . '/mui', ' ' . $formulaBlock . ' ', $matches, PREG_SET_ORDER);
$formulaBlockx = ' ' . (preg_replace_callback(self::SHEETNAME_PART_WITH_SLASHES, $callback, $formulaBlock) ?? $formulaBlock) . ' ';
$matchCount = preg_match_all('/' . self::REFHELPER_REGEXP_CELLREF . '/mui', $formulaBlockx, $matches, PREG_SET_ORDER);
if ($matchCount > 0) {
foreach ($matches as $match) {
$fromString = ($match[2] > '') ? $match[2] . '!' : '';
$fromString .= $match[3];
$fromString = self::sheetnameBeforeCells($match[2], $worksheetName, "{$match[3]}");
$modified3 = $this->updateCellReference($match[3], $includeAbsoluteReferences, $onlyAbsoluteReferences);
if ($match[3] !== $modified3) {
if (($match[2] == '') || (trim($match[2], "'") == $worksheetName)) {
$toString = ($match[2] > '') ? $match[2] . '!' : '';
$toString .= $modified3;
if (self::matchSheetName($match[2], $worksheetName)) {
$toString = self::sheetnameBeforeCells($match[2], $worksheetName, "$modified3");
[$column, $row] = Coordinate::coordinateFromString($match[3]);
$columnAdditionalIndex = $column[0] === '$' ? 1 : 0;
$rowAdditionalIndex = $row[0] === '$' ? 1 : 0;
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace PhpOffice\PhpSpreadsheetTests;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PHPUnit\Framework\TestCase;
class ReferenceHelper4Test extends TestCase
{
/**
* @dataProvider dataProvider
*/
public function testIssue3907(string $expectedResult, string $settingsTitle, string $formula, string $dataTitle = 'DATA'): void
{
$spreadsheet = new Spreadsheet();
$dataSheet = $spreadsheet->getActiveSheet();
$dataSheet->setTitle($dataTitle);
$settingsSheet = $spreadsheet->createSheet();
$settingsSheet->setTitle($settingsTitle);
$settingsSheet->getCell('A1')->setValue(10);
$settingsSheet->getCell('B1')->setValue(20);
$dataSheet->getCell('A5')->setValue($formula);
$dataSheet->getCell('A2')->setValue(1);
$dataSheet->insertNewColumnBefore('A');
self::assertSame($expectedResult, $dataSheet->getCell('B5')->getValue());
$spreadsheet->disconnectWorksheets();
}
public static function dataProvider(): array
{
return [
["=SUM(B2, 'F1 (SETTINGS)'!A1:B1)", 'F1 (SETTINGS)', "=SUM(A2, 'F1 (SETTINGS)'!A1:B1)"],
["=SUM(B2, 'x F1 (SETTINGS)'!A1:B1)", 'x F1 (SETTINGS)', "=SUM(A2, 'x F1 (SETTINGS)'!A1:B1)"],
["=SUM(B2, 'DATA'!B1)", 'F1 (SETTINGS)', "=SUM(A2, 'DATA'!A1)"],
["=SUM(B2, 'DATA'!C1)", 'F1 (SETTINGS)', "=SUM(A2, 'data'!B1)"],
['=SUM(B2, DATA!D1)', 'F1 (SETTINGS)', '=SUM(A2, data!C1)'],
["=SUM(B2, 'DATA'!B1:C1)", 'F1 (SETTINGS)', "=SUM(A2, 'Data'!A1:B1)"],
['=SUM(B2, DATA!B1:C1)', 'F1 (SETTINGS)', '=SUM(A2, DAta!A1:B1)'],
["=SUM(B2, 'F1 Data'!C1)", 'F1 (SETTINGS)', "=SUM(A2, 'F1 Data'!B1)", 'F1 Data'],
["=SUM(B2, 'x F1 Data'!C1)", 'F1 (SETTINGS)', "=SUM(A2, 'x F1 Data'!B1)", 'x F1 Data'],
["=SUM(B2, 'x F1 Data'!C1)", 'F1 (SETTINGS)', "=SUM(A2, 'x F1 Data'!B1)", 'x F1 Data'],
["=SUM(B2, 'x F1 Data'!C1:D2)", 'F1 (SETTINGS)', "=SUM(A2, 'x F1 Data'!B1:C2)", 'x F1 Data'],
['=SUM(B2, definedname1A1)', 'F1 (SETTINGS)', '=SUM(A2, definedname1A1)', 'x F1 Data'],
];
}
}