Skip to content

Commit ee1d4d0

Browse files
committed
Currency and Accounting Formats
Fix PHPOffice#4125. Currency and Accounting Wizards generate styles for ISO codes, but these are incorrect and cause a problem when Excel tries to open a spreadsheet containing these styles. Debugging that problem, other problems with Wizards came to light: - Currency Wizard should permit four different styles for negative numbers (as Excel does) - minus sign, minus sign and red font, paretheses, and parenthese and red font. It currently uses only minus sign. - Accounting Wizard should use parentheses for negative numbers (as Excel does). It currently uses minus sign. - Accounting Wizard should always use SYMBOL_WITH_SPACING (as Excel does). It currently permits the use of SYMBOL_WITHOUT_SPACING. What WITH_SPACING really does is to ensure decimal-point alignment among adjacent cells in a column with the same format. - Currency Wizard should always use SYMBOL_WITHOUT_SPACING (as Excel does). It currently permits the use of SYMBOL_WITH_SPACING. I am correcting these problems by: - renaming Currency Wizard to CurrencyBase - adding a `negative` property with setter to it and its constructor. - adding a new Currency which extends CurrencyBase, always using SYMBOL_WITHOUT_SPACING when formatting. - having Accounting extend CurrencyBase rather than Currency, always using SYMBOL_WITH_SPACING and NEGATIVE_PARENS when formatting. - CurrencyBase can be used if the restrictions on Currency and Accounting are not desired (e.g. the suggested accounting constant from [this unimplemented PR](PHPOffice#1576)). Excel does some funny stuff with these formats. In particular, it might try to guess if you have a particular Accounting format in mind. So the Accounting wizard for dollar sign generates a format which (a) matches FORMAT_ACCOUNTING_USD, and (b) Excel (correctly) interprets as an Accounting format for symbol $. On the other hand, the Accounting wizard for euro sign generates a format which (a) matches FORMAT_ACCOUNTING_EUR, but (b) Excel interprets as a custom code rather than an Accounting format. This in itself is not a particularly big deal, but it has made it impossible for me to see exactly what format Excel uses for trailing currency symbols for negative numbers. I can't get them to decimal-point align with positive numbers if I put any kind of space between the trailing parenthesis and the currency symbol, so I omit that. It doesn't look terrible, and it keeps everything aligned, but it might not be what people are used to. I've also changed the formatting to use spaces rather than non-breaking spaces. They seem to work just fine, and the constants mentioned above use them rather than nbsp. Fix PHPOffice#4124. Currency formats that contain an ISO currency code which contains one of the characters used to recognize a date format (hmsdy), e.g. [$HUF], are being formatted by PhpSpreadsheet as dates rather than currencies. Code is changed to recognize open bracket followed by dollar sign followed by 3 Latin alphabetic characters followed by close bracket as a non-date.
1 parent 3b15055 commit ee1d4d0

File tree

11 files changed

+831
-194
lines changed

11 files changed

+831
-194
lines changed

samples/Basic4/52_Currency.php

+469
Large diffs are not rendered by default.

src/PhpSpreadsheet/Style/NumberFormat/Formatter.php

+2
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ public static function toFormattedString($value, string $format, ?array $callBac
162162
if (
163163
// Check for date/time characters (not inside quotes)
164164
(preg_match('/(\[\$[A-Z]*-[0-9A-F]*\])*[hmsdy](?=(?:[^"]|"[^"]*")*$)/miu', $format))
165+
// Look out for Currency formats Issue 4124
166+
&& !(preg_match('/\[\$[A-Z]{3}\]/miu', $format))
165167
// A date/time with a decimal time shouldn't have a digit placeholder before the decimal point
166168
&& (preg_match('/[0\?#]\.(?![^\[]*\])/miu', $format) === 0)
167169
) {

src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php

+1
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ public static function format(mixed $value, string $format): string
236236

237237
if (preg_match('/\[\$(.*)\]/u', $format, $m)) {
238238
// Currency or Accounting
239+
$value = preg_replace('/-0+(( |\\xc2\\xa0))?\\[/', '- [', (string) $value) ?? $value;
239240
$currencyCode = $m[1];
240241
[$currencyCode] = explode('-', $currencyCode);
241242
if ($currencyCode == '') {

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

+4-56
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,11 @@
55
use NumberFormatter;
66
use PhpOffice\PhpSpreadsheet\Exception;
77

8-
class Accounting extends Currency
8+
class Accounting extends CurrencyBase
99
{
10-
/**
11-
* @param string $currencyCode the currency symbol or code to display for this mask
12-
* @param int $decimals number of decimal places to display, in the range 0-30
13-
* @param bool $thousandsSeparator indicator whether the thousands separator should be used, or not
14-
* @param bool $currencySymbolPosition indicates whether the currency symbol comes before or after the value
15-
* Possible values are Currency::LEADING_SYMBOL and Currency::TRAILING_SYMBOL
16-
* @param bool $currencySymbolSpacing indicates whether there is spacing between the currency symbol and the value
17-
* Possible values are Currency::SYMBOL_WITH_SPACING and Currency::SYMBOL_WITHOUT_SPACING
18-
* @param ?string $locale Set the locale for the currency format; or leave as the default null.
19-
* If provided, Locale values must be a valid formatted locale string (e.g. 'en-GB', 'fr', uz-Arab-AF).
20-
* Note that setting a locale will override any other settings defined in this class
21-
* other than the currency code; or decimals (unless the decimals value is set to 0).
22-
*
23-
* @throws Exception If a provided locale code is not a valid format
24-
*/
25-
public function __construct(
26-
string $currencyCode = '$',
27-
int $decimals = 2,
28-
bool $thousandsSeparator = true,
29-
bool $currencySymbolPosition = self::LEADING_SYMBOL,
30-
bool $currencySymbolSpacing = self::SYMBOL_WITHOUT_SPACING,
31-
?string $locale = null,
32-
bool $stripLeadingRLM = self::DEFAULT_STRIP_LEADING_RLM
33-
) {
34-
$this->setCurrencyCode($currencyCode);
35-
$this->setThousandsSeparator($thousandsSeparator);
36-
$this->setDecimals($decimals);
37-
$this->setCurrencySymbolPosition($currencySymbolPosition);
38-
$this->setCurrencySymbolSpacing($currencySymbolSpacing);
39-
$this->setLocale($locale);
40-
$this->stripLeadingRLM = $stripLeadingRLM;
41-
}
10+
protected ?bool $overrideSpacing = true;
11+
12+
protected ?string $overrideNegative = self::NEGATIVE_PARENS;
4213

4314
/**
4415
* @throws Exception if the Intl extension and ICU version don't support Accounting formats
@@ -76,27 +47,4 @@ private function formatCurrencyCode(): string
7647

7748
return "[\${$this->currencyCode}-{$this->locale}]";
7849
}
79-
80-
public function format(): string
81-
{
82-
if ($this->localeFormat !== null) {
83-
return $this->localeFormat;
84-
}
85-
86-
return sprintf(
87-
'_-%s%s%s0%s%s%s_-',
88-
$this->currencySymbolPosition === self::LEADING_SYMBOL ? $this->formatCurrencyCode() : null,
89-
(
90-
$this->currencySymbolPosition === self::LEADING_SYMBOL
91-
&& $this->currencySymbolSpacing === self::SYMBOL_WITH_SPACING
92-
) ? "\u{a0}" : '',
93-
$this->thousandsSeparator ? '#,##' : null,
94-
$this->decimals > 0 ? '.' . str_repeat('0', $this->decimals) : null,
95-
(
96-
$this->currencySymbolPosition === self::TRAILING_SYMBOL
97-
&& $this->currencySymbolSpacing === self::SYMBOL_WITH_SPACING
98-
) ? "\u{a0}" : '',
99-
$this->currencySymbolPosition === self::TRAILING_SYMBOL ? $this->formatCurrencyCode() : null
100-
);
101-
}
10250
}

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

+3-118
Original file line numberDiff line numberDiff line change
@@ -2,124 +2,9 @@
22

33
namespace PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard;
44

5-
use NumberFormatter;
6-
use PhpOffice\PhpSpreadsheet\Exception;
7-
8-
class Currency extends Number
5+
class Currency extends CurrencyBase
96
{
10-
public const LEADING_SYMBOL = true;
11-
12-
public const TRAILING_SYMBOL = false;
13-
14-
public const SYMBOL_WITH_SPACING = true;
15-
16-
public const SYMBOL_WITHOUT_SPACING = false;
17-
18-
protected string $currencyCode = '$';
19-
20-
protected bool $currencySymbolPosition = self::LEADING_SYMBOL;
21-
22-
protected bool $currencySymbolSpacing = self::SYMBOL_WITHOUT_SPACING;
23-
24-
protected const DEFAULT_STRIP_LEADING_RLM = false;
25-
26-
protected bool $stripLeadingRLM = self::DEFAULT_STRIP_LEADING_RLM;
27-
28-
/**
29-
* @param string $currencyCode the currency symbol or code to display for this mask
30-
* @param int $decimals number of decimal places to display, in the range 0-30
31-
* @param bool $thousandsSeparator indicator whether the thousands separator should be used, or not
32-
* @param bool $currencySymbolPosition indicates whether the currency symbol comes before or after the value
33-
* Possible values are Currency::LEADING_SYMBOL and Currency::TRAILING_SYMBOL
34-
* @param bool $currencySymbolSpacing indicates whether there is spacing between the currency symbol and the value
35-
* Possible values are Currency::SYMBOL_WITH_SPACING and Currency::SYMBOL_WITHOUT_SPACING
36-
* @param ?string $locale Set the locale for the currency format; or leave as the default null.
37-
* If provided, Locale values must be a valid formatted locale string (e.g. 'en-GB', 'fr', uz-Arab-AF).
38-
* Note that setting a locale will override any other settings defined in this class
39-
* 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+.
42-
*
43-
* @throws Exception If a provided locale code is not a valid format
44-
*/
45-
public function __construct(
46-
string $currencyCode = '$',
47-
int $decimals = 2,
48-
bool $thousandsSeparator = true,
49-
bool $currencySymbolPosition = self::LEADING_SYMBOL,
50-
bool $currencySymbolSpacing = self::SYMBOL_WITHOUT_SPACING,
51-
?string $locale = null,
52-
bool $stripLeadingRLM = self::DEFAULT_STRIP_LEADING_RLM
53-
) {
54-
$this->setCurrencyCode($currencyCode);
55-
$this->setThousandsSeparator($thousandsSeparator);
56-
$this->setDecimals($decimals);
57-
$this->setCurrencySymbolPosition($currencySymbolPosition);
58-
$this->setCurrencySymbolSpacing($currencySymbolSpacing);
59-
$this->setLocale($locale);
60-
$this->stripLeadingRLM = $stripLeadingRLM;
61-
}
62-
63-
public function setCurrencyCode(string $currencyCode): void
64-
{
65-
$this->currencyCode = $currencyCode;
66-
}
67-
68-
public function setCurrencySymbolPosition(bool $currencySymbolPosition = self::LEADING_SYMBOL): void
69-
{
70-
$this->currencySymbolPosition = $currencySymbolPosition;
71-
}
72-
73-
public function setCurrencySymbolSpacing(bool $currencySymbolSpacing = self::SYMBOL_WITHOUT_SPACING): void
74-
{
75-
$this->currencySymbolSpacing = $currencySymbolSpacing;
76-
}
77-
78-
public function setStripLeadingRLM(bool $stripLeadingRLM): void
79-
{
80-
$this->stripLeadingRLM = $stripLeadingRLM;
81-
}
82-
83-
protected function getLocaleFormat(): string
84-
{
85-
$formatter = new Locale($this->fullLocale, NumberFormatter::CURRENCY);
86-
$mask = $formatter->format($this->stripLeadingRLM);
87-
if ($this->decimals === 0) {
88-
$mask = (string) preg_replace('/\.0+/miu', '', $mask);
89-
}
90-
91-
return str_replace('¤', $this->formatCurrencyCode(), $mask);
92-
}
93-
94-
private function formatCurrencyCode(): string
95-
{
96-
if ($this->locale === null) {
97-
return $this->currencyCode;
98-
}
99-
100-
return "[\${$this->currencyCode}-{$this->locale}]";
101-
}
102-
103-
public function format(): string
104-
{
105-
if ($this->localeFormat !== null) {
106-
return $this->localeFormat;
107-
}
7+
protected ?bool $overrideSpacing = false;
1088

109-
return sprintf(
110-
'%s%s%s0%s%s%s',
111-
$this->currencySymbolPosition === self::LEADING_SYMBOL ? $this->formatCurrencyCode() : null,
112-
(
113-
$this->currencySymbolPosition === self::LEADING_SYMBOL
114-
&& $this->currencySymbolSpacing === self::SYMBOL_WITH_SPACING
115-
) ? "\u{a0}" : '',
116-
$this->thousandsSeparator ? '#,##' : null,
117-
$this->decimals > 0 ? '.' . str_repeat('0', $this->decimals) : null,
118-
(
119-
$this->currencySymbolPosition === self::TRAILING_SYMBOL
120-
&& $this->currencySymbolSpacing === self::SYMBOL_WITH_SPACING
121-
) ? "\u{a0}" : '',
122-
$this->currencySymbolPosition === self::TRAILING_SYMBOL ? $this->formatCurrencyCode() : null
123-
);
124-
}
9+
protected ?string $overrideNegative = null;
12510
}

0 commit comments

Comments
 (0)