Skip to content

Commit 86a87e0

Browse files
committed
Unexpected Charset Possible for Currency Symbol
We do not recommend it, but users can call the Php function `setlocale` and that might affect the character used as currency symbol. A problem arises when the caller to setlocale does not specify a character set - in that case, Php will attempt to return its `localeconv()` values in a single-byte character set rather than UTF-8. This is particularly problematic for currency symbols. PhpSpreadsheet till now has accepted such a character, and that can lead to corrupt spreadsheets. It is changed to validate the currency symbol as UTF-8, and fall back to a different choice if not (e.g. EUR rather than 0x80, which is how the euro symbol is depicted in Win-1252). An additional problem arises because Linux systems seem to return the alternate symbol with a trailing blank, but Windows systems do not. To allow callers to get a consistent result, a parameter is added to `getCurrencyCode` which will trim or not (default) the currency code.
1 parent eccbcce commit 86a87e0

File tree

3 files changed

+87
-38
lines changed

3 files changed

+87
-38
lines changed

src/PhpSpreadsheet/Shared/StringHelper.php

Lines changed: 25 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,17 @@ class StringHelper
1919
/**
2020
* Decimal separator.
2121
*/
22-
private static ?string $decimalSeparator;
22+
private static ?string $decimalSeparator = null;
2323

2424
/**
2525
* Thousands separator.
2626
*/
27-
private static ?string $thousandsSeparator;
27+
private static ?string $thousandsSeparator = null;
2828

2929
/**
3030
* Currency code.
3131
*/
32-
private static ?string $currencyCode;
32+
private static ?string $currencyCode = null;
3333

