Files
PhpSpreadsheet/infra/LocaleGenerator.php
T
oleibman 90422bf1d2 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.
2022-09-16 08:25:26 -07:00

346 lines
13 KiB
PHP

<?php
namespace PhpOffice\PhpSpreadsheetInfra;
use Exception;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Column;
use PhpOffice\PhpSpreadsheet\Worksheet\Row;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class LocaleGenerator
{
private const EXCEL_LOCALISATION_WORKSHEET = 'Excel Localisation';
private const EXCEL_FUNCTIONS_WORKSHEET = 'Excel Functions';
private const LOCALE_NAME_ROW = 1;
private const LOCALE_LANGUAGE_NAME_ROW = 2;
private const ENGLISH_LANGUAGE_NAME_ROW = 3;
private const ARGUMENT_SEPARATOR_ROW = 5;
private const ERROR_CODES_FIRST_ROW = 8;
private const FUNCTION_NAME_LIST_FIRST_ROW = 4;
private const ENGLISH_FUNCTION_CATEGORIES_COLUMN = 'A';
private const ENGLISH_REFERENCE_COLUMN = 'B';
private const EOL = "\n"; // not PHP_EOL
/**
* @var string
*/
protected $translationSpreadsheetName;
/**
* @var string
*/
protected $translationBaseFolder;
protected $phpSpreadsheetFunctions;
/**
* @var Spreadsheet
*/
protected $translationSpreadsheet;
protected $verbose;
/**
* @var Worksheet
*/
protected $localeTranslations;
protected $localeLanguageMap = [];
protected $errorCodeMap = [];
/**
* @var Worksheet
*/
private $functionNameTranslations;
protected $functionNameLanguageMap = [];
protected $functionNameMap = [];
public function __construct(
string $translationBaseFolder,
string $translationSpreadsheetName,
array $phpSpreadsheetFunctions,
bool $verbose = false
) {
$this->translationBaseFolder = $translationBaseFolder;
$this->translationSpreadsheetName = $translationSpreadsheetName;
$this->phpSpreadsheetFunctions = $phpSpreadsheetFunctions;
$this->verbose = $verbose;
}
public function generateLocales(): void
{
$this->openTranslationWorkbook();
$this->localeTranslations = $this->getTranslationSheet(self::EXCEL_LOCALISATION_WORKSHEET);
$this->localeLanguageMap = $this->mapLanguageColumns($this->localeTranslations);
$this->mapErrorCodeRows();
$this->functionNameTranslations = $this->getTranslationSheet(self::EXCEL_FUNCTIONS_WORKSHEET);
$this->functionNameLanguageMap = $this->mapLanguageColumns($this->functionNameTranslations);
$this->mapFunctionNameRows();
foreach ($this->localeLanguageMap as $column => $locale) {
$this->buildConfigFileForLocale($column, $locale);
}
foreach ($this->functionNameLanguageMap as $column => $locale) {
$this->buildFunctionsFileForLocale($column, $locale);
}
}
protected function buildConfigFileForLocale($column, $locale): void
{
$language = $this->localeTranslations->getCell($column . self::ENGLISH_LANGUAGE_NAME_ROW)->getValue();
$localeLanguage = $this->localeTranslations->getCell($column . self::LOCALE_LANGUAGE_NAME_ROW)->getValue();
$configFile = $this->openConfigFile($locale, $language, $localeLanguage);
$this->writeConfigArgumentSeparator($configFile, $column);
$this->writeFileSectionHeader($configFile, 'Error Codes');
foreach ($this->errorCodeMap as $errorCode => $row) {
$translationCell = $this->localeTranslations->getCell($column . $row);
$translationValue = $translationCell->getValue();
if (!empty($translationValue)) {
$errorCodeTranslation = "{$errorCode} = {$translationValue}" . self::EOL;
fwrite($configFile, $errorCodeTranslation);
} else {
$errorCodeTranslation = "{$errorCode}" . self::EOL;
fwrite($configFile, $errorCodeTranslation);
$this->log("No {$language} translation available for error code {$errorCode}");
}
}
fclose($configFile);
}
protected function writeConfigArgumentSeparator($configFile, $column): void
{
$translationCell = $this->localeTranslations->getCell($column . self::ARGUMENT_SEPARATOR_ROW);
$localeValue = $translationCell->getValue();
if (!empty($localeValue)) {
$functionTranslation = "ArgumentSeparator = {$localeValue}" . self::EOL;
fwrite($configFile, $functionTranslation);
} else {
$this->log('No Argument Separator defined');
}
}
protected function buildFunctionsFileForLocale($column, $locale): void
{
$language = $this->functionNameTranslations->getCell($column . self::ENGLISH_LANGUAGE_NAME_ROW)->getValue();
$localeLanguage = $this->functionNameTranslations->getCell($column . self::LOCALE_LANGUAGE_NAME_ROW)
->getValue();
$functionFile = $this->openFunctionNameFile($locale, $language, $localeLanguage);
foreach ($this->functionNameMap as $functionName => $row) {
$translationCell = $this->functionNameTranslations->getCell($column . $row);
$translationValue = $translationCell->getValue();
if ($this->isFunctionCategoryEntry($translationCell)) {
$this->writeFileSectionHeader($functionFile, "{$translationValue} ({$functionName})");
} elseif (!array_key_exists($functionName, $this->phpSpreadsheetFunctions) && substr($functionName, 0, 1) !== '*') {
$this->log("Function {$functionName} is not defined in PhpSpreadsheet");
} elseif (!empty($translationValue)) {
$functionTranslation = "{$functionName} = {$translationValue}" . self::EOL;
fwrite($functionFile, $functionTranslation);
} else {
$this->log("No {$language} translation available for function {$functionName}");
}
}
fclose($functionFile);
}
protected function openConfigFile(string $locale, string $language, string $localeLanguage)
{
$this->log("Building locale {$locale} ($language) configuration");
$localeFolder = $this->getLocaleFolder($locale);
$configFileName = realpath($localeFolder . DIRECTORY_SEPARATOR . 'config');
$this->log("Writing locale configuration to {$configFileName}");
$configFile = fopen($configFileName, 'wb');
$this->writeFileHeader($configFile, $localeLanguage, $language, 'locale settings');
return $configFile;
}
protected function openFunctionNameFile(string $locale, string $language, string $localeLanguage)
{
$this->log("Building locale {$locale} ($language) function names");
$localeFolder = $this->getLocaleFolder($locale);
$functionFileName = realpath($localeFolder . DIRECTORY_SEPARATOR . 'functions');
$this->log("Writing local function names to {$functionFileName}");
$functionFile = fopen($functionFileName, 'wb');
$this->writeFileHeader($functionFile, $localeLanguage, $language, 'function name translations');
return $functionFile;
}
protected function getLocaleFolder(string $locale): string
{
$localeFolder = $this->translationBaseFolder .
DIRECTORY_SEPARATOR .
str_replace('_', DIRECTORY_SEPARATOR, $locale);
if (!file_exists($localeFolder) || !is_dir($localeFolder)) {
mkdir($localeFolder, 0777, true);
}
return $localeFolder;
}
protected function writeFileHeader($localeFile, string $localeLanguage, string $language, string $title): void
{
fwrite($localeFile, str_repeat('#', 60) . self::EOL);
fwrite($localeFile, '##' . self::EOL);
fwrite($localeFile, "## PhpSpreadsheet - {$title}" . self::EOL);
fwrite($localeFile, '##' . self::EOL);
fwrite($localeFile, "## {$localeLanguage} ({$language})" . self::EOL);
fwrite($localeFile, '##' . self::EOL);
fwrite($localeFile, str_repeat('#', 60) . self::EOL . self::EOL);
}
protected function writeFileSectionHeader($localeFile, string $header): void
{
fwrite($localeFile, self::EOL . '##' . self::EOL);
fwrite($localeFile, "## {$header}" . self::EOL);
fwrite($localeFile, '##' . self::EOL);
}
protected function openTranslationWorkbook(): void
{
$filepathName = $this->translationBaseFolder . '/' . $this->translationSpreadsheetName;
$this->translationSpreadsheet = IOFactory::load($filepathName);
}
protected function getTranslationSheet(string $sheetName): Worksheet
{
$worksheet = $this->translationSpreadsheet->setActiveSheetIndexByName($sheetName);
if ($worksheet === null) {
throw new Exception("{$sheetName} Worksheet not found");
}
return $worksheet;
}
protected function mapLanguageColumns(Worksheet $translationWorksheet): array
{
$sheetName = $translationWorksheet->getTitle();
$this->log("Mapping Languages for {$sheetName}:");
$baseColumn = self::ENGLISH_REFERENCE_COLUMN;
$languagesList = $translationWorksheet->getColumnIterator(++$baseColumn);
$languageNameMap = [];
foreach ($languagesList as $languageColumn) {
/** @var Column $languageColumn */
$cells = $languageColumn->getCellIterator(self::LOCALE_NAME_ROW, self::LOCALE_NAME_ROW);
$cells->setIterateOnlyExistingCells(true);
foreach ($cells as $cell) {
/** @var Cell $cell */
if ($this->localeCanBeSupported($translationWorksheet, $cell)) {
$languageNameMap[$cell->getColumn()] = $cell->getValue();
$this->log($cell->getColumn() . ' -> ' . $cell->getValue());
}
}
}
return $languageNameMap;
}
protected function localeCanBeSupported(Worksheet $worksheet, Cell $cell): bool
{
if ($worksheet->getTitle() === self::EXCEL_LOCALISATION_WORKSHEET) {
// Only provide support for languages that have a function argument separator defined
// in the localisation worksheet
return !empty(
$worksheet->getCell($cell->getColumn() . self::ARGUMENT_SEPARATOR_ROW)->getValue()
);
}
// If we're processing other worksheets, then language support is determined by whether we included the
// language in the map when we were processing the localisation worksheet (which is always processed first)
return in_array($cell->getValue(), $this->localeLanguageMap, true);
}
protected function mapErrorCodeRows(): void
{
$this->log('Mapping Error Codes:');
$errorList = $this->localeTranslations->getRowIterator(self::ERROR_CODES_FIRST_ROW);
foreach ($errorList as $errorRow) {
/** @var Row $errorList */
$cells = $errorRow->getCellIterator(self::ENGLISH_REFERENCE_COLUMN, self::ENGLISH_REFERENCE_COLUMN);
$cells->setIterateOnlyExistingCells(true);
foreach ($cells as $cell) {
/** @var Cell $cell */
if ($cell->getValue() != '') {
$this->log($cell->getRow() . ' -> ' . $cell->getValue());
$this->errorCodeMap[$cell->getValue()] = $cell->getRow();
}
}
}
}
protected function mapFunctionNameRows(): void
{
$this->log('Mapping Functions:');
$functionList = $this->functionNameTranslations->getRowIterator(self::FUNCTION_NAME_LIST_FIRST_ROW);
foreach ($functionList as $functionRow) {
/** @var Row $functionRow */
$cells = $functionRow->getCellIterator(self::ENGLISH_REFERENCE_COLUMN, self::ENGLISH_REFERENCE_COLUMN);
$cells->setIterateOnlyExistingCells(true);
foreach ($cells as $cell) {
/** @var Cell $cell */
if ($this->isFunctionCategoryEntry($cell)) {
if (!empty($cell->getValue())) {
$this->log('CATEGORY: ' . $cell->getValue());
$this->functionNameMap[$cell->getValue()] = $cell->getRow();
}
continue;
}
if ($cell->getValue() != '') {
if (is_bool($cell->getValue())) {
$this->log($cell->getRow() . ' -> ' . ($cell->getValue() ? 'TRUE' : 'FALSE'));
$this->functionNameMap[($cell->getValue() ? 'TRUE' : 'FALSE')] = $cell->getRow();
} else {
$this->log($cell->getRow() . ' -> ' . $cell->getValue());
$this->functionNameMap[$cell->getValue()] = $cell->getRow();
}
}
}
}
}
private function isFunctionCategoryEntry(Cell $cell): bool
{
$categoryCheckCell = self::ENGLISH_FUNCTION_CATEGORIES_COLUMN . $cell->getRow();
if ($this->functionNameTranslations->getCell($categoryCheckCell)->getValue() != '') {
return true;
}
return false;
}
private function log(string $message): void
{
if ($this->verbose === false) {
return;
}
echo $message, self::EOL;
}
}