diff --git a/CHANGELOG.md b/CHANGELOG.md index a8960f5b..f0364e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Fixed +- Fix number format masks containing literal (non-decimal point) dots [Issue #1079](https://github.com/PHPOffice/PhpSpreadsheet/issues/1079) - Fix number format masks containing named colours that were being misinterpreted as date formats; and add support for masks that fully replace the value with a full text string [Issue #1009](https://github.com/PHPOffice/PhpSpreadsheet/issues/1009) - Stricter-typed comparison testing in COUNTIF() and COUNTIFS() evaluation [Issue #1046](https://github.com/PHPOffice/PhpSpreadsheet/issues/1046) - COUPNUM should not return zero when settlement is in the last period - [Issue #1020](https://github.com/PHPOffice/PhpSpreadsheet/issues/1020) and [PR #1021](https://github.com/PHPOffice/PhpSpreadsheet/pull/1021) diff --git a/src/PhpSpreadsheet/Style/NumberFormat.php b/src/PhpSpreadsheet/Style/NumberFormat.php index 255f06db..8d1db5d6 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat.php +++ b/src/PhpSpreadsheet/Style/NumberFormat.php @@ -551,24 +551,32 @@ class NumberFormat extends Supervisor } } - private static function complexNumberFormatMask($number, $mask) + private static function mergeComplexNumberFormatMasks($numbers, $masks) { - $sign = ($number < 0.0); - $number = abs($number); - if (strpos($mask, '.') !== false) { - $numbers = explode('.', $number . '.0'); - $masks = explode('.', $mask . '.0'); - $result1 = self::complexNumberFormatMask($numbers[0], $masks[0]); - $result2 = strrev(self::complexNumberFormatMask(strrev($numbers[1]), strrev($masks[1]))); + $decimalCount = strlen($numbers[1]); + $postDecimalMasks = []; - return (($sign) ? '-' : '') . $result1 . '.' . $result2; - } + do { + $tempMask = array_pop($masks); + $postDecimalMasks[] = $tempMask; + $decimalCount -= strlen($tempMask); + } while ($decimalCount > 0); - $r = preg_match_all('/0+/', $mask, $result, PREG_OFFSET_CAPTURE); - if ($r > 1) { - $result = array_reverse($result[0]); + return [ + implode('.', $masks), + implode('.', array_reverse($postDecimalMasks)), + ]; + } - foreach ($result as $block) { + private static function processComplexNumberFormatMask($number, $mask) + { + $result = $number; + $maskingBlockCount = preg_match_all('/0+/', $mask, $maskingBlocks, PREG_OFFSET_CAPTURE); + + if ($maskingBlockCount > 1) { + $maskingBlocks = array_reverse($maskingBlocks[0]); + + foreach ($maskingBlocks as $block) { $divisor = 1 . $block[0]; $size = strlen($block[0]); $offset = $block[1]; @@ -584,13 +592,125 @@ class NumberFormat extends Supervisor $mask = substr_replace($mask, $number, $offset, 0); } $result = $mask; - } else { - $result = $number; } + return $result; + } + + private static function complexNumberFormatMask($number, $mask, $splitOnPoint = true) + { + $sign = ($number < 0.0); + $number = abs($number); + if ($splitOnPoint && strpos($mask, '.') !== false && strpos($number, '.') !== false) { + $numbers = explode('.', $number); + $masks = explode('.', $mask); + if (count($masks) > 2) { + $masks = self::mergeComplexNumberFormatMasks($numbers, $masks); + } + $result1 = self::complexNumberFormatMask($numbers[0], $masks[0], false); + $result2 = strrev(self::complexNumberFormatMask(strrev($numbers[1]), strrev($masks[1]), false)); + + return (($sign) ? '-' : '') . $result1 . '.' . $result2; + } + + $result = self::processComplexNumberFormatMask($number, $mask); + return (($sign) ? '-' : '') . $result; } + private static function formatAsNumber($value, $format) + { + if ($format === self::FORMAT_CURRENCY_EUR_SIMPLE) { + return 'EUR ' . sprintf('%1.2f', $value); + } + + // Some non-number strings are quoted, so we'll get rid of the quotes, likewise any positional * symbols + $format = str_replace(['"', '*'], '', $format); + + // Find out if we need thousands separator + // This is indicated by a comma enclosed by a digit placeholder: + // #,# or 0,0 + $useThousands = preg_match('/(#,#|0,0)/', $format); + if ($useThousands) { + $format = preg_replace('/0,0/', '00', $format); + $format = preg_replace('/#,#/', '##', $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 = pow(1000, strlen($matches[2])); + + // strip the commas + $format = preg_replace('/0,+/', '0', $format); + $format = preg_replace('/#,+/', '#', $format); + } + + if (preg_match('/#?.*\?\/\?/', $format, $m)) { + if ($value != (int) $value) { + self::formatAsFraction($value, $format); + } + } else { + // Handle the number itself + + // scale number + $value = $value / $scale; + + // Strip # + $format = preg_replace('/\\#/', '0', $format); + + // Remove locale code [$-###] + $format = preg_replace('/\[\$\-.*\]/', '', $format); + + $n = '/\\[[^\\]]+\\]/'; + $m = preg_replace($n, '', $format); + $number_regex = '/(0+)(\\.?)(0*)/'; + if (preg_match($number_regex, $m, $matches)) { + $left = $matches[1]; + $dec = $matches[2]; + $right = $matches[3]; + + // minimun width of formatted number (including dot) + $minWidth = strlen($left) + strlen($dec) + strlen($right); + if ($useThousands) { + $value = number_format( + $value, + strlen($right), + StringHelper::getDecimalSeparator(), + StringHelper::getThousandsSeparator() + ); + $value = preg_replace($number_regex, $value, $format); + } else { + if (preg_match('/[0#]E[+-]0/i', $format)) { + // Scientific format + $value = sprintf('%5.2E', $value); + } elseif (preg_match('/0([^\d\.]+)0/', $format) || substr_count($format, '.') > 1) { + $value = self::complexNumberFormatMask($value, $format); + } else { + $sprintf_pattern = "%0$minWidth." . strlen($right) . 'f'; + $value = sprintf($sprintf_pattern, $value); + $value = preg_replace($number_regex, $value, $format); + } + } + } + } + + if (preg_match('/\[\$(.*)\]/u', $format, $m)) { + // Currency or Accounting + $currencyCode = $m[1]; + list($currencyCode) = explode('-', $currencyCode); + if ($currencyCode == '') { + $currencyCode = StringHelper::getCurrencyCode(); + } + $value = preg_replace('/\[\$([^\]]*)\]/u', $currencyCode, $value); + } + + return $value; + } + /** * Convert a value in a pre-defined format to a PHP string. * @@ -676,92 +796,7 @@ class NumberFormat extends Supervisor // % number format self::formatAsPercentage($value, $format); } else { - if ($format === self::FORMAT_CURRENCY_EUR_SIMPLE) { - $value = 'EUR ' . sprintf('%1.2f', $value); - } else { - // Some non-number strings are quoted, so we'll get rid of the quotes, likewise any positional * symbols - $format = str_replace(['"', '*'], '', $format); - - // Find out if we need thousands separator - // This is indicated by a comma enclosed by a digit placeholder: - // #,# or 0,0 - $useThousands = preg_match('/(#,#|0,0)/', $format); - if ($useThousands) { - $format = preg_replace('/0,0/', '00', $format); - $format = preg_replace('/#,#/', '##', $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 = pow(1000, strlen($matches[2])); - - // strip the commas - $format = preg_replace('/0,+/', '0', $format); - $format = preg_replace('/#,+/', '#', $format); - } - - if (preg_match('/#?.*\?\/\?/', $format, $m)) { - if ($value != (int) $value) { - self::formatAsFraction($value, $format); - } - } else { - // Handle the number itself - - // scale number - $value = $value / $scale; - - // Strip # - $format = preg_replace('/\\#/', '0', $format); - - // Remove locale code [$-###] - $format = preg_replace('/\[\$\-.*\]/', '', $format); - - $n = '/\\[[^\\]]+\\]/'; - $m = preg_replace($n, '', $format); - $number_regex = '/(0+)(\\.?)(0*)/'; - if (preg_match($number_regex, $m, $matches)) { - $left = $matches[1]; - $dec = $matches[2]; - $right = $matches[3]; - - // minimun width of formatted number (including dot) - $minWidth = strlen($left) + strlen($dec) + strlen($right); - if ($useThousands) { - $value = number_format( - $value, - strlen($right), - StringHelper::getDecimalSeparator(), - StringHelper::getThousandsSeparator() - ); - $value = preg_replace($number_regex, $value, $format); - } else { - if (preg_match('/[0#]E[+-]0/i', $format)) { - // Scientific format - $value = sprintf('%5.2E', $value); - } elseif (preg_match('/0([^\d\.]+)0/', $format)) { - $value = self::complexNumberFormatMask($value, $format); - } else { - $sprintf_pattern = "%0$minWidth." . strlen($right) . 'f'; - $value = sprintf($sprintf_pattern, $value); - $value = preg_replace($number_regex, $value, $format); - } - } - } - } - if (preg_match('/\[\$(.*)\]/u', $format, $m)) { - // Currency or Accounting - $currencyCode = $m[1]; - list($currencyCode) = explode('-', $currencyCode); - if ($currencyCode == '') { - $currencyCode = StringHelper::getCurrencyCode(); - } - $value = preg_replace('/\[\$([^\]]*)\]/u', $currencyCode, $value); - } - } + $value = self::formatAsNumber($value, $format); } } diff --git a/tests/data/Style/NumberFormat.php b/tests/data/Style/NumberFormat.php index 12e71a4d..4c3b2f14 100644 --- a/tests/data/Style/NumberFormat.php +++ b/tests/data/Style/NumberFormat.php @@ -33,11 +33,6 @@ return [ 12, '#.0#', ], - [ - '-70', - -70, - '#,##0;[Red]-#,##0' - ], [ '0.1', 0.10000000000000001, @@ -172,6 +167,7 @@ return [ 5.25, '???/???', ], + // Complex formats [ '(001) 2-3456-789', 123456789, @@ -182,6 +178,31 @@ return [ 123456789, '0 (+00) 0000 00 00 00', ], + [ + '002-01-0035-7', + 20100357, + '000-00-0000-0', + ], + [ + '002-01-00.35-7', + 20100.357, + '000-00-00.00-0', + ], + [ + '002.01.0035.7', + 20100357, + '000\.00\.0000\.0', + ], + [ + '002.01.00.35.7', + 20100.357, + '000\.00\.00.00\.0', + ], + [ + '002.01.00.35.70', + 20100.357, + '000\.00\.00.00\.00', + ], [ '12345:67:89', 123456789, @@ -239,25 +260,33 @@ return [ 12345, '[Green]General', ], + [ + '-70', + -70, + '#,##0;[Red]-#,##0' + ], + [ + '-12,345', + -12345, + '#,##0;[Red]-#,##0' + ], // Multiple colors [ '12345', 12345, '[Blue]0;[Red]0', ], - // Multiple colors [ 'Positive', 12, '[Green]"Positive";[Red]"Negative";[Blue]"Zero"', ], - // Multiple colors + // Multiple colors with text substitution [ 'Zero', 0, '[Green]"Positive";[Red]"Negative";[Blue]"Zero"', ], - // Multiple colors [ 'Negative', -2,