Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

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  }