Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 401 and 402] [Versions 401 and 403]

   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          $sign = ($number < 0.0) ? '-' : '';
  72          /** @var float */
  73          $numberFloat = $number;
  74          $number = (string) abs($numberFloat);
  75  
  76          if ($splitOnPoint && strpos($mask, '.') !== false && strpos($number, '.') !== false) {
  77              $numbers = explode('.', $number);
  78              $masks = explode('.', $mask);
  79              if (count($masks) > 2) {
  80                  $masks = self::mergeComplexNumberFormatMasks($numbers, $masks);
  81              }
  82              $integerPart = self::complexNumberFormatMask($numbers[0], $masks[0], false);
  83              $decimalPart = strrev(self::complexNumberFormatMask(strrev($numbers[1]), strrev($masks[1]), false));
  84  
  85              return "{$sign}{$integerPart}.{$decimalPart}";
  86          }
  87  
  88          $result = self::processComplexNumberFormatMask($number, $mask);
  89  
  90          return "{$sign}{$result}";
  91      }
  92  
  93      /**
  94       * @param mixed $value
  95       */
  96      private static function formatStraightNumericValue($value, string $format, array $matches, bool $useThousands): string
  97      {
  98          /** @var float */
  99          $valueFloat = $value;
 100          $left = $matches[1];
 101          $dec = $matches[2];
 102          $right = $matches[3];
 103  
 104          // minimun width of formatted number (including dot)
 105          $minWidth = strlen($left) + strlen($dec) + strlen($right);
 106          if ($useThousands) {
 107              $value = number_format(
 108                  $valueFloat,
 109                  strlen($right),
 110                  StringHelper::getDecimalSeparator(),
 111                  StringHelper::getThousandsSeparator()
 112              );
 113  
 114              return self::pregReplace(self::NUMBER_REGEX, $value, $format);
 115          }
 116  
 117          if (preg_match('/[0#]E[+-]0/i', $format)) {
 118              //    Scientific format
 119              return sprintf('%5.2E', $valueFloat);
 120          } elseif (preg_match('/0([^\d\.]+)0/', $format) || substr_count($format, '.') > 1) {
 121              if ($value == (int) $valueFloat && substr_count($format, '.') === 1) {
 122                  $value *= 10 ** strlen(explode('.', $format)[1]);
 123              }
 124  
 125              return self::complexNumberFormatMask($value, $format);
 126          }
 127  
 128          $sprintf_pattern = "%0$minWidth." . strlen($right) . 'f';
 129          /** @var float */
 130          $valueFloat = $value;
 131          $value = sprintf($sprintf_pattern, round($valueFloat, strlen($right)));
 132  
 133          return self::pregReplace(self::NUMBER_REGEX, $value, $format);
 134      }
 135  
 136      /**
 137       * @param mixed $value
 138       */
 139      public static function format($value, string $format): string
 140      {
 141          // The "_" in this string has already been stripped out,
 142          // so this test is never true. Furthermore, testing
 143          // on Excel shows this format uses Euro symbol, not "EUR".
 144          //if ($format === NumberFormat::FORMAT_CURRENCY_EUR_SIMPLE) {
 145          //    return 'EUR ' . sprintf('%1.2f', $value);
 146          //}
 147  
 148          // Some non-number strings are quoted, so we'll get rid of the quotes, likewise any positional * symbols
 149          $format = self::makeString(str_replace(['"', '*'], '', $format));
 150  
 151          // Find out if we need thousands separator
 152          // This is indicated by a comma enclosed by a digit placeholder:
 153          //        #,#   or   0,0
 154          $useThousands = (bool) preg_match('/(#,#|0,0)/', $format);
 155          if ($useThousands) {
 156              $format = self::pregReplace('/0,0/', '00', $format);
 157              $format = self::pregReplace('/#,#/', '##', $format);
 158          }
 159  
 160          // Scale thousands, millions,...
 161          // This is indicated by a number of commas after a digit placeholder:
 162          //        #,   or    0.0,,
 163          $scale = 1; // same as no scale
 164          $matches = [];
 165          if (preg_match('/(#|0)(,+)/', $format, $matches)) {
 166              $scale = 1000 ** strlen($matches[2]);
 167  
 168              // strip the commas
 169              $format = self::pregReplace('/0,+/', '0', $format);
 170              $format = self::pregReplace('/#,+/', '#', $format);
 171          }
 172          if (preg_match('/#?.*\?\/\?/', $format, $m)) {
 173              $value = FractionFormatter::format($value, $format);
 174          } else {
 175              // Handle the number itself
 176  
 177              // scale number
 178              $value = $value / $scale;
 179              // Strip #
 180              $format = self::pregReplace('/\\#/', '0', $format);
 181              // Remove locale code [$-###]
 182              $format = self::pregReplace('/\[\$\-.*\]/', '', $format);
 183  
 184              $n = '/\\[[^\\]]+\\]/';
 185              $m = self::pregReplace($n, '', $format);
 186              if (preg_match(self::NUMBER_REGEX, $m, $matches)) {
 187                  // There are placeholders for digits, so inject digits from the value into the mask
 188                  $value = self::formatStraightNumericValue($value, $format, $matches, $useThousands);
 189              } elseif ($format !== NumberFormat::FORMAT_GENERAL) {
 190                  // Yes, I know that this is basically just a hack;
 191                  //      if there's no placeholders for digits, just return the format mask "as is"
 192                  $value = self::makeString(str_replace('?', '', $format));
 193              }
 194          }
 195  
 196          if (preg_match('/\[\$(.*)\]/u', $format, $m)) {
 197              //  Currency or Accounting
 198              $currencyCode = $m[1];
 199              [$currencyCode] = explode('-', $currencyCode);
 200              if ($currencyCode == '') {
 201                  $currencyCode = StringHelper::getCurrencyCode();
 202              }
 203              $value = self::pregReplace('/\[\$([^\]]*)\]/u', $currencyCode, (string) $value);
 204          }
 205  
 206          return (string) $value;
 207      }
 208  
 209      /**
 210       * @param array|string $value
 211       */
 212      private static function makeString($value): string
 213      {
 214          return is_array($value) ? '' : "$value";
 215      }
 216  
 217      private static function pregReplace(string $pattern, string $replacement, string $subject): string
 218      {
 219          return self::makeString(preg_replace($pattern, $replacement, $subject) ?? '');
 220      }
 221  }