Skip to content

Commit 90422bf

Browse files
authored
R1C1 Format and Internationalization, plus Relative Offsets (#3052)
* R1C1 Format and Internationalization, plus Relative Offsets Fix #1704, albeit imperfectly. Excel's implementation of this feature makes it impossible to fix perfectly. I don't know why it was necessary to internationalize R1C1 in the first place - the benefits are so minimal,and the result is worksheets that break when opened in different locales. Ugh. I can't even find complete documentation about the format in different languages; I am using https://answers.microsoft.com/en-us/officeinsider/forum/all/indirect-function-is-broken-at-least-for-excel-in/1fcbcf20-a103-4172-abf1-2c0dfe848e60 as my definitive reference. This fix concentrates on the original report, using the INDIRECT function; there may be other areas similarly affected. As with ambiguous date formats, PhpSpreadsheet will do a little better than Excel itself when reading spreadsheets with internationalized R1C1 by trying all possibilities before giving up. When it does give up, it will now return `#REF!`, as Excel does, rather than throwing an exception, which is certainly friendlier. Although read now works better, when writing it will use whatever the user specified, so spreadsheets breaking in the wrong locale will still happen. There were some bugs that turned up as I added test cases, all of them concerning relative addressing in R1C1 format, e.g. `R[+1]C[-1]`. The regexp for validating the format allowed for minus signs, but not plus signs. Also, the relevant functions did not allow for passing the current cell address, which made relative addressing impossible. The code now allows these, and suitable test cases are added. * Use Locale for Formats, but Not for XML Implementing a suggestion from @MarkBaker to use the system locale for determining R1C1 format rather than looping through a set of regexes and accepting any that work. This is closer to how Excel itself operates. The assumption we are making is to use the first character of the translated ROW and COLUMN functions. This will not work for Russian or Bulgarian, where each starts with the same letter, but it appears that Russian, at least, still uses R1C1. So our algorithm will not use non-ASCII characters, nor characters where ROW and COLUMN start with the same letter, falling back to R/C in those cases. Turkish falls into that category. Czech uses an accented character for one of the functions, and I'm guessing to use the unaccented character in that case. Polish COLUMN function is NR.KOLUMNY, and I'm guessing to use K in that case. The function that converts R1C1 references is also used by the XML reader *where the format is always R1C1*, not locale-based (confirmed by successfully opening in Excel an XML spreadsheet when my language is set to French). The conversion code now handles that distinction through the use of an extra parameter. Xml Reader Load Test is duplicated to confirm that spreadsheet is loaded properly whether the locale is English or French. (No, I did not add an INDIRECT function to the Xml spreadsheet.) Tests CsvIssue2232Test and TranslationTest both changed locale without resetting it when done. That omission was exposed by the new code, and both are now corrected. * OpenOffice and Gnumeric OpenOffice and Gnumeric make it much easier to test with other languages - they can be handled with an environment variable. Sensibly, they require R and C as the characters for R1C1 notation regardless of the language. Change code to recognize this difference from Excel. * Handle Output of ADDRESS Function One other function has to deal with R1C1 format as a string. Unlike INDIRECT, which receives the string on input, ADDRESS generates the string on output. Ensure that the ADDRESS output is consistent with the INDIRECT input. ADDRESS expects its 4th arg to be bool, but it can also accept int, and many examples on the net supply it as an int. This had not been handled properly, but is now corrected. * More Structured Test I earlier introduced a new test for relative R1C1 addressing. Rewrite it to be clearer. * Add Row for This to Locale Spreadsheet It took a while for me to figure out how it all works. I have added a new row (with English value `*RC`) to Translations.xlsx, in the "Lookup and Reference" section of sheet "Excel Functions". By starting the "function name" with an asterisk, it will not be confused with a "real" function (confirmed by a new test). This approach also gives us the flexibility to do something similar if another surprise case occurs in future; in particular, I think this is more flexible than adding this as another option on the "Excel Localisation" sheet. It also means that any errors or omissions in the list below will be handled as with any other translation problem, by updating the spreadsheet without needing to touch any code. The spreadsheet has the following entries in the *RC row: - first letter of ROW/COLUMN functions for da, de, es, fi, fr, hu, nl, nb, pt, pt_br, sv - no value for locales where ROW/COLUMN functions start with same letter - bg, ru, tr - no value for locales with a multi-part name for ROW and/or COLUMN - it, pl (I had not previously noted Italian as an exception) - no value for locales where ROW and/or COLUMN starts with a non-ASCII character - cs (this would also apply to bg and ru which are already included under "same letter") - it does nothing for locales which are defined on the "Excel Localisation" sheet but have no entries yet on the "Excel Functions" sheet (e.g. eu) Note that all but the first bullet item will continue to use R/C, which leaves them no worse off than they were before this change.
1 parent a846a93 commit 90422bf

29 files changed

+436
-38
lines changed

infra/LocaleGenerator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ protected function buildFunctionsFileForLocale($column, $locale): void
146146
$translationValue = $translationCell->getValue();
147147
if ($this->isFunctionCategoryEntry($translationCell)) {
148148
$this->writeFileSectionHeader($functionFile, "{$translationValue} ({$functionName})");
149-
} elseif (!array_key_exists($functionName, $this->phpSpreadsheetFunctions)) {
149+
} elseif (!array_key_exists($functionName, $this->phpSpreadsheetFunctions) && substr($functionName, 0, 1) !== '*') {
150150
$this->log("Function {$functionName} is not defined in PhpSpreadsheet");
151151
} elseif (!empty($translationValue)) {
152152
$functionTranslation = "{$functionName} = {$translationValue}" . self::EOL;

src/PhpSpreadsheet/Calculation/Calculation.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3110,7 +3110,7 @@ public function setLocale(string $locale)
31103110
[$localeFunction] = explode('##', $localeFunction); // Strip out comments
31113111
if (strpos($localeFunction, '=') !== false) {
31123112
[$fName, $lfName] = array_map('trim', explode('=', $localeFunction));
3113-
if ((isset(self::$phpSpreadsheetFunctions[$fName])) && ($lfName != '') && ($fName != $lfName)) {
3113+
if ((substr($fName, 0, 1) === '*' || isset(self::$phpSpreadsheetFunctions[$fName])) && ($lfName != '') && ($fName != $lfName)) {
31143114
self::$localeFunctions[$fName] = $lfName;
31153115
}
31163116
}

src/PhpSpreadsheet/Calculation/LookupRef/Address.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
66
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
7+
use PhpOffice\PhpSpreadsheet\Cell\AddressHelper;
78
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
89

910
class Address
@@ -72,6 +73,9 @@ public static function cell($row, $column, $relativity = 1, $referenceStyle = tr
7273

7374
$sheetName = self::sheetName($sheetName);
7475

76+
if (is_int($referenceStyle)) {
77+
$referenceStyle = (bool) $referenceStyle;
78+
}
7579
if ((!is_bool($referenceStyle)) || $referenceStyle === self::REFERENCE_STYLE_A1) {
7680
return self::formatAsA1($row, $column, $relativity, $sheetName);
7781
}
@@ -113,7 +117,8 @@ private static function formatAsR1C1(int $row, int $column, int $relativity, str
113117
if (($relativity == self::ADDRESS_ROW_RELATIVE) || ($relativity == self::ADDRESS_RELATIVE)) {
114118
$row = "[{$row}]";
115119
}
120+
[$rowChar, $colChar] = AddressHelper::getRowAndColumnChars();
116121

117-
return "{$sheetName}R{$row}C{$column}";
122+
return "{$sheetName}$rowChar{$row}$colChar{$column}";
118123
}
119124
}

src/PhpSpreadsheet/Calculation/LookupRef/Helpers.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ class Helpers
1313

1414
public const CELLADDRESS_USE_R1C1 = false;
1515

16-
private static function convertR1C1(string &$cellAddress1, ?string &$cellAddress2, bool $a1): string
16+
private static function convertR1C1(string &$cellAddress1, ?string &$cellAddress2, bool $a1, ?int $baseRow = null, ?int $baseCol = null): string
1717
{
1818
if ($a1 === self::CELLADDRESS_USE_R1C1) {
19-
$cellAddress1 = AddressHelper::convertToA1($cellAddress1);
19+
$cellAddress1 = AddressHelper::convertToA1($cellAddress1, $baseRow ?? 1, $baseCol ?? 1);
2020
if ($cellAddress2) {
21-
$cellAddress2 = AddressHelper::convertToA1($cellAddress2);
21+
$cellAddress2 = AddressHelper::convertToA1($cellAddress2, $baseRow ?? 1, $baseCol ?? 1);
2222
}
2323
}
2424

@@ -35,7 +35,7 @@ private static function adjustSheetTitle(string &$sheetTitle, ?string $value): v
3535
}
3636
}
3737

38-
public static function extractCellAddresses(string $cellAddress, bool $a1, Worksheet $sheet, string $sheetName = ''): array
38+
public static function extractCellAddresses(string $cellAddress, bool $a1, Worksheet $sheet, string $sheetName = '', ?int $baseRow = null, ?int $baseCol = null): array
3939
{
4040
$cellAddress1 = $cellAddress;
4141
$cellAddress2 = null;
@@ -52,7 +52,7 @@ public static function extractCellAddresses(string $cellAddress, bool $a1, Works
5252
if (strpos($cellAddress, ':') !== false) {
5353
[$cellAddress1, $cellAddress2] = explode(':', $cellAddress);
5454
}
55-
$cellAddress = self::convertR1C1($cellAddress1, $cellAddress2, $a1);
55+
$cellAddress = self::convertR1C1($cellAddress1, $cellAddress2, $a1, $baseRow, $baseCol);
5656

5757
return [$cellAddress1, $cellAddress2, $cellAddress];
5858
}

src/PhpSpreadsheet/Calculation/LookupRef/Indirect.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
88
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
99
use PhpOffice\PhpSpreadsheet\Cell\Cell;
10+
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
1011
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
1112

1213
class Indirect
@@ -63,6 +64,8 @@ private static function validateAddress($cellAddress): string
6364
*/
6465
public static function INDIRECT($cellAddress, $a1fmt, Cell $cell)
6566
{
67+
[$baseCol, $baseRow] = Coordinate::indexesFromString($cell->getCoordinate());
68+
6669
try {
6770
$a1 = self::a1Format($a1fmt);
6871
$cellAddress = self::validateAddress($cellAddress);
@@ -78,7 +81,11 @@ public static function INDIRECT($cellAddress, $a1fmt, Cell $cell)
7881
$cellAddress = self::handleRowColumnRanges($worksheet, ...explode(':', $cellAddress));
7982
}
8083

81-
[$cellAddress1, $cellAddress2, $cellAddress] = Helpers::extractCellAddresses($cellAddress, $a1, $cell->getWorkSheet(), $sheetName);
84+
try {
85+
[$cellAddress1, $cellAddress2, $cellAddress] = Helpers::extractCellAddresses($cellAddress, $a1, $cell->getWorkSheet(), $sheetName, $baseRow, $baseCol);
86+
} catch (Exception $e) {
87+
return ExcelError::REF();
88+
}
8289

8390
if (
8491
(!preg_match('/^' . Calculation::CALCULATION_REGEXP_CELLREF . '$/miu', $cellAddress1, $matches)) ||
Binary file not shown.

src/PhpSpreadsheet/Calculation/locale/da/functions

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ ROWS = RÆKKER
245245
RTD = RTD
246246
TRANSPOSE = TRANSPONER
247247
VLOOKUP = LOPSLAG
248+
*RC = RK
248249

249250
##
250251
## Matematiske og trigonometriske funktioner (Math & Trig Functions)

src/PhpSpreadsheet/Calculation/locale/de/functions

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ ROWS = ZEILEN
243243
RTD = RTD
244244
TRANSPOSE = MTRANS
245245
VLOOKUP = SVERWEIS
246+
*RC = ZS
246247

247248
##
248249
## Mathematische und trigonometrische Funktionen (Math & Trig Functions)

src/PhpSpreadsheet/Calculation/locale/es/functions

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ ROWS = FILAS
245245
RTD = RDTR
246246
TRANSPOSE = TRANSPONER
247247
VLOOKUP = BUSCARV
248+
*RC = FC
248249

249250
##
250251
## Funciones matemáticas y trigonométricas (Math & Trig Functions)

src/PhpSpreadsheet/Calculation/locale/fi/functions

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ ROWS = RIVIT
245245
RTD = RTD
246246
TRANSPOSE = TRANSPONOI
247247
VLOOKUP = PHAKU
248+
*RC = RS
248249

249250
##
250251
## Matemaattiset ja trigonometriset funktiot (Math & Trig Functions)

0 commit comments

Comments
 (0)