Differences Between: [Versions 400 and 402] [Versions 401 and 402]
1 <?php 2 3 namespace PhpOffice\PhpSpreadsheet\Style\NumberFormat; 4 5 use PhpOffice\PhpSpreadsheet\Shared\StringHelper; 6 use PhpOffice\PhpSpreadsheet\Style\NumberFormat; 7 8 class NumberFormatter 9 { 10 private const NUMBER_REGEX = '/(0+)(\\.?)(0*)/'; 11 12 private static function mergeComplexNumberFormatMasks(array $numbers, array $masks): array 13 { 14 $decimalCount = strlen($numbers[1]); 15 $postDecimalMasks = []; 16 17 do { 18 $tempMask = array_pop($masks); 19 if ($tempMask !== null) { 20 $postDecimalMasks[] = $tempMask; 21 $decimalCount -= strlen($tempMask); 22 } 23 } while ($tempMask !== null && $decimalCount > 0); 24 25 return [ 26 implode('.', $masks), 27 implode('.', array_reverse($postDecimalMasks)), 28 ]; 29 } 30 31 /** 32 * @param mixed $number 33 */ 34 private static function processComplexNumberFormatMask($number, string $mask): string 35 { 36 /** @var string */ 37 $result = $number; 38 $maskingBlockCount = preg_match_all('/0+/', $mask, $maskingBlocks, PREG_OFFSET_CAPTURE); 39 40 if ($maskingBlockCount > 1) { 41 $maskingBlocks = array_reverse($maskingBlocks[0]); 42 43 $offset = 0; 44 foreach ($maskingBlocks as $block) { 45 $size = strlen($block[0]); 46 $divisor = 10 ** $size; 47 $offset = $block[1]; 48 49 /** @var float */ 50 $numberFloat = $number; 51 $blockValue = sprintf("%0{$size}d", fmod($numberFloat, $divisor)); 52 $number = floor($numberFloat / $divisor); 53 $mask = substr_replace($mask, $blockValue, $offset, $size); 54 } 55 /** @var string */ 56 $numberString = $number; 57 if ($number > 0) { 58 $mask = substr_replace($mask, $numberString, $offset, 0); 59 } 60 $result = $mask; 61 } 62 63 return self::makeString($result); 64 } 65 66 /** 67 * @param mixed $number 68 */ 69 private static function complexNumberFormatMask($number, string $mask, bool $splitOnPoint = true): string 70 { 71 /** @var float */ 72 $numberFloat = $number; 73 if ($splitOnPoint) { 74 $masks = explode('.', $mask); 75 if (count($masks) <= 2) { 76 $decmask = $masks[1] ?? ''; 77 $decpos = substr_count($decmask, '0'); 78 $numberFloat = round($numberFloat, $decpos); 79 } 80 } 81 $sign = ($numberFloat < 0.0) ? '-' : ''; 82 $number = self::f2s(abs($numberFloat)); 83 84 if ($splitOnPoint && strpos($mask, '.') !== false && strpos($number, '.') !== false) { 85 $numbers = explode('.', $number); 86 $masks = explode('.', $mask); 87 if (count($masks) > 2) { 88 $masks = self::mergeComplexNumberFormatMasks($numbers, $masks); 89 } 90 $integerPart = self::complexNumberFormatMask($numbers[0], $masks[0], false); 91 $numlen = strlen($numbers[1]); 92 $msklen = strlen($masks[1]); 93 if ($numlen < $msklen) { 94 $numbers[1] .= str_repeat('0', $msklen - $numlen); 95 } 96 $decimalPart = strrev(self::complexNumberFormatMask(strrev($numbers[1]), strrev($masks[1]), false)); 97 $decimalPart = substr($decimalPart, 0, $msklen); 98 99 return "{$sign}{$integerPart}.{$decimalPart}"; 100 } 101 102 if (strlen($number) < strlen($mask)) { 103 $number = str_repeat('0', strlen($mask) - strlen($number)) . $number; 104 } 105 $result = self::processComplexNumberFormatMask($number, $mask); 106 107 return "{$sign}{$result}"; 108 } 109 110 public static function f2s(float $f): string 111 { 112 return self::floatStringConvertScientific((string) $f); 113 } 114 115 public static function floatStringConvertScientific(string $s): string 116 { 117 // convert only normalized form of scientific notation: 118 // optional sign, single digit 1-9, 119 // decimal point and digits (allowed to be omitted), 120 // E (e permitted), optional sign, one or more digits 121 if (preg_match('/^([+-])?([1-9])([.]([0-9]+))?[eE]([+-]?[0-9]+)$/', $s, $matches) === 1) { 122 $exponent = (int) $matches[5]; 123 $sign = ($matches[1] === '-') ? '-' : ''; 124 if ($exponent >= 0) { 125 $exponentPlus1 = $exponent + 1; 126 $out = $matches[2] . $matches[4]; 127 $len = strlen($out); 128 if ($len < $exponentPlus1) { 129 $out .= str_repeat('0', $exponentPlus1 - $len); 130 } 131 $out = substr($out, 0, $exponentPlus1) . ((strlen($out) === $exponentPlus1) ? '' : ('.' . substr($out, $exponentPlus1))); 132 $s = "$sign$out"; 133 } else { 134 $s = $sign . '0.' . str_repeat('0', -$exponent - 1) . $matches[2] . $matches[4]; 135 } 136 } 137 138 return $s; 139 } 140 141 /** 142 * @param mixed $value 143 */ 144 private static function formatStraightNumericValue($value, string $format, array $matches, bool $useThousands): string 145 { 146 /** @var float */ 147 $valueFloat = $value; 148 $left = $matches[1]; 149 $dec = $matches[2]; 150 $right = $matches[3]; 151 152 // minimun width of formatted number (including dot) 153 $minWidth = strlen($left) + strlen($dec) + strlen($right); 154 if ($useThousands) { 155 $value = number_format( 156 $valueFloat, 157 strlen($right), 158 StringHelper::getDecimalSeparator(), 159 StringHelper::getThousandsSeparator() 160 ); 161 162 return self::pregReplace(self::NUMBER_REGEX, $value, $format); 163 } 164 165 if (preg_match('/[0#]E[+-]0/i', $format)) { 166 // Scientific format 167 $decimals = strlen($right); 168 $size = $decimals + 3; 169 170 return sprintf("%{$size}.{$decimals}E", $valueFloat); 171 } elseif (preg_match('/0([^\d\.]+)0/', $format) || substr_count($format, '.') > 1) { 172 if ($valueFloat == floor($valueFloat) && substr_count($format, '.') === 1) { 173 $value *= 10 ** strlen(explode('.', $format)[1]); 174 } 175 176 $result = self::complexNumberFormatMask($value, $format); 177 if (strpos($result, 'E') !== false) { 178 // This is a hack and doesn't match Excel. 179 // It will, at least, be an accurate representation, 180 // even if formatted incorrectly. 181 // This is needed for absolute values >=1E18. 182 $result = self::f2s($valueFloat); 183 } 184 185 return $result; 186 } 187 188 $sprintf_pattern = "%0$minWidth." . strlen($right) . 'f'; 189 190 /** @var float */ 191 $valueFloat = $value; 192 $value = sprintf($sprintf_pattern, round($valueFloat, strlen($right))); 193 194 return self::pregReplace(self::NUMBER_REGEX, $value, $format); 195 } 196 197 /** 198 * @param mixed $value 199 */ 200 public static function format($value, string $format): string 201 { 202 // The "_" in this string has already been stripped out, 203 // so this test is never true. Furthermore, testing 204 // on Excel shows this format uses Euro symbol, not "EUR". 205 // if ($format === NumberFormat::FORMAT_CURRENCY_EUR_SIMPLE) { 206 // return 'EUR ' . sprintf('%1.2f', $value); 207 // } 208 209 $baseFormat = $format; 210 211 $useThousands = self::areThousandsRequired($format); 212 $scale = self::scaleThousandsMillions($format); 213 214 if (preg_match('/[#\?0]?.*[#\?0]\/(\?+|\d+|#)/', $format)) { 215 // It's a dirty hack; but replace # and 0 digit placeholders with ? 216 $format = (string) preg_replace('/[#0]+\//', '?/', $format); 217 $format = (string) preg_replace('/\/[#0]+/', '/?', $format); 218 $value = FractionFormatter::format($value, $format); 219 } else { 220 // Handle the number itself 221 // scale number 222 $value = $value / $scale; 223 $paddingPlaceholder = (strpos($format, '?') !== false); 224 225 // Replace # or ? with 0 226 $format = self::pregReplace('/[\\#\?](?=(?:[^"]*"[^"]*")*[^"]*\Z)/', '0', $format); 227 // Remove locale code [$-###] for an LCID 228 $format = self::pregReplace('/\[\$\-.*\]/', '', $format); 229 230 $n = '/\\[[^\\]]+\\]/'; 231 $m = self::pregReplace($n, '', $format); 232 233 // Some non-number strings are quoted, so we'll get rid of the quotes, likewise any positional * symbols 234 $format = self::makeString(str_replace(['"', '*'], '', $format)); 235 if (preg_match(self::NUMBER_REGEX, $m, $matches)) { 236 // There are placeholders for digits, so inject digits from the value into the mask 237 $value = self::formatStraightNumericValue($value, $format, $matches, $useThousands); 238 if ($paddingPlaceholder === true) { 239 $value = self::padValue($value, $baseFormat); 240 } 241 } elseif ($format !== NumberFormat::FORMAT_GENERAL) { 242 // Yes, I know that this is basically just a hack; 243 // if there's no placeholders for digits, just return the format mask "as is" 244 $value = self::makeString(str_replace('?', '', $format)); 245 } 246 } 247 248 if (preg_match('/\[\$(.*)\]/u', $format, $m)) { 249 // Currency or Accounting 250 $currencyCode = $m[1]; 251 [$currencyCode] = explode('-', $currencyCode); 252 if ($currencyCode == '') { 253 $currencyCode = StringHelper::getCurrencyCode(); 254 } 255 $value = self::pregReplace('/\[\$([^\]]*)\]/u', $currencyCode, (string) $value); 256 } 257 258 if ( 259 (strpos((string) $value, '0.') !== false) && 260 ((strpos($baseFormat, '#.') !== false) || (strpos($baseFormat, '?.') !== false)) 261 ) { 262 $value = preg_replace('/(\b)0\.|([^\d])0\./', '$2}.', (string) $value); 263 } 264 265 return (string) $value; 266 } 267 268 /** 269 * @param array|string $value 270 */ 271 private static function makeString($value): string 272 { 273 return is_array($value) ? '' : "$value"; 274 } 275 276 private static function pregReplace(string $pattern, string $replacement, string $subject): string 277 { 278 return self::makeString(preg_replace($pattern, $replacement, $subject) ?? ''); 279 } 280 281 public static function padValue(string $value, string $baseFormat): string 282 { 283 /** @phpstan-ignore-next-line */ 284 [$preDecimal, $postDecimal] = preg_split('/\.(?=(?:[^"]*"[^"]*")*[^"]*\Z)/miu', $baseFormat . '.?'); 285 286 $length = strlen($value); 287 if (strpos($postDecimal, '?') !== false) { 288 $value = str_pad(rtrim($value, '0. '), $length, ' ', STR_PAD_RIGHT); 289 } 290 if (strpos($preDecimal, '?') !== false) { 291 $value = str_pad(ltrim($value, '0, '), $length, ' ', STR_PAD_LEFT); 292 } 293 294 return $value; 295 } 296 297 /** 298 * Find out if we need thousands separator 299 * This is indicated by a comma enclosed by a digit placeholders: #, 0 or ? 300 */ 301 public static function areThousandsRequired(string &$format): bool 302 { 303 $useThousands = (bool) preg_match('/([#\?0]),([#\?0])/', $format); 304 if ($useThousands) { 305 $format = self::pregReplace('/([#\?0]),([#\?0])/', '$1}$2}', $format); 306 } 307 308 return $useThousands; 309 } 310 311 /** 312 * Scale thousands, millions,... 313 * This is indicated by a number of commas after a digit placeholder: #, or 0.0,, or ?,. 314 */ 315 public static function scaleThousandsMillions(string &$format): int 316 { 317 $scale = 1; // same as no scale 318 if (preg_match('/(#|0|\?)(,+)/', $format, $matches)) { 319 $scale = 1000 ** strlen($matches[2]); 320 // strip the commas 321 $format = self::pregReplace('/([#\?0]),+/', '$1}', $format); 322 } 323 324 return $scale; 325 } 326 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body