Skip to content

Commit 0753ad5

Browse files
committed
Php 8.3 Problem - RLM Added to NumberFormatter Currency - Minor Break
Fix PHPOffice#3571. This isn't truly a Php8.3 problem - it all depends on the version of ICU with which Php was linked. ICU 72.1 adds an RLM (right-to-left mark) character in some circumstances when creating a currency format. This broke some tests for the Currency and Accounting wizards, and can result in a difference in the appearance of some spreadsheet cells. This PR changes code to strip out the RLM or not depending on a new property. The new property could default to true (so end-users will not see any change no matter what release of ICU is used), or false. For the latter, users might see a break, but my assumption is that the ICU developers have good reasons for their change, and it's probably best to go along with it. If users wish to retain the existing behavior, they can do so by adding the following code before setting the wizard's locale: ```php $wizard->setStripLeadingRLM(false); ```
1 parent ea4f0a2 commit 0753ad5

File tree

5 files changed

+112
-16
lines changed

5 files changed

+112
-16
lines changed

src/PhpSpreadsheet/Style/NumberFormat/Wizard/Accounting.php

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,16 @@ public function __construct(
2828
bool $thousandsSeparator = true,
2929
bool $currencySymbolPosition = self::LEADING_SYMBOL,
3030
bool $currencySymbolSpacing = self::SYMBOL_WITHOUT_SPACING,
31-
?string $locale = null
31+
?string $locale = null,
32+
bool $stripLeadingRLM = self::DEFAULT_STRIP_LEADING_RLM
3233
) {
3334
$this->setCurrencyCode($currencyCode);
3435
$this->setThousandsSeparator($thousandsSeparator);
3536
$this->setDecimals($decimals);
3637
$this->setCurrencySymbolPosition($currencySymbolPosition);
3738
$this->setCurrencySymbolSpacing($currencySymbolSpacing);
3839
$this->setLocale($locale);
40+
$this->stripLeadingRLM = $stripLeadingRLM;
3941
}
4042

4143
/**
@@ -44,24 +46,28 @@ public function __construct(
4446
protected function getLocaleFormat(): string
4547
{
4648
if (version_compare(PHP_VERSION, '7.4.1', '<')) {
49+
// @codeCoverageIgnoreStart
4750
throw new Exception('The Intl extension does not support Accounting Formats below PHP 7.4.1');
51+
// @codeCoverageIgnoreEnd
4852
}
4953

50-
if ($this->icuVersion() < 53.0) {
54+
if (self::icuVersion() < 53.0) {
55+
// @codeCoverageIgnoreStart
5156
throw new Exception('The Intl extension does not support Accounting Formats without ICU 53');
57+
// @codeCoverageIgnoreEnd
5258
}
5359

5460
// Scrutinizer does not recognize CURRENCY_ACCOUNTING
5561
$formatter = new Locale($this->fullLocale, NumberFormatter::CURRENCY_ACCOUNTING);
56-
$mask = $formatter->format();
62+
$mask = $formatter->format($this->stripLeadingRLM);
5763
if ($this->decimals === 0) {
5864
$mask = (string) preg_replace('/\.0+/miu', '', $mask);
5965
}
6066

6167
return str_replace('¤', $this->formatCurrencyCode(), $mask);
6268
}
6369

64-
private function icuVersion(): float
70+
public static function icuVersion(): float
6571
{
6672
[$major, $minor] = explode('.', INTL_ICU_VERSION);
6773

src/PhpSpreadsheet/Style/NumberFormat/Wizard/Currency.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ class Currency extends Number
2121

2222
protected bool $currencySymbolSpacing = self::SYMBOL_WITHOUT_SPACING;
2323

24+
protected const DEFAULT_STRIP_LEADING_RLM = false;
25+
26+
protected bool $stripLeadingRLM = self::DEFAULT_STRIP_LEADING_RLM;
27+
2428
/**
2529
* @param string $currencyCode the currency symbol or code to display for this mask
2630
* @param int $decimals number of decimal places to display, in the range 0-30
@@ -33,6 +37,8 @@ class Currency extends Number
3337
* If provided, Locale values must be a valid formatted locale string (e.g. 'en-GB', 'fr', uz-Arab-AF).
3438
* Note that setting a locale will override any other settings defined in this class
3539
* other than the currency code; or decimals (unless the decimals value is set to 0).
40+
* @param bool $stripLeadingRLM remove leading RLM added with
41+
* ICU 72.1+.
3642
*
3743
* @throws Exception If a provided locale code is not a valid format
3844
*/
@@ -42,14 +48,16 @@ public function __construct(
4248
bool $thousandsSeparator = true,
4349
bool $currencySymbolPosition = self::LEADING_SYMBOL,
4450
bool $currencySymbolSpacing = self::SYMBOL_WITHOUT_SPACING,
45-
?string $locale = null
51+
?string $locale = null,
52+
bool $stripLeadingRLM = self::DEFAULT_STRIP_LEADING_RLM
4653
) {
4754
$this->setCurrencyCode($currencyCode);
4855
$this->setThousandsSeparator($thousandsSeparator);
4956
$this->setDecimals($decimals);
5057
$this->setCurrencySymbolPosition($currencySymbolPosition);
5158
$this->setCurrencySymbolSpacing($currencySymbolSpacing);
5259
$this->setLocale($locale);
60+
$this->stripLeadingRLM = $stripLeadingRLM;
5361
}
5462

