Skip to content

Commit e2c5014

Browse files
committed
Wizards for defining Number Format masks for Numbers, Percentages, Scientific, Currency, and Accounting
1 parent fc8966e commit e2c5014

File tree

14 files changed

+882
-0
lines changed

14 files changed

+882
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
1010
### Added
1111

1212
- Support for configuring a Chart Title's overlay [PR #3325](https://github.com/PHPOffice/PhpSpreadsheet/pull/3325)
13+
- Wizards for defining Number Format masks for Numbers, Percentages, Scientific, Currency and Accounting [PR #3334](https://github.com/PHPOffice/PhpSpreadsheet/pull/3334)
1314

1415
### Changed
1516

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard;
4+
5+
use NumberFormatter;
6+
use PhpOffice\PhpSpreadsheet\Exception;
7+
8+
class Accounting extends Currency
9+
{
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.
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+
) {
33+
$this->setCurrencyCode($currencyCode);
34+
$this->setThousandsSeparator($thousandsSeparator);
35+
$this->setDecimals($decimals);
36+
$this->setCurrencySymbolPosition($currencySymbolPosition);
37+
$this->setCurrencySymbolSpacing($currencySymbolSpacing);
38+
$this->setLocale($locale);
39+
}
40+
41+
protected function getLocaleFormat(): string
42+
{
43+
$formatter = new Locale($this->fullLocale, NumberFormatter::CURRENCY_ACCOUNTING); // @phpstan-ignore-line
44+
45+
return str_replace('¤', $this->formatCurrencyCode(), $formatter->format());
46+
}
47+
48+
private function formatCurrencyCode(): string
49+
{
50+
if ($this->locale === null) {
51+
return $this->currencyCode . '*';
52+
}
53+
54+
return "[\${$this->currencyCode}-{$this->locale}]";
55+
}
56+
57+
public function format(): string
58+
{
59+
if ($this->localeFormat !== null) {
60+
return $this->localeFormat;
61+
}
62+
63+
return sprintf(
64+
'_-%s%s%s0%s%s%s_-',
65+
$this->currencySymbolPosition === self::LEADING_SYMBOL ? $this->formatCurrencyCode() : null,
66+
(
67+
$this->currencySymbolPosition === self::LEADING_SYMBOL &&
68+
$this->currencySymbolSpacing === self::SYMBOL_WITH_SPACING
69+
) ? ' ' : '',
70+
$this->thousandsSeparator ? '#,##' : null,
71+
$this->decimals > 0 ? '.' . str_repeat('0', $this->decimals) : null,
72+
(
73+
$this->currencySymbolPosition === self::TRAILING_SYMBOL &&
74+
$this->currencySymbolSpacing === self::SYMBOL_WITH_SPACING
75+
) ? ' ' : '',
76+
$this->currencySymbolPosition === self::TRAILING_SYMBOL ? $this->formatCurrencyCode() : null
77+
);
78+
}
79+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard;
4+
5+
use NumberFormatter;
6+
use PhpOffice\PhpSpreadsheet\Exception;
7+
8+
class Currency extends Number
9+
{
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+
/**
25+
* @param string $currencyCode the currency symbol or code to display for this mask
26+
* @param int $decimals number of decimal places to display, in the range 0-30
27+
* @param bool $thousandsSeparator indicator whether the thousands separator should be used, or not
28+
* @param bool $currencySymbolPosition indicates whether the currency symbol comes before or after the value
29+
* Possible values are Currency::LEADING_SYMBOL and Currency::TRAILING_SYMBOL
30+
* @param bool $currencySymbolSpacing indicates whether there is spacing between the currency symbol and the value
31+
* Possible values are Currency::SYMBOL_WITH_SPACING and Currency::SYMBOL_WITHOUT_SPACING
32+
* @param ?string $locale Set the locale for the currency format; or leave as the default null.
33+
* If provided, Locale values must be a valid formatted locale string (e.g. 'en-GB', 'fr', uz-Arab-AF).
34+
* Note that setting a locale will override any other settings defined in this class
35+
* other than the currency code.
36+
*
37+
* @throws Exception If a provided locale code is not a valid format
38+
*/
39+
public function __construct(
40+
string $currencyCode = '$',
41+
int $decimals = 2,
42+
bool $thousandsSeparator = true,
43+
bool $currencySymbolPosition = self::LEADING_SYMBOL,
44+
bool $currencySymbolSpacing = self::SYMBOL_WITHOUT_SPACING,
45+
?string $locale = null
46+
) {
47+
$this->setCurrencyCode($currencyCode);
48+
$this->setThousandsSeparator($thousandsSeparator);
49+
$this->setDecimals($decimals);
50+
$this->setCurrencySymbolPosition($currencySymbolPosition);
51+
$this->setCurrencySymbolSpacing($currencySymbolSpacing);
52+
$this->setLocale($locale);
53+
}
54+
55+
public function setCurrencyCode(string $currencyCode): void
56+
{
57+
$this->currencyCode = $currencyCode;
58+
}
59+
60+
public function setCurrencySymbolPosition(bool $currencySymbolPosition = self::LEADING_SYMBOL): void
61+
{
62+
$this->currencySymbolPosition = $currencySymbolPosition;
63+
}
64+
65+
public function setCurrencySymbolSpacing(bool $currencySymbolSpacing = self::SYMBOL_WITHOUT_SPACING): void
66+
{
67+
$this->currencySymbolSpacing = $currencySymbolSpacing;
68+
}
69+
70+
protected function getLocaleFormat(): string
71+
{
72+
$formatter = new Locale($this->fullLocale, NumberFormatter::CURRENCY); // @phpstan-ignore-line
73+
74+
return str_replace('¤', $this->formatCurrencyCode(), $formatter->format());
75+
}
76+
77+
private function formatCurrencyCode(): string
78+
{
79+
if ($this->locale === null) {
80+
return $this->currencyCode;
81+
}
82+
83+
return "[\${$this->currencyCode}-{$this->locale}]";
84+
}
85+
86+
public function format(): string
87+
{
88+
if ($this->localeFormat !== null) {
89+
return $this->localeFormat;
90+
}
91+
92+
return sprintf(
93+
'%s%s%s0%s%s%s',
94+
$this->currencySymbolPosition === self::LEADING_SYMBOL ? $this->formatCurrencyCode() : null,
95+
(
96+
$this->currencySymbolPosition === self::LEADING_SYMBOL &&
97+
$this->currencySymbolSpacing === self::SYMBOL_WITH_SPACING
98+
) ? "\u{a0}" : '',
99+
$this->thousandsSeparator ? '#,##' : null,
100+
$this->decimals > 0 ? '.' . str_repeat('0', $this->decimals) : null,
101+
(
102+
$this->currencySymbolPosition === self::TRAILING_SYMBOL &&
103+
$this->currencySymbolSpacing === self::SYMBOL_WITH_SPACING
104+
) ? "\u{a0}" : '',
105+
$this->currencySymbolPosition === self::TRAILING_SYMBOL ? $this->formatCurrencyCode() : null
106+
);
107+
}
108+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard;
4+
5+
use NumberFormatter;
6+
use PhpOffice\PhpSpreadsheet\Exception;
7+
8+
final class Locale
9+
{
10+
/**
11+
* Language code: ISO-639 2 character, alpha.
12+
* Optional script code: ISO-15924 4 alpha.
13+
* Optional country code: ISO-3166-1, 2 character alpha.
14+
* Separated by underscores or dashes.
15+
*/
16+
public const STRUCTURE = '/^(?P<language>[a-z]{2})([-_](?P<script>[a-z]{4}))?([-_](?P<country>[a-z]{2}))?$/i';
17+
18+
private NumberFormatter $formatter;
19+
20+
public function __construct(string $locale, int $style)
21+
{
22+
if (class_exists(NumberFormatter::class) === false) {
23+
throw new Exception();
24+
}
25+
26+
$formatterLocale = str_replace('-', '_', $locale);
27+
$this->formatter = new NumberFormatter($formatterLocale, $style);
28+
if ($this->formatter->getLocale() !== $formatterLocale) {
29+
throw new Exception("Unable to read locale data for '{$locale}'");
30+
}
31+
}
32+
33+
public function format(): string
34+
{
35+
return $this->formatter->getPattern();
36+
}
37+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard;
4+
5+
use PhpOffice\PhpSpreadsheet\Exception;
6+
7+
class Number extends NumberBase implements Wizard
8+
{
9+
public const WITH_THOUSANDS_SEPARATOR = true;
10+
11+
public const WITHOUT_THOUSANDS_SEPARATOR = false;
12+
13+
protected bool $thousandsSeparator = true;
14+
15+
/**
16+
* @param int $decimals number of decimal places to display, in the range 0-30
17+
* @param bool $thousandsSeparator indicator whether the thousands separator should be used, or not
18+
* @param ?string $locale Set the locale for the number format; or leave as the default null.
19+
* Locale has no effect for Number Format values, and is retained here only for compatibility
20+
* with the other Wizards.
21+
* If provided, Locale values must be a valid formatted locale string (e.g. 'en-GB', 'fr', uz-Arab-AF).
22+
*
23+
* @throws Exception If a provided locale code is not a valid format
24+
*/
25+
public function __construct(
26+
int $decimals = 2,
27+
bool $thousandsSeparator = self::WITH_THOUSANDS_SEPARATOR,
28+
?string $locale = null
29+
) {
30+
$this->setDecimals($decimals);
31+
$this->setThousandsSeparator($thousandsSeparator);
32+
$this->setLocale($locale);
33+
}
34+
35+
public function setThousandsSeparator(bool $thousandsSeparator = self::WITH_THOUSANDS_SEPARATOR): void
36+
{
37+
$this->thousandsSeparator = $thousandsSeparator;
38+
}
39+
40+
/**
41+
* As MS Excel cannot easily handle Lakh, which is the only locale-specific Number format variant,
42+
* we don't use locale with Numbers.
43+
*/
44+
protected function getLocaleFormat(): string
45+
{
46+
return $this->format();
47+
}
48+
49+
public function format(): string
50+
{
51+
return sprintf(
52+
'%s0%s',
53+
$this->thousandsSeparator ? '#,##' : null,
54+
$this->decimals > 0 ? '.' . str_repeat('0', $this->decimals) : null
55+
);
56+
}
57+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard;
4+
5+
use NumberFormatter;
6+
use PhpOffice\PhpSpreadsheet\Exception;
7+
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
8+
9+
abstract class NumberBase
10+
{
11+
protected const MAX_DECIMALS = 30;
12+
13+
protected int $decimals = 2;
14+
15+
protected ?string $locale = null;
16+
17+
protected ?string $fullLocale = null;
18+
19+
protected ?string $localeFormat = null;
20+
21+
public function setDecimals(int $decimals = 2, ?string $locale = null): void
22+
{
23+
$this->decimals = ($decimals > self::MAX_DECIMALS) ? self::MAX_DECIMALS : max($decimals, 0);
24+
}
25+
26+
/**
27+
* Setting a locale will override any settings defined in this class.
28+
*
29+
* @throws Exception If the locale code is not a valid format
30+
*/
31+
public function setLocale(?string $locale = null): void
32+
{
33+
if ($locale === null) {
34+
$this->localeFormat = $this->locale = $this->fullLocale = null;
35+
36+
return;
37+
}
38+
39+
$this->locale = $this->validateLocale($locale);
40+
41+
if (class_exists(NumberFormatter::class)) {
42+
$this->localeFormat = $this->getLocaleFormat();
43+
}
44+
}
45+
46+
/**
47+
* Stub: should be implemented as a concrete method in concrete wizards.
48+
*/
49+
abstract protected function getLocaleFormat(): string;
50+
51+
/**
52+
* @throws Exception If the locale code is not a valid format
53+
*/
54+
private function validateLocale(string $locale): string
55+
{
56+
if (preg_match(Locale::STRUCTURE, $locale, $matches, PREG_UNMATCHED_AS_NULL) !== 1) {
57+
throw new Exception("Invalid locale code '{$locale}'");
58+
}
59+
60+
['language' => $language, 'script' => $script, 'country' => $country] = $matches;
61+
// Set case and separator to match standardised locale case
62+
$language = strtolower($language ?? '');
63+
$script = ($script === null) ? null : ucfirst(strtolower($script));
64+
$country = ($country === null) ? null : strtoupper($country);
65+
66+
$this->fullLocale = implode('-', array_filter([$language, $script, $country]));
67+
68+
return $country === null ? $language : "{$language}-{$country}";
69+
}
70+
71+
public function format(): string
72+
{
73+
return NumberFormat::FORMAT_GENERAL;
74+
}
75+
76+
public function __toString(): string
77+
{
78+
return $this->format();
79+
}
80+
}

0 commit comments

Comments
 (0)