From f468e78b66d20f9d22b2c54603451ee0ceb1cc7f Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Wed, 22 Feb 2023 12:53:21 +0100 Subject: [PATCH 1/2] Improved handling for ? placeholder in Number Format Masks --- CHANGELOG.md | 3 +- .../Style/NumberFormat/NumberFormatter.php | 46 +++++++++-- tests/data/Style/NumberFormat.php | 82 +++++++++++++++++-- 3 files changed, 117 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aedc79c934..f3b7f2aa0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php b/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php index dfbf0b82ee..4353b7724f 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php +++ b/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php @@ -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))); @@ -201,17 +202,20 @@ 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); - //} + // if ($format === NumberFormat::FORMAT_CURRENCY_EUR_SIMPLE) { + // return 'EUR ' . sprintf('%1.2f', $value); + // } + + $baseFormat = $format; // 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); + $useThousands = (bool) preg_match('/(#,#|0,0|\?,\?)/', $format); if ($useThousands) { $format = self::pregReplace('/0,0/', '00', $format); $format = self::pregReplace('/#,#/', '##', $format); + $format = self::pregReplace('/\?,\?/', '??', $format); } // Scale thousands, millions,... @@ -219,24 +223,25 @@ public static function format($value, string $format): string // #, or 0.0,, $scale = 1; // same as no scale $matches = []; - if (preg_match('/(#|0)(,+)/', $format, $matches)) { + if (preg_match('/(#|0|\?)(,+)/', $format, $matches)) { $scale = 1000 ** strlen($matches[2]); // strip the commas $format = self::pregReplace('/0,+/', '0', $format); $format = self::pregReplace('/#,+/', '#', $format); + $format = self::pregReplace('/\?,+/', '?', $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); + $format = self::pregReplace('/[\\#\?](?=(?:[^"]*"[^"]*")*[^"]*\Z)/', '0', $format); // Remove locale code [$-###] $format = self::pregReplace('/\[\$\-.*\]/', '', $format); @@ -245,10 +250,12 @@ public static function format($value, string $format): string // 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" @@ -266,6 +273,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; } @@ -281,4 +295,20 @@ 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; + } } diff --git a/tests/data/Style/NumberFormat.php b/tests/data/Style/NumberFormat.php index 8a13205691..43291c3e94 100644 --- a/tests/data/Style/NumberFormat.php +++ b/tests/data/Style/NumberFormat.php @@ -364,7 +364,7 @@ '_-€* #,##0.00_-;"-€"* #,##0.00_-;_-€* -??_-;_-@_-', ], [ - ' € - ', + ' € - ', 0, '_-€* #,##0.00_-;"-€"* #,##0.00_-;_-€* -??_-;_-@_-', ], @@ -1326,7 +1326,7 @@ NumberFormat::FORMAT_CURRENCY_EUR_INTEGER, ], [ - ' $ - ', + ' $ - ', '0', NumberFormat::FORMAT_ACCOUNTING_USD, ], @@ -1356,7 +1356,7 @@ NumberFormat::FORMAT_ACCOUNTING_USD, ], [ - ' $ - ', + ' $ - ', '-0', NumberFormat::FORMAT_ACCOUNTING_USD, ], @@ -1386,7 +1386,7 @@ NumberFormat::FORMAT_ACCOUNTING_USD, ], [ - ' € - ', + ' € - ', '0', NumberFormat::FORMAT_ACCOUNTING_EUR, ], @@ -1416,7 +1416,7 @@ NumberFormat::FORMAT_ACCOUNTING_EUR, ], [ - ' € - ', + ' € - ', '-0', NumberFormat::FORMAT_ACCOUNTING_EUR, ], @@ -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;;"---"', + ], ]; From acdcb0b0a25ebff25198508844fe79048980cf05 Mon Sep 17 00:00:00 2001 From: MarkBaker Date: Wed, 22 Feb 2023 19:46:18 +0100 Subject: [PATCH 2/2] Refactoring and simplification of replacement code --- .../Style/NumberFormat/NumberFormatter.php | 59 +++++++++++-------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php b/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php index 4353b7724f..c1808e28cb 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php +++ b/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php @@ -208,29 +208,8 @@ public static function format($value, string $format): string $baseFormat = $format; - // 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); - $format = self::pregReplace('/\?,\?/', '??', $format); - } - - // 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]); - - // strip the commas - $format = self::pregReplace('/0,+/', '0', $format); - $format = self::pregReplace('/#,+/', '#', $format); - $format = self::pregReplace('/\?,+/', '?', $format); - } + $useThousands = self::areThousandsRequired($format); + $scale = self::scaleThousandsMillions($format); if (preg_match('/#?.*\?\/(\?+|\d+)/', $format)) { $value = FractionFormatter::format($value, $format); @@ -240,9 +219,9 @@ public static function format($value, string $format): string $value = $value / $scale; $paddingPlaceholder = (strpos($format, '?') !== false); - // Strip # + // Replace # or ? with 0 $format = self::pregReplace('/[\\#\?](?=(?:[^"]*"[^"]*")*[^"]*\Z)/', '0', $format); - // Remove locale code [$-###] + // Remove locale code [$-###] for an LCID $format = self::pregReplace('/\[\$\-.*\]/', '', $format); $n = '/\\[[^\\]]+\\]/'; @@ -311,4 +290,34 @@ public static function padValue(string $value, string $baseFormat): string 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; + } }