5563
public function setCurrencyCode(string $currencyCode): void
@@ -67,10 +75,15 @@ public function setCurrencySymbolSpacing(bool $currencySymbolSpacing = self::SYM
6775
$this->currencySymbolSpacing = $currencySymbolSpacing;
6876
}
6977

78+
public function setStripLeadingRLM(bool $stripLeadingRLM): void
79+
{
80+
$this->stripLeadingRLM = $stripLeadingRLM;
81+
}
82+
7083
protected function getLocaleFormat(): string
7184
{
7285
$formatter = new Locale($this->fullLocale, NumberFormatter::CURRENCY);
73-
$mask = $formatter->format();
86+
$mask = $formatter->format($this->stripLeadingRLM);
7487
if ($this->decimals === 0) {
7588
$mask = (string) preg_replace('/\.0+/miu', '', $mask);
7689
}

src/PhpSpreadsheet/Style/NumberFormat/Wizard/Locale.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ public function __construct(?string $locale, int $style)
3030
}
3131
}
3232

33-
public function format(): string
33+
public function format(bool $stripRlm = true): string
3434
{
35-
return $this->formatter->getPattern();
35+
$str = $this->formatter->getPattern();
36+
37+
return ($stripRlm && substr($str, 0, 3) === "\xe2\x80\x8f") ? substr($str, 3) : $str;
3638
}
3739
}

tests/PhpSpreadsheetTests/Style/NumberFormat/Wizard/AccountingTest.php

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,19 +44,25 @@ public static function providerAccounting(): array
4444
public function testAccountingLocale(
4545
string $expectedResult,
4646
string $currencyCode,
47-
string $locale
47+
string $locale,
48+
?bool $stripRLM = null
4849
): void {
4950
if (class_exists(NumberFormatter::class) === false) {
5051
self::markTestSkipped('Intl extension is not available');
5152
}
5253

5354
$wizard = new Accounting($currencyCode);
55+
if ($stripRLM !== null) {
56+
$wizard->setStripLeadingRLM($stripRLM);
57+
}
5458
$wizard->setLocale($locale);
5559
self::assertSame($expectedResult, (string) $wizard);
5660
}
5761

5862
public static function providerAccountingLocale(): array
5963
{
64+
// \u{a0} is non-breaking space
65+
// \u{200e} is LRM (left-to-right mark)
6066
return [
6167
["[\$€-fy-NL]\u{a0}#,##0.00;([\$€-fy-NL]\u{a0}#,##0.00)", '', 'fy-NL'],
6268
["[\$€-nl-NL]\u{a0}#,##0.00;([\$€-nl-NL]\u{a0}#,##0.00)", '', 'nl-NL'],
@@ -66,29 +72,60 @@ public static function providerAccountingLocale(): array
6672
['[$$-en-CA]#,##0.00;([$$-en-CA]#,##0.00)', '$', 'en-ca'],
6773
["#,##0.00\u{a0}[\$\$-fr-CA];(#,##0.00\u{a0}[\$\$-fr-CA])", '$', 'fr-ca'],
6874
['[$¥-ja-JP]#,##0;([$¥-ja-JP]#,##0)', '¥', 'ja-JP'], // No decimals
69-
["#,##0.000\u{a0}[\$د.ب-ar-BH]", 'د.ب', 'ar-BH'], // 3 decimals
75+
["#,##0.000\u{a0}[\$د.ب\u{200e}-ar-BH]", "د.ب\u{200e}", 'ar-BH', true], // 3 decimals
7076
];
7177
}
7278

79+
public function testIcu721(): void
80+
{
81+
if (class_exists(NumberFormatter::class) === false) {
82+
self::markTestSkipped('Intl extension is not available');
83+
}
84+
85+
$currencyCode = "د.ب\u{200e}";
86+
$locale = 'ar-BH';
87+
$expected = "#,##0.000\u{a0}[\$د.ب\u{200e}-ar-BH]";
88+
$wizardFalse = new Accounting($currencyCode);
89+
$wizardFalse->setStripLeadingRLM(false);
90+
$wizardFalse->setLocale($locale);
91+
$stringFalse = (string) $wizardFalse;
92+
$wizardTrue = new Accounting($currencyCode);
93+
$wizardTrue->setStripLeadingRLM(true);
94+
$wizardTrue->setLocale($locale);
95+
$stringTrue = (string) $wizardTrue;
96+
$version = Accounting::icuVersion();
97+
if ($version < 72.1) {
98+
self::assertSame($stringFalse, $stringTrue);
99+
} else {
100+
self::assertSame("\u{200f}$stringTrue", $stringFalse);
101+
}
102+
}
103+
73104
/**
74105
* @dataProvider providerAccountingLocaleNoDecimals
75106
*/
76107
public function testAccountingLocaleNoDecimals(
77108
string $expectedResult,
78109
string $currencyCode,
79-
string $locale
110+
string $locale,
111+
?bool $stripRLM = null
80112
): void {
81113
if (class_exists(NumberFormatter::class) === false) {
82114
self::markTestSkipped('Intl extension is not available');
83115
}
84116

85117
$wizard = new Accounting($currencyCode, 0);
118+
if ($stripRLM !== null) {
119+
$wizard->setStripLeadingRLM($stripRLM);
120+
}
86121
$wizard->setLocale($locale);
87122
self::assertSame($expectedResult, (string) $wizard);
88123
}
89124

90125
public static function providerAccountingLocaleNoDecimals(): array
91126
{
127+
// \u{a0} is non-breaking space
128+
// \u{200e} is LRM (left-to-right mark)
92129
return [
93130
["[\$€-fy-NL]\u{a0}#,##0;([\$€-fy-NL]\u{a0}#,##0)", '', 'fy-NL'],
94131
["[\$€-nl-NL]\u{a0}#,##0;([\$€-nl-NL]\u{a0}#,##0)", '', 'nl-NL'],
@@ -98,7 +135,7 @@ public static function providerAccountingLocaleNoDecimals(): array
98135
['[$$-en-CA]#,##0;([$$-en-CA]#,##0)', '$', 'en-ca'],
99136
["#,##0\u{a0}[\$\$-fr-CA];(#,##0\u{a0}[\$\$-fr-CA])", '$', 'fr-ca'],
100137
['[$¥-ja-JP]#,##0;([$¥-ja-JP]#,##0)', '¥', 'ja-JP'], // No decimals to truncate
101-
["#,##0\u{a0}[\$د.ب-ar-BH]", 'د.ب', 'ar-BH'], // 3 decimals truncated to none
138+
["#,##0\u{a0}[\$د.ب\u{200e}-ar-BH]", "د.ب\u{200e}", 'ar-BH', true], // 3 decimals truncated to none
102139
];
103140
}
104141

tests/PhpSpreadsheetTests/Style/NumberFormat/Wizard/CurrencyTest.php

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

55
use NumberFormatter;
66
use PhpOffice\PhpSpreadsheet\Exception;
7+
use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Accounting;
78
use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Currency;
89
use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Number;
910
use PHPUnit\Framework\TestCase;
@@ -43,19 +44,25 @@ public static function providerCurrency(): array
4344
public function testCurrencyLocale(
4445
string $expectedResult,
4546
string $currencyCode,
46-
string $locale
47+
string $locale,
48+
?bool $stripRLM = null
4749
): void {
4850
if (class_exists(NumberFormatter::class) === false) {
4951
self::markTestSkipped('Intl extension is not available');
5052
}
5153

5254
$wizard = new Currency($currencyCode);
55+
if ($stripRLM !== null) {
56+
$wizard->setStripLeadingRLM($stripRLM);
57+
}
5358
$wizard->setLocale($locale);
5459
self::assertSame($expectedResult, (string) $wizard);
5560
}
5661

5762
public static function providerCurrencyLocale(): array
5863
{
64+
// \u{a0} is non-breaking space
65+
// \u{200e} is LRM (left-to-right mark)
5966
return [
6067
["[\$€-fy-NL]\u{a0}#,##0.00;[\$€-fy-NL]\u{a0}#,##0.00-", '', 'fy-NL'], // Trailing negative
6168
["[\$€-nl-NL]\u{a0}#,##0.00;[\$€-nl-NL]\u{a0}-#,##0.00", '', 'nl-NL'], // Sign between currency and value
@@ -65,29 +72,60 @@ public static function providerCurrencyLocale(): array
6572
['[$$-en-CA]#,##0.00', '$', 'en-ca'],
6673
["#,##0.00\u{a0}[\$\$-fr-CA]", '$', 'fr-ca'], // Trailing currency code
6774
['[$¥-ja-JP]#,##0', '¥', 'ja-JP'], // No decimals
68-
["#,##0.000\u{a0}[\$د.ب-ar-BH]", 'د.ب', 'ar-BH'], // 3 decimals
75+
["#,##0.000\u{a0}[\$د.ب\u{200e}-ar-BH]", "د.ب\u{200e}", 'ar-BH', true], // 3 decimals
6976
];
7077
}
7178

79+
public function testIcu721(): void
80+
{
81+
if (class_exists(NumberFormatter::class) === false) {
82+
self::markTestSkipped('Intl extension is not available');
83+
}
84+
85+
$currencyCode = "د.ب\u{200e}";
86+
$locale = 'ar-BH';
87+
$expected = "#,##0.000\u{a0}[\$د.ب\u{200e}-ar-BH]";
88+
$wizardFalse = new Currency($currencyCode);
89+
$wizardFalse->setStripLeadingRLM(false);
90+
$wizardFalse->setLocale($locale);
91+
$stringFalse = (string) $wizardFalse;
92+
$wizardTrue = new Currency($currencyCode);
93+
$wizardTrue->setStripLeadingRLM(true);
94+
$wizardTrue->setLocale($locale);
95+
$stringTrue = (string) $wizardTrue;
96+
$version = Accounting::icuVersion();
97+
if ($version < 72.1) {
98+
self::assertSame($stringFalse, $stringTrue);
99+
} else {
100+
self::assertSame("\u{200f}$stringTrue", $stringFalse);
101+
}
102+
}
103+
72104
/**
73105
* @dataProvider providerCurrencyLocaleNoDecimals
74106
*/
75107
public function testCurrencyLocaleNoDecimals(
76108
string $expectedResult,
77109
string $currencyCode,
78-
string $locale
110+
string $locale,
111+
?bool $stripRLM = null
79112
): void {
80113
if (class_exists(NumberFormatter::class) === false) {
81114
self::markTestSkipped('Intl extension is not available');
82115
}
83116

84117
$wizard = new Currency($currencyCode, 0);
118+
if ($stripRLM !== null) {
119+
$wizard->setStripLeadingRLM($stripRLM);
120+
}
85121
$wizard->setLocale($locale);
86122
self::assertSame($expectedResult, (string) $wizard);
87123
}
88124

89125
public static function providerCurrencyLocaleNoDecimals(): array
90126
{
127+
// \u{a0} is non-breaking space
128+
// \u{200e} is LRM (left-to-right mark)
91129
return [
92130
["[\$€-fy-NL]\u{a0}#,##0;[\$€-fy-NL]\u{a0}#,##0-", '', 'fy-NL'], // Trailing negative
93131
["[\$€-nl-NL]\u{a0}#,##0;[\$€-nl-NL]\u{a0}-#,##0", '', 'nl-NL'], // Sign between currency and value
@@ -97,7 +135,7 @@ public static function providerCurrencyLocaleNoDecimals(): array
97135
['[$$-en-CA]#,##0', '$', 'en-ca'],
98136
["#,##0\u{a0}[\$\$-fr-CA]", '$', 'fr-ca'], // Trailing currency code
99137
['[$¥-ja-JP]#,##0', '¥', 'ja-JP'], // No decimals to truncate
100-
["#,##0\u{a0}[\$د.ب-ar-BH]", 'د.ب', 'ar-BH'], // 3 decimals truncated to none
138+
["#,##0\u{a0}[\$د.ب\u{200e}-ar-BH]", "د.ب\u{200e}", 'ar-BH', true], // 3 decimals truncated to none
101139
];
102140
}
103141

0 commit comments

Comments
 (0)