Skip to content

Commit

Permalink
Merge pull request #3394 from PHPOffice/NumberFormat_decimal-placing-…
Browse files Browse the repository at this point in the history
…with-question-mark

Improved handling for ? placeholder in Number Format Masks
  • Loading branch information
MarkBaker authored Feb 22, 2023
2 parents 4cefd7a + acdcb0b commit 014a120
Show file tree
Hide file tree
Showing 3 changed files with 146 additions and 34 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org).

### Changed

- Improved handling for @ in Number Format Masks [PR #3344](https://github.com/PHPOffice/PhpSpreadsheet/pull/3344)
- Improved handling for @ placeholder in Number Format Masks [PR #3344](https://github.com/PHPOffice/PhpSpreadsheet/pull/3344)
- Improved handling for ? placeholder in Number Format Masks [PR #3394](https://github.com/PHPOffice/PhpSpreadsheet/pull/3394)
- Improved support for locale settings and currency codes when matching formatted strings to numerics in the Calculation Engine [PR #3373](https://github.com/PHPOffice/PhpSpreadsheet/pull/3373) and [PR #3374](https://github.com/PHPOffice/PhpSpreadsheet/pull/3374)
- Improved support for locale settings and matching in the Advanced Value Binder [PR #3376](https://github.com/PHPOffice/PhpSpreadsheet/pull/3376)

Expand Down
95 changes: 67 additions & 28 deletions src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ private static function formatStraightNumericValue($value, string $format, array
}

$sprintf_pattern = "%0$minWidth." . strlen($right) . 'f';

/** @var float */
$valueFloat = $value;
$value = sprintf($sprintf_pattern, round($valueFloat, strlen($right)));
Expand All @@ -201,54 +202,39 @@ public static function format($value, string $format): string
// The "_" in this string has already been stripped out,
// so this test is never true. Furthermore, testing
// on Excel shows this format uses Euro symbol, not "EUR".
//if ($format === NumberFormat::FORMAT_CURRENCY_EUR_SIMPLE) {
// return 'EUR ' . sprintf('%1.2f', $value);
//}

// Find out if we need thousands separator
// This is indicated by a comma enclosed by a digit placeholder:
// #,# or 0,0
$useThousands = (bool) preg_match('/(#,#|0,0)/', $format);
if ($useThousands) {
$format = self::pregReplace('/0,0/', '00', $format);
$format = self::pregReplace('/#,#/', '##', $format);
}
// if ($format === NumberFormat::FORMAT_CURRENCY_EUR_SIMPLE) {
// return 'EUR ' . sprintf('%1.2f', $value);
// }

// Scale thousands, millions,...
// This is indicated by a number of commas after a digit placeholder:
// #, or 0.0,,
$scale = 1; // same as no scale
$matches = [];
if (preg_match('/(#|0)(,+)/', $format, $matches)) {
$scale = 1000 ** strlen($matches[2]);
$baseFormat = $format;

// strip the commas
$format = self::pregReplace('/0,+/', '0', $format);
$format = self::pregReplace('/#,+/', '#', $format);
}
$useThousands = self::areThousandsRequired($format);
$scale = self::scaleThousandsMillions($format);

if (preg_match('/#?.*\?\/(\?+|\d+)/', $format)) {
$value = FractionFormatter::format($value, $format);
} else {
// Handle the number itself

// scale number
$value = $value / $scale;
$paddingPlaceholder = (strpos($format, '?') !== false);

// Strip #
$format = self::pregReplace('/\\#(?=(?:[^"]*"[^"]*")*[^"]*\Z)/', '0', $format);
// Remove locale code [$-###]
// Replace # or ? with 0
$format = self::pregReplace('/[\\#\?](?=(?:[^"]*"[^"]*")*[^"]*\Z)/', '0', $format);
// Remove locale code [$-###] for an LCID
$format = self::pregReplace('/\[\$\-.*\]/', '', $format);

$n = '/\\[[^\\]]+\\]/';
$m = self::pregReplace($n, '', $format);

// Some non-number strings are quoted, so we'll get rid of the quotes, likewise any positional * symbols
$format = self::makeString(str_replace(['"', '*'], '', $format));

if (preg_match(self::NUMBER_REGEX, $m, $matches)) {
// There are placeholders for digits, so inject digits from the value into the mask
$value = self::formatStraightNumericValue($value, $format, $matches, $useThousands);
if ($paddingPlaceholder === true) {
$value = self::padValue($value, $baseFormat);
}
} elseif ($format !== NumberFormat::FORMAT_GENERAL) {
// Yes, I know that this is basically just a hack;
// if there's no placeholders for digits, just return the format mask "as is"
Expand All @@ -266,6 +252,13 @@ public static function format($value, string $format): string
$value = self::pregReplace('/\[\$([^\]]*)\]/u', $currencyCode, (string) $value);
}

if (
(strpos((string) $value, '0.') !== false) &&
((strpos($baseFormat, '#.') !== false) || (strpos($baseFormat, '?.') !== false))
) {
$value = preg_replace('/(\b)0\.|([^\d])0\./', '${2}.', (string) $value);
}

return (string) $value;
}

Expand All @@ -281,4 +274,50 @@ private static function pregReplace(string $pattern, string $replacement, string
{
return self::makeString(preg_replace($pattern, $replacement, $subject) ?? '');
}

public static function padValue(string $value, string $baseFormat): string
{
/** @phpstan-ignore-next-line */
[$preDecimal, $postDecimal] = preg_split('/\.(?=(?:[^"]*"[^"]*")*[^"]*\Z)/miu', $baseFormat . '.?');

$length = strlen($value);
if (strpos($postDecimal, '?') !== false) {
$value = str_pad(rtrim($value, '0. '), $length, ' ', STR_PAD_RIGHT);
}
if (strpos($preDecimal, '?') !== false) {
$value = str_pad(ltrim($value, '0, '), $length, ' ', STR_PAD_LEFT);
}

return $value;
}

/**
* Find out if we need thousands separator
* This is indicated by a comma enclosed by a digit placeholders: #, 0 or ?
*/
public static function areThousandsRequired(string &$format): bool
{
$useThousands = (bool) preg_match('/([#\?0]),([#\?0])/', $format);
if ($useThousands) {
$format = self::pregReplace('/([#\?0]),([#\?0])/', '${1}${2}', $format);
}

return $useThousands;
}

/**
* Scale thousands, millions,...
* This is indicated by a number of commas after a digit placeholder: #, or 0.0,, or ?,.
*/
public static function scaleThousandsMillions(string &$format): int
{
$scale = 1; // same as no scale
if (preg_match('/(#|0|\?)(,+)/', $format, $matches)) {
$scale = 1000 ** strlen($matches[2]);
// strip the commas
$format = self::pregReplace('/([#\?0]),+/', '${1}', $format);
}

return $scale;
}
}
82 changes: 77 additions & 5 deletions tests/data/Style/NumberFormat.php
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@
'_-€* #,##0.00_-;"-€"* #,##0.00_-;_-€* -??_-;_-@_-',
],
[
' € - ',
' € - ',
0,
'_-€* #,##0.00_-;"-€"* #,##0.00_-;_-€* -??_-;_-@_-',
],
Expand Down Expand Up @@ -1326,7 +1326,7 @@
NumberFormat::FORMAT_CURRENCY_EUR_INTEGER,
],
[
' $ - ',
' $ - ',
'0',
NumberFormat::FORMAT_ACCOUNTING_USD,
],
Expand Down Expand Up @@ -1356,7 +1356,7 @@
NumberFormat::FORMAT_ACCOUNTING_USD,
],
[
' $ - ',
' $ - ',
'-0',
NumberFormat::FORMAT_ACCOUNTING_USD,
],
Expand Down Expand Up @@ -1386,7 +1386,7 @@
NumberFormat::FORMAT_ACCOUNTING_USD,
],
[
' € - ',
' € - ',
'0',
NumberFormat::FORMAT_ACCOUNTING_EUR,
],
Expand Down Expand Up @@ -1416,7 +1416,7 @@
NumberFormat::FORMAT_ACCOUNTING_EUR,
],
[
' € - ',
' € - ',
'-0',
NumberFormat::FORMAT_ACCOUNTING_EUR,
],
Expand Down Expand Up @@ -1550,4 +1550,76 @@
1025132.36,
'#,###,.##',
],
[
'.05',
50,
'#.00,',
],
[
'50.05',
50050,
'#.00,',
],
[
'555.50',
555500,
'#.00,',
],
[
'.56',
555500,
'#.00,,',
],
// decimal placement
[
' 44.398',
44.398,
'???.???',
],
[
'102.65 ',
102.65,
'???.???',
],
[
' 2.8 ',
2.8,
'???.???',
],
[
' 3',
2.8,
'???',
],
[
'12,345',
12345,
'?,???',
],
[
'123',
123,
'?,???',
],
[
'$.50',
0.5,
'$?.00',
],
[
'Part Cost $.50',
0.5,
'Part Cost $?.00',
],
// Empty Section
[
'',
-12345.6789,
'#,##0.00;',
],
[
'',
-12345.6789,
'#,##0.00;;"---"',
],
];

0 comments on commit 014a120

Please sign in to comment.