diff --git a/src/PhpSpreadsheet/Shared/StringHelper.php b/src/PhpSpreadsheet/Shared/StringHelper.php index 9a8fa86551..66800f179c 100644 --- a/src/PhpSpreadsheet/Shared/StringHelper.php +++ b/src/PhpSpreadsheet/Shared/StringHelper.php @@ -35,7 +35,7 @@ class StringHelper /** * Currency code. * - * @var string + * @var ?string */ private static $currencyCode; @@ -551,9 +551,9 @@ public static function getDecimalSeparator(): string * Set the decimal separator. Only used by NumberFormat::toFormattedString() * to format output by \PhpOffice\PhpSpreadsheet\Writer\Html and \PhpOffice\PhpSpreadsheet\Writer\Pdf. * - * @param string $separator Character for decimal separator + * @param ?string $separator Character for decimal separator */ - public static function setDecimalSeparator(string $separator): void + public static function setDecimalSeparator(?string $separator): void { self::$decimalSeparator = $separator; } @@ -582,9 +582,9 @@ public static function getThousandsSeparator(): string * Set the thousands separator. Only used by NumberFormat::toFormattedString() * to format output by \PhpOffice\PhpSpreadsheet\Writer\Html and \PhpOffice\PhpSpreadsheet\Writer\Pdf. * - * @param string $separator Character for thousands separator + * @param ?string $separator Character for thousands separator */ - public static function setThousandsSeparator(string $separator): void + public static function setThousandsSeparator(?string $separator): void { self::$thousandsSeparator = $separator; } @@ -618,9 +618,9 @@ public static function getCurrencyCode(): string * Set the currency code. Only used by NumberFormat::toFormattedString() * to format output by \PhpOffice\PhpSpreadsheet\Writer\Html and \PhpOffice\PhpSpreadsheet\Writer\Pdf. * - * @param string $currencyCode Character for currency code + * @param ?string $currencyCode Character for currency code */ - public static function setCurrencyCode(string $currencyCode): void + public static function setCurrencyCode(?string $currencyCode): void { self::$currencyCode = $currencyCode; } diff --git a/src/PhpSpreadsheet/Style/NumberFormat/BaseFormatter.php b/src/PhpSpreadsheet/Style/NumberFormat/BaseFormatter.php index 7988143c51..d6f373d079 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat/BaseFormatter.php +++ b/src/PhpSpreadsheet/Style/NumberFormat/BaseFormatter.php @@ -2,6 +2,8 @@ namespace PhpOffice\PhpSpreadsheet\Style\NumberFormat; +use PhpOffice\PhpSpreadsheet\Shared\StringHelper; + abstract class BaseFormatter { protected static function stripQuotes(string $format): string @@ -9,4 +11,15 @@ protected static function stripQuotes(string $format): string // Some non-number strings are quoted, so we'll get rid of the quotes, likewise any positional * symbols return str_replace(['"', '*'], '', $format); } + + protected static function adjustSeparators(string $value): string + { + $thousandsSeparator = StringHelper::getThousandsSeparator(); + $decimalSeparator = StringHelper::getDecimalSeparator(); + if ($thousandsSeparator !== ',' || $decimalSeparator !== '.') { + $value = str_replace(['.', ',', "\u{fffd}"], ["\u{fffd}", '.', ','], $value); + } + + return $value; + } } diff --git a/src/PhpSpreadsheet/Style/NumberFormat/Formatter.php b/src/PhpSpreadsheet/Style/NumberFormat/Formatter.php index 1115467de4..f7969a449a 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat/Formatter.php +++ b/src/PhpSpreadsheet/Style/NumberFormat/Formatter.php @@ -8,7 +8,7 @@ use PhpOffice\PhpSpreadsheet\Style\Color; use PhpOffice\PhpSpreadsheet\Style\NumberFormat; -class Formatter +class Formatter extends BaseFormatter { /** * Matches any @ symbol that isn't enclosed in quotes. @@ -133,7 +133,7 @@ public static function toFormattedString($value, $format, $callBack = null) // For 'General' format code, we just pass the value although this is not entirely the way Excel does it, // it seems to round numbers to a total of 10 digits. if (($format === NumberFormat::FORMAT_GENERAL) || ($format === NumberFormat::FORMAT_TEXT)) { - return (string) $value; + return self::adjustSeparators((string) $value); } // Ignore square-$-brackets prefix in format string, like "[$-411]ge.m.d", "[$-010419]0%", etc diff --git a/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php b/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php index f927964d37..6a6934524c 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php +++ b/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php @@ -5,7 +5,7 @@ use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use PhpOffice\PhpSpreadsheet\Style\NumberFormat; -class NumberFormatter +class NumberFormatter extends BaseFormatter { private const NUMBER_REGEX = '/(0+)(\\.?)(0*)/'; @@ -176,11 +176,11 @@ private static function formatStraightNumericValue(mixed $value, string $format, return $result; } - $sprintf_pattern = "%0$minWidth." . strlen($right) . 'f'; + $sprintf_pattern = "%0$minWidth." . strlen($right) . 'F'; /** @var float */ $valueFloat = $value; - $value = sprintf($sprintf_pattern, round($valueFloat, strlen($right))); + $value = self::adjustSeparators(sprintf($sprintf_pattern, round($valueFloat, strlen($right)))); return self::pregReplace(self::NUMBER_REGEX, $value, $format); } diff --git a/src/PhpSpreadsheet/Style/NumberFormat/PercentageFormatter.php b/src/PhpSpreadsheet/Style/NumberFormat/PercentageFormatter.php index 55b5971aa9..6d23f9ddfa 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat/PercentageFormatter.php +++ b/src/PhpSpreadsheet/Style/NumberFormat/PercentageFormatter.php @@ -38,11 +38,11 @@ public static function format($value, string $format): string $wholePartSize += $decimalPartSize + (int) ($decimalPartSize > 0); $replacement = "0{$wholePartSize}.{$decimalPartSize}"; - $mask = (string) preg_replace('/[#0,]+\.?[?#0,]*/ui', "%{$replacement}f{$placeHolders}", $format); + $mask = (string) preg_replace('/[#0,]+\.?[?#0,]*/ui', "%{$replacement}F{$placeHolders}", $format); /** @var float */ $valueFloat = $value; - return sprintf($mask, round($valueFloat, $decimalPartSize)); + return self::adjustSeparators(sprintf($mask, round($valueFloat, $decimalPartSize))); } } diff --git a/tests/PhpSpreadsheetTests/Calculation/Engine/FormattedNumberSlashTest.php b/tests/PhpSpreadsheetTests/Calculation/Engine/FormattedNumberSlashTest.php index 9196bcc32a..c23f738709 100644 --- a/tests/PhpSpreadsheetTests/Calculation/Engine/FormattedNumberSlashTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/Engine/FormattedNumberSlashTest.php @@ -10,24 +10,11 @@ class FormattedNumberSlashTest extends TestCase { - private string $originalCurrencyCode; - - private string $originalDecimalSeparator; - - private string $originalThousandsSeparator; - - protected function setUp(): void - { - $this->originalCurrencyCode = StringHelper::getCurrencyCode(); - $this->originalDecimalSeparator = StringHelper::getDecimalSeparator(); - $this->originalThousandsSeparator = StringHelper::getThousandsSeparator(); - } - protected function tearDown(): void { - StringHelper::setCurrencyCode($this->originalCurrencyCode); - StringHelper::setDecimalSeparator($this->originalDecimalSeparator); - StringHelper::setThousandsSeparator($this->originalThousandsSeparator); + StringHelper::setCurrencyCode(null); + StringHelper::setDecimalSeparator(null); + StringHelper::setThousandsSeparator(null); } /** diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ValueTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ValueTest.php index c04dfe8798..2df5db47bd 100644 --- a/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ValueTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/TextData/ValueTest.php @@ -9,26 +9,12 @@ class ValueTest extends AllSetupTeardown { - private string $currencyCode; - - private string $decimalSeparator; - - private string $thousandsSeparator; - - protected function setUp(): void - { - parent::setUp(); - $this->currencyCode = StringHelper::getCurrencyCode(); - $this->decimalSeparator = StringHelper::getDecimalSeparator(); - $this->thousandsSeparator = StringHelper::getThousandsSeparator(); - } - protected function tearDown(): void { parent::tearDown(); - StringHelper::setCurrencyCode($this->currencyCode); - StringHelper::setDecimalSeparator($this->decimalSeparator); - StringHelper::setThousandsSeparator($this->thousandsSeparator); + StringHelper::setCurrencyCode(null); + StringHelper::setDecimalSeparator(null); + StringHelper::setThousandsSeparator(null); } /** diff --git a/tests/PhpSpreadsheetTests/Cell/AdvancedValueBinderTest.php b/tests/PhpSpreadsheetTests/Cell/AdvancedValueBinderTest.php index 9963f01f6c..3ab89878bf 100644 --- a/tests/PhpSpreadsheetTests/Cell/AdvancedValueBinderTest.php +++ b/tests/PhpSpreadsheetTests/Cell/AdvancedValueBinderTest.php @@ -18,20 +18,11 @@ class AdvancedValueBinderTest extends TestCase private string $originalLocale; - private string $originalCurrencyCode; - - private string $originalDecimalSeparator; - - private string $originalThousandsSeparator; - private IValueBinder $valueBinder; protected function setUp(): void { $this->originalLocale = Settings::getLocale(); - $this->originalCurrencyCode = StringHelper::getCurrencyCode(); - $this->originalDecimalSeparator = StringHelper::getDecimalSeparator(); - $this->originalThousandsSeparator = StringHelper::getThousandsSeparator(); $this->valueBinder = Cell::getValueBinder(); Cell::setValueBinder(new AdvancedValueBinder()); @@ -39,9 +30,9 @@ protected function setUp(): void protected function tearDown(): void { - StringHelper::setCurrencyCode($this->originalCurrencyCode); - StringHelper::setDecimalSeparator($this->originalDecimalSeparator); - StringHelper::setThousandsSeparator($this->originalThousandsSeparator); + StringHelper::setCurrencyCode(null); + StringHelper::setDecimalSeparator(null); + StringHelper::setThousandsSeparator(null); Settings::setLocale($this->originalLocale); Cell::setValueBinder($this->valueBinder); } diff --git a/tests/PhpSpreadsheetTests/Reader/Csv/CsvNumberFormatLocaleTest.php b/tests/PhpSpreadsheetTests/Reader/Csv/CsvNumberFormatLocaleTest.php index 9550636b3c..d6d65329e9 100644 --- a/tests/PhpSpreadsheetTests/Reader/Csv/CsvNumberFormatLocaleTest.php +++ b/tests/PhpSpreadsheetTests/Reader/Csv/CsvNumberFormatLocaleTest.php @@ -31,7 +31,7 @@ protected function setUp(): void { $this->currentLocale = setlocale(LC_ALL, '0'); - if (!setlocale(LC_ALL, 'de_DE.UTF-8', 'deu_deu')) { + if (!setlocale(LC_ALL, 'de_DE.UTF-8', 'deu_deu.utf8')) { $this->localeAdjusted = false; return; @@ -52,6 +52,8 @@ protected function tearDown(): void /** * @dataProvider providerNumberFormatNoConversionTest + * + * @runInSeparateProcess */ public function testNumberFormatNoConversion(mixed $expectedValue, string $expectedFormat, string $cellAddress): void { diff --git a/tests/PhpSpreadsheetTests/Shared/StringHelperTest.php b/tests/PhpSpreadsheetTests/Shared/StringHelperTest.php index e77e02a424..03a4403d1a 100644 --- a/tests/PhpSpreadsheetTests/Shared/StringHelperTest.php +++ b/tests/PhpSpreadsheetTests/Shared/StringHelperTest.php @@ -9,25 +9,11 @@ class StringHelperTest extends TestCase { - private string $currencyCode; - - private string $decimalSeparator; - - private string $thousandsSeparator; - - protected function setUp(): void - { - parent::setUp(); - $this->currencyCode = StringHelper::getCurrencyCode(); - $this->decimalSeparator = StringHelper::getDecimalSeparator(); - $this->thousandsSeparator = StringHelper::getThousandsSeparator(); - } - protected function tearDown(): void { - StringHelper::setCurrencyCode($this->currencyCode); - StringHelper::setDecimalSeparator($this->decimalSeparator); - StringHelper::setThousandsSeparator($this->thousandsSeparator); + StringHelper::setCurrencyCode(null); + StringHelper::setDecimalSeparator(null); + StringHelper::setThousandsSeparator(null); } public function testGetIsIconvEnabled(): void diff --git a/tests/PhpSpreadsheetTests/Style/NumberFormatTest.php b/tests/PhpSpreadsheetTests/Style/NumberFormatTest.php index 5cd09574c8..0a1aaf2920 100644 --- a/tests/PhpSpreadsheetTests/Style/NumberFormatTest.php +++ b/tests/PhpSpreadsheetTests/Style/NumberFormatTest.php @@ -11,26 +11,17 @@ class NumberFormatTest extends TestCase { - private string $currencyCode; - - private string $decimalSeparator; - - private string $thousandsSeparator; - protected function setUp(): void { - $this->currencyCode = StringHelper::getCurrencyCode(); - $this->decimalSeparator = StringHelper::getDecimalSeparator(); - $this->thousandsSeparator = StringHelper::getThousandsSeparator(); StringHelper::setDecimalSeparator('.'); StringHelper::setThousandsSeparator(','); } protected function tearDown(): void { - StringHelper::setCurrencyCode($this->currencyCode); - StringHelper::setDecimalSeparator($this->decimalSeparator); - StringHelper::setThousandsSeparator($this->thousandsSeparator); + StringHelper::setCurrencyCode(null); + StringHelper::setDecimalSeparator(null); + StringHelper::setThousandsSeparator(null); } /** diff --git a/tests/PhpSpreadsheetTests/Writer/Html/HtmlNumberFormatTest.php b/tests/PhpSpreadsheetTests/Writer/Html/HtmlNumberFormatTest.php index 1594a6fa34..f792f92523 100644 --- a/tests/PhpSpreadsheetTests/Writer/Html/HtmlNumberFormatTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Html/HtmlNumberFormatTest.php @@ -13,27 +13,18 @@ class HtmlNumberFormatTest extends Functional\AbstractFunctional { - private string $currency; - - private string $decsep; - - private string $thosep; - protected function setUp(): void { - $this->currency = StringHelper::getCurrencyCode(); StringHelper::setCurrencyCode('$'); - $this->decsep = StringHelper::getDecimalSeparator(); StringHelper::setDecimalSeparator('.'); - $this->thosep = StringHelper::getThousandsSeparator(); StringHelper::setThousandsSeparator(','); } protected function tearDown(): void { - StringHelper::setCurrencyCode($this->currency); - StringHelper::setDecimalSeparator($this->decsep); - StringHelper::setThousandsSeparator($this->thosep); + StringHelper::setCurrencyCode(null); + StringHelper::setDecimalSeparator(null); + StringHelper::setThousandsSeparator(null); } public function testColorNumberFormat(): void diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/LocaleFloatsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/LocaleFloatsTest.php index 185154e1bd..5902896e7c 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/LocaleFloatsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/LocaleFloatsTest.php @@ -4,17 +4,18 @@ namespace PhpOffice\PhpSpreadsheetTests\Writer\Xlsx; +use PhpOffice\PhpSpreadsheet\Reader\Xlsx as XlsxReader; +use PhpOffice\PhpSpreadsheet\Settings; +use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat; use PhpOffice\PhpSpreadsheetTests\Functional\AbstractFunctional; class LocaleFloatsTest extends AbstractFunctional { - private bool $localeAdjusted; + private false|string $currentPhpLocale; - /** - * @var false|string - */ - private $currentLocale; + private string $originalLocale; /** @var ?Spreadsheet */ private $spreadsheet; @@ -24,21 +25,19 @@ class LocaleFloatsTest extends AbstractFunctional protected function setUp(): void { - $this->currentLocale = setlocale(LC_ALL, '0'); - - if (!setlocale(LC_ALL, 'fr_FR.UTF-8', 'fra_fra')) { - $this->localeAdjusted = false; - - return; - } - - $this->localeAdjusted = true; + $this->currentPhpLocale = setlocale(LC_ALL, '0'); + $this->originalLocale = Settings::getLocale(); + StringHelper::setDecimalSeparator(null); + StringHelper::setThousandsSeparator(null); } protected function tearDown(): void { - if ($this->localeAdjusted && is_string($this->currentLocale)) { - setlocale(LC_ALL, $this->currentLocale); + StringHelper::setDecimalSeparator(null); + StringHelper::setThousandsSeparator(null); + Settings::setLocale($this->originalLocale); + if ($this->currentPhpLocale !== false) { + setlocale(LC_ALL, $this->currentPhpLocale); } if ($this->spreadsheet !== null) { $this->spreadsheet->disconnectWorksheets(); @@ -50,12 +49,17 @@ protected function tearDown(): void } } + /** + * Use separate process because this calls native Php setlocale. + * + * @runInSeparateProcess + */ public function testLocaleFloatsCorrectlyConvertedByWriter(): void { - if (!$this->localeAdjusted) { + if (!setlocale(LC_ALL, 'fr_FR.UTF-8', 'fra_fra.utf8')) { + $this->currentPhpLocale = false; self::markTestSkipped('Unable to set locale for testing.'); } - $this->spreadsheet = $spreadsheet = new Spreadsheet(); $properties = $spreadsheet->getProperties(); $properties->setCustomProperty('Version', 1.2); @@ -68,7 +72,67 @@ public function testLocaleFloatsCorrectlyConvertedByWriter(): void $prop = $reloadedSpreadsheet->getProperties()->getCustomPropertyValue('Version'); self::assertEqualsWithDelta(1.2, $prop, 1.0E-8); - $actual = sprintf('%f', $result); + $actual = $reloadedSpreadsheet->getActiveSheet()->getCell('A1')->getFormattedValue(); self::assertStringContainsString('1,1', $actual); } + + public function testPercentageStoredAsString(): void + { + Settings::setLocale('fr_FR'); + StringHelper::setDecimalSeparator(','); + StringHelper::setThousandsSeparator('.'); + $reader = new XlsxReader(); + $this->spreadsheet = $spreadsheet = $reader->load('tests/data/Writer/Xlsx/issue.3811b.xlsx'); + $sheet = $spreadsheet->getActiveSheet(); + self::assertSame('48,34%', $sheet->getCell('L2')->getValue()); + self::assertIsString($sheet->getCell('L2')->getValue()); + self::assertSame('=(10%+L2)/2', $sheet->getCell('L1')->getValue()); + self::assertEqualsWithDelta(0.2917, $sheet->getCell('L1')->getCalculatedValue(), 1E-8); + self::assertIsFloat($sheet->getCell('L1')->getCalculatedValue()); + self::assertEquals('29,17%', $sheet->getCell('L1')->getFormattedValue()); + + $sheet->getCell('A10')->setValue(3.2); + self::assertSame(NumberFormat::FORMAT_GENERAL, $sheet->getStyle('A10')->getNumberFormat()->getFormatCode()); + self::assertSame('3,2', $sheet->getCell('A10')->getFormattedValue()); + $sheet->getCell('A11')->setValue(1002.5); + $sheet->getStyle('A11')->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1); + self::assertSame('1.002,50', $sheet->getCell('A11')->getFormattedValue()); + $sheet->getCell('A12')->setValue(2.5); + $sheet->getStyle('A12')->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_NUMBER_00); + self::assertSame('2,50', $sheet->getCell('A12')->getFormattedValue()); + } + + /** + * Use separate process because this calls native Php setlocale. + * + * @runInSeparateProcess + */ + public function testPercentageStoredAsString2(): void + { + if (!setlocale(LC_ALL, 'fr_FR.UTF-8', 'fra_fra.utf8')) { + $this->currentPhpLocale = false; + self::markTestSkipped('Unable to set locale for testing.'); + } + $reader = new XlsxReader(); + $this->spreadsheet = $spreadsheet = $reader->load('tests/data/Writer/Xlsx/issue.3811b.xlsx'); + $sheet = $spreadsheet->getActiveSheet(); + self::assertSame('48,34%', $sheet->getCell('L2')->getValue()); + self::assertIsString($sheet->getCell('L2')->getValue()); + self::assertSame('=(10%+L2)/2', $sheet->getCell('L1')->getValue()); + self::assertEqualsWithDelta(0.2917, $sheet->getCell('L1')->getCalculatedValue(), 1E-8); + self::assertIsFloat($sheet->getCell('L1')->getCalculatedValue()); + self::assertEquals('29,17%', $sheet->getCell('L1')->getFormattedValue()); + + $sheet->getCell('A10')->setValue(3.2); + self::assertSame(NumberFormat::FORMAT_GENERAL, $sheet->getStyle('A10')->getNumberFormat()->getFormatCode()); + self::assertSame('3,2', $sheet->getCell('A10')->getFormattedValue()); + $narrowNonBreakSpace = "\u{202f}"; + self::assertSame($narrowNonBreakSpace, localeconv()['thousands_sep']); + $sheet->getCell('A11')->setValue(1002.5); + $sheet->getStyle('A11')->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1); + self::assertSame('1' . $narrowNonBreakSpace . '002,50', $sheet->getCell('A11')->getFormattedValue()); + $sheet->getCell('A12')->setValue(2.5); + $sheet->getStyle('A12')->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_NUMBER_00); + self::assertSame('2,50', $sheet->getCell('A12')->getFormattedValue()); + } } diff --git a/tests/data/Writer/XLSX/issue.3811b.xlsx b/tests/data/Writer/XLSX/issue.3811b.xlsx new file mode 100644 index 0000000000..43dbf4bd8d Binary files /dev/null and b/tests/data/Writer/XLSX/issue.3811b.xlsx differ