3434
/**
3535
* Is iconv extension avalable?
@@ -511,21 +511,31 @@ public static function strCaseReverse(string $textValue): string
511511
return implode('', $characters);
512512
}
513513

514+
private static function useAlt(string $altValue, string $default, bool $trimAlt): string
515+
{
516+
return ($trimAlt ? trim($altValue) : $altValue) ?: $default;
517+
}
518+
519+
private static function getLocaleValue(string $key, string $altKey, string $default, bool $trimAlt = false): string
520+
{
521+
$localeconv = localeconv();
522+
$rslt = $localeconv[$key];
523+
// win-1252 implements Euro as 0x80 plus other symbols
524+
if (preg_match('//u', $rslt) !== 1) {
525+
$rslt = '';
526+
}
527+
528+
return $rslt ?: self::useAlt($localeconv[$altKey], $default, $trimAlt);
529+
}
530+
514531
/**
515532
* Get the decimal separator. If it has not yet been set explicitly, try to obtain number
516533
* formatting information from locale.
517534
*/
518535
public static function getDecimalSeparator(): string
519536
{
520537
if (!isset(self::$decimalSeparator)) {
521-
$localeconv = localeconv();
522-
self::$decimalSeparator = ($localeconv['decimal_point'] != '')
523-
? $localeconv['decimal_point'] : $localeconv['mon_decimal_point'];
524-
525-
if (self::$decimalSeparator == '') {
526-
// Default to .
527-
self::$decimalSeparator = '.';
528-
}
538+
self::$decimalSeparator = self::getLocaleValue('decimal_point', 'mon_decimal_point', '.');
529539
}
530540

531541
return self::$decimalSeparator;
@@ -549,14 +559,7 @@ public static function setDecimalSeparator(?string $separator): void
549559
public static function getThousandsSeparator(): string
550560
{
551561
if (!isset(self::$thousandsSeparator)) {
552-
$localeconv = localeconv();
553-
self::$thousandsSeparator = ($localeconv['thousands_sep'] != '')
554-
? $localeconv['thousands_sep'] : $localeconv['mon_thousands_sep'];
555-
556-
if (self::$thousandsSeparator == '') {
557-
// Default to .
558-
self::$thousandsSeparator = ',';
559-
}
562+
self::$thousandsSeparator = self::getLocaleValue('thousands_sep', 'mon_thousands_sep', ',');
560563
}
561564

562565
return self::$thousandsSeparator;
@@ -577,22 +580,10 @@ public static function setThousandsSeparator(?string $separator): void
577580
* Get the currency code. If it has not yet been set explicitly, try to obtain the
578581
* symbol information from locale.
579582
*/
580-
public static function getCurrencyCode(): string
583+
public static function getCurrencyCode(bool $trimAlt = false): string
581584
{
582-
if (!empty(self::$currencyCode)) {
583-
return self::$currencyCode;
584-
}
585-
self::$currencyCode = '$';
586-
$localeconv = localeconv();
587-
if (!empty($localeconv['currency_symbol'])) {
588-
self::$currencyCode = $localeconv['currency_symbol'];
589-
590-
return self::$currencyCode;
591-
}
592-
if (!empty($localeconv['int_curr_symbol'])) {
593-
self::$currencyCode = $localeconv['int_curr_symbol'];
594-
595-
return self::$currencyCode;
585+
if (!isset(self::$currencyCode)) {
586+
self::$currencyCode = self::getLocaleValue('currency_symbol', 'int_curr_symbol', '$', $trimAlt);
596587
}
597588

598589
return self::$currencyCode;

tests/PhpSpreadsheetTests/Reader/Csv/CsvNumberFormatLocaleTest.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@
66

77
use PhpOffice\PhpSpreadsheet\Cell\DataType;
88
use PhpOffice\PhpSpreadsheet\Reader\Csv;
9+
use PHPUnit\Framework\Attributes;
10+
use PHPUnit\Framework\Attributes\DataProvider;
911
use PHPUnit\Framework\TestCase;
1012

13+
// separate processes due to setLocale
14+
#[Attributes\RunTestsInSeparateProcesses]
1115
class CsvNumberFormatLocaleTest extends TestCase
1216
{
1317
private bool $localeAdjusted;
@@ -44,8 +48,7 @@ protected function tearDown(): void
4448
}
4549
}
4650

47-
#[\PHPUnit\Framework\Attributes\DataProvider('providerNumberFormatNoConversionTest')]
48-
#[\PHPUnit\Framework\Attributes\RunInSeparateProcess]
51+
#[DataProvider('providerNumberFormatNoConversionTest')]
4952
public function testNumberFormatNoConversion(mixed $expectedValue, string $expectedFormat, string $cellAddress): void
5053
{
5154
if (!$this->localeAdjusted) {
@@ -85,8 +88,7 @@ public static function providerNumberFormatNoConversionTest(): array
8588
];
8689
}
8790

88-
#[\PHPUnit\Framework\Attributes\DataProvider('providerNumberValueConversionTest')]
89-
#[\PHPUnit\Framework\Attributes\RunInSeparateProcess]
91+
#[DataProvider('providerNumberValueConversionTest')]
9092
public function testNumberValueConversion(mixed $expectedValue, string $cellAddress): void
9193
{
9294
if (!$this->localeAdjusted) {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpOffice\PhpSpreadsheetTests\Shared;
6+
7+
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
8+
use PHPUnit\Framework\Attributes;
9+
use PHPUnit\Framework\TestCase;
10+
11+
// separate processes due to setLocale
12+
#[Attributes\RunTestsInSeparateProcesses]
13+
class StringHelperLocaleTest extends TestCase
14+
{
15+
/**
16+
* @var false|string
17+
*/
18+
private $currentLocale;
19+
20+
protected function setUp(): void
21+
{
22+
$this->currentLocale = setlocale(LC_ALL, '0');
23+
}
24+
25+
protected function tearDown(): void
26+
{
27+
if (is_string($this->currentLocale)) {
28+
setlocale(LC_ALL, $this->currentLocale);
29+
}
30+
StringHelper::setCurrencyCode(null);
31+
}
32+
33+
public function testCurrency(): void
34+
{
35+
if (!setlocale(LC_ALL, 'de_DE.UTF-8', 'deu_deu.utf8')) {
36+
self::markTestSkipped('Unable to set German UTF8 locale for testing.');
37+
}
38+
$result = StringHelper::getCurrencyCode();
39+
self::assertSame('', $result);
40+
if (!setlocale(LC_ALL, 'en_us')) {
41+
self::markTestSkipped('Unable to set US locale for testing.');
42+
}
43+
$result = StringHelper::getCurrencyCode();
44+
self::assertSame('', $result, 'result persists despite locale change');
45+
StringHelper::setCurrencyCode(null);
46+
$result = StringHelper::getCurrencyCode();
47+
self::assertSame('$', $result, 'locale now used');
48+
StringHelper::setCurrencyCode(null);
49+
if (!setlocale(LC_ALL, 'deu_deu', 'de_DE')) {
50+
self::markTestSkipped('Unable to set German single-byte locale for testing.');
51+
}
52+
// Seems like Linux returns trailing blank, Win doesn't
53+
$result = StringHelper::getCurrencyCode(true); // trim if alt symbol is used
54+
self::assertSame('EUR', $result, 'non-UTF8 result ignored');
55+
}
56+
}

0 commit comments

Comments
 (0)