* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Polyfill\Intl\Icu; use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentNotImplementedException; use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentValueNotImplementedException; use Symfony\Polyfill\Intl\Icu\Exception\MethodNotImplementedException; use Symfony\Polyfill\Intl\Icu\Exception\NotImplementedException; /** * Replacement for PHP's native {@link \NumberFormatter} class. * * The only methods currently supported in this class are: * * - {@link __construct} * - {@link create} * - {@link formatCurrency} * - {@link format} * - {@link getAttribute} * - {@link getErrorCode} * - {@link getErrorMessage} * - {@link getLocale} * - {@link parse} * - {@link setAttribute} * * @author Eriksen Costa * @author Bernhard Schussek * * @internal */ abstract class NumberFormatter { /* Format style constants */ public const PATTERN_DECIMAL = 0; public const DECIMAL = 1; public const CURRENCY = 2; public const PERCENT = 3; public const SCIENTIFIC = 4; public const SPELLOUT = 5; public const ORDINAL = 6; public const DURATION = 7; public const PATTERN_RULEBASED = 9; public const IGNORE = 0; public const DEFAULT_STYLE = 1; /* Format type constants */ public const TYPE_DEFAULT = 0; public const TYPE_INT32 = 1; public const TYPE_INT64 = 2; public const TYPE_DOUBLE = 3; public const TYPE_CURRENCY = 4; /* Numeric attribute constants */ public const PARSE_INT_ONLY = 0; public const GROUPING_USED = 1; public const DECIMAL_ALWAYS_SHOWN = 2; public const MAX_INTEGER_DIGITS = 3; public const MIN_INTEGER_DIGITS = 4; public const INTEGER_DIGITS = 5; public const MAX_FRACTION_DIGITS = 6; public const MIN_FRACTION_DIGITS = 7; public const FRACTION_DIGITS = 8; public const MULTIPLIER = 9; public const GROUPING_SIZE = 10; public const ROUNDING_MODE = 11; public const ROUNDING_INCREMENT = 12; public const FORMAT_WIDTH = 13; public const PADDING_POSITION = 14; public const SECONDARY_GROUPING_SIZE = 15; public const SIGNIFICANT_DIGITS_USED = 16; public const MIN_SIGNIFICANT_DIGITS = 17; public const MAX_SIGNIFICANT_DIGITS = 18; public const LENIENT_PARSE = 19; /* Text attribute constants */ public const POSITIVE_PREFIX = 0; public const POSITIVE_SUFFIX = 1; public const NEGATIVE_PREFIX = 2; public const NEGATIVE_SUFFIX = 3; public const PADDING_CHARACTER = 4; public const CURRENCY_CODE = 5; public const DEFAULT_RULESET = 6; public const PUBLIC_RULESETS = 7; /* Format symbol constants */ public const DECIMAL_SEPARATOR_SYMBOL = 0; public const GROUPING_SEPARATOR_SYMBOL = 1; public const PATTERN_SEPARATOR_SYMBOL = 2; public const PERCENT_SYMBOL = 3; public const ZERO_DIGIT_SYMBOL = 4; public const DIGIT_SYMBOL = 5; public const MINUS_SIGN_SYMBOL = 6; public const PLUS_SIGN_SYMBOL = 7; public const CURRENCY_SYMBOL = 8; public const INTL_CURRENCY_SYMBOL = 9; public const MONETARY_SEPARATOR_SYMBOL = 10; public const EXPONENTIAL_SYMBOL = 11; public const PERMILL_SYMBOL = 12; public const PAD_ESCAPE_SYMBOL = 13; public const INFINITY_SYMBOL = 14; public const NAN_SYMBOL = 15; public const SIGNIFICANT_DIGIT_SYMBOL = 16; public const MONETARY_GROUPING_SEPARATOR_SYMBOL = 17; /* Rounding mode values used by NumberFormatter::setAttribute() with NumberFormatter::ROUNDING_MODE attribute */ public const ROUND_CEILING = 0; public const ROUND_FLOOR = 1; public const ROUND_DOWN = 2; public const ROUND_UP = 3; public const ROUND_HALFEVEN = 4; public const ROUND_HALFDOWN = 5; public const ROUND_HALFUP = 6; /* Pad position values used by NumberFormatter::setAttribute() with NumberFormatter::PADDING_POSITION attribute */ public const PAD_BEFORE_PREFIX = 0; public const PAD_AFTER_PREFIX = 1; public const PAD_BEFORE_SUFFIX = 2; public const PAD_AFTER_SUFFIX = 3; /** * The error code from the last operation. * * @var int */ protected $errorCode = Icu::U_ZERO_ERROR; /** * The error message from the last operation. * * @var string */ protected $errorMessage = 'U_ZERO_ERROR'; /** * @var int */ private $style; /** * Default values for the en locale. */ private $attributes = [ self::FRACTION_DIGITS => 0, self::GROUPING_USED => 1, self::ROUNDING_MODE => self::ROUND_HALFEVEN, ]; /** * Holds the initialized attributes code. */ private $initializedAttributes = []; /** * The supported styles to the constructor $styles argument. */ private static $supportedStyles = [ 'CURRENCY' => self::CURRENCY, 'DECIMAL' => self::DECIMAL, ]; /** * Supported attributes to the setAttribute() $attr argument. */ private static $supportedAttributes = [ 'FRACTION_DIGITS' => self::FRACTION_DIGITS, 'GROUPING_USED' => self::GROUPING_USED, 'ROUNDING_MODE' => self::ROUNDING_MODE, ]; /** * The available rounding modes for setAttribute() usage with * NumberFormatter::ROUNDING_MODE. NumberFormatter::ROUND_DOWN * and NumberFormatter::ROUND_UP does not have a PHP only equivalent. */ private static $roundingModes = [ 'ROUND_HALFEVEN' => self::ROUND_HALFEVEN, 'ROUND_HALFDOWN' => self::ROUND_HALFDOWN, 'ROUND_HALFUP' => self::ROUND_HALFUP, 'ROUND_CEILING' => self::ROUND_CEILING, 'ROUND_FLOOR' => self::ROUND_FLOOR, 'ROUND_DOWN' => self::ROUND_DOWN, 'ROUND_UP' => self::ROUND_UP, ]; /** * The mapping between NumberFormatter rounding modes to the available * modes in PHP's round() function. * * @see https://php.net/round */ private static $phpRoundingMap = [ self::ROUND_HALFDOWN => \PHP_ROUND_HALF_DOWN, self::ROUND_HALFEVEN => \PHP_ROUND_HALF_EVEN, self::ROUND_HALFUP => \PHP_ROUND_HALF_UP, ]; /** * The list of supported rounding modes which aren't available modes in * PHP's round() function, but there's an equivalent. Keys are rounding * modes, values does not matter. */ private static $customRoundingList = [ self::ROUND_CEILING => true, self::ROUND_FLOOR => true, self::ROUND_DOWN => true, self::ROUND_UP => true, ]; /** * The maximum value of the integer type in 32 bit platforms. */ private static $int32Max = 2147483647; /** * The maximum value of the integer type in 64 bit platforms. * * @var int|float */ private static $int64Max = 9223372036854775807; private static $enSymbols = [ self::DECIMAL => ['.', ',', ';', '%', '0', '#', '-', '+', '¤', '¤¤', '.', 'E', '‰', '*', '∞', 'NaN', '@', ','], self::CURRENCY => ['.', ',', ';', '%', '0', '#', '-', '+', '¤', '¤¤', '.', 'E', '‰', '*', '∞', 'NaN', '@', ','], ]; private static $enTextAttributes = [ self::DECIMAL => ['', '', '-', '', ' ', 'XXX', ''], self::CURRENCY => ['¤', '', '-¤', '', ' ', 'XXX'], ]; /** * @param string|null $locale The locale code. The only currently supported locale is "en" (or null using the default locale, i.e. "en") * @param int $style Style of the formatting, one of the format style constants. * The only supported styles are NumberFormatter::DECIMAL * and NumberFormatter::CURRENCY. * @param string $pattern Not supported. A pattern string in case $style is NumberFormat::PATTERN_DECIMAL or * NumberFormat::PATTERN_RULEBASED. It must conform to the syntax * described in the ICU DecimalFormat or ICU RuleBasedNumberFormat documentation * * @see https://php.net/numberformatter.create * @see https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/classicu_1_1DecimalFormat.html#details * @see https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/classicu_1_1RuleBasedNumberFormat.html#details * * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed * @throws MethodArgumentValueNotImplementedException When the $style is not supported * @throws MethodArgumentNotImplementedException When the pattern value is different than null */ public function __construct(?string $locale = 'en', int $style = null, string $pattern = null) { if ('en' !== $locale && null !== $locale) { throw new MethodArgumentValueNotImplementedException(__METHOD__, 'locale', $locale, 'Only the locale "en" is supported'); } if (!\in_array($style, self::$supportedStyles)) { $message = sprintf('The available styles are: %s.', implode(', ', array_keys(self::$supportedStyles))); throw new MethodArgumentValueNotImplementedException(__METHOD__, 'style', $style, $message); } if (null !== $pattern) { throw new MethodArgumentNotImplementedException(__METHOD__, 'pattern'); } $this->style = $style; } /** * Static constructor. * * @param string|null $locale The locale code. The only supported locale is "en" (or null using the default locale, i.e. "en") * @param int $style Style of the formatting, one of the format style constants. * The only currently supported styles are NumberFormatter::DECIMAL * and NumberFormatter::CURRENCY. * @param string $pattern Not supported. A pattern string in case $style is NumberFormat::PATTERN_DECIMAL or * NumberFormat::PATTERN_RULEBASED. It must conform to the syntax * described in the ICU DecimalFormat or ICU RuleBasedNumberFormat documentation * * @return static * * @see https://php.net/numberformatter.create * @see http://www.icu-project.org/apiref/icu4c/classDecimalFormat.html#_details * @see http://www.icu-project.org/apiref/icu4c/classRuleBasedNumberFormat.html#_details * * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed * @throws MethodArgumentValueNotImplementedException When the $style is not supported * @throws MethodArgumentNotImplementedException When the pattern value is different than null */ public static function create(?string $locale = 'en', int $style = null, string $pattern = null) { return new static($locale, $style, $pattern); } /** * Format a currency value. * * @return string The formatted currency value * * @see https://php.net/numberformatter.formatcurrency * @see https://en.wikipedia.org/wiki/ISO_4217#Active_codes */ public function formatCurrency(float $amount, string $currency) { if (self::DECIMAL === $this->style) { return $this->format($amount); } if (null === $symbol = Currencies::getSymbol($currency)) { return false; } $fractionDigits = Currencies::getFractionDigits($currency); $amount = $this->roundCurrency($amount, $currency); $negative = false; if (0 > $amount) { $negative = true; $amount *= -1; } $amount = $this->formatNumber($amount, $fractionDigits); // There's a non-breaking space after the currency code (i.e. CRC 100), but not if the currency has a symbol (i.e. £100). $ret = $symbol.(mb_strlen($symbol, 'UTF-8') > 2 ? "\xc2\xa0" : '').$amount; return $negative ? '-'.$ret : $ret; } /** * Format a number. * * @param int|float $num The value to format * @param int $type Type of the formatting, one of the format type constants. * Only type NumberFormatter::TYPE_DEFAULT is currently supported. * * @return bool|string The formatted value or false on error * * @see https://php.net/numberformatter.format * * @throws NotImplementedException If the method is called with the class $style 'CURRENCY' * @throws MethodArgumentValueNotImplementedException If the $type is different than TYPE_DEFAULT */ public function format($num, int $type = self::TYPE_DEFAULT) { // The original NumberFormatter does not support this format type if (self::TYPE_CURRENCY === $type) { if (\PHP_VERSION_ID >= 80000) { throw new \ValueError(sprintf('The format type must be a NumberFormatter::TYPE_* constant (%s given).', $type)); } trigger_error(__METHOD__.'(): Unsupported format type '.$type, \E_USER_WARNING); return false; } if (self::CURRENCY === $this->style) { throw new NotImplementedException(sprintf('"%s()" method does not support the formatting of currencies (instance with CURRENCY style). "%s".', __METHOD__, NotImplementedException::INTL_INSTALL_MESSAGE)); } // Only the default type is supported. if (self::TYPE_DEFAULT !== $type) { throw new MethodArgumentValueNotImplementedException(__METHOD__, 'type', $type, 'Only TYPE_DEFAULT is supported'); } $fractionDigits = $this->getAttribute(self::FRACTION_DIGITS); $num = $this->round($num, $fractionDigits); $num = $this->formatNumber($num, $fractionDigits); // behave like the intl extension $this->resetError(); return $num; } /** * Returns an attribute value. * * @return int|false The attribute value on success or false on error * * @see https://php.net/numberformatter.getattribute */ public function getAttribute(int $attribute) { return $this->attributes[$attribute] ?? null; } /** * Returns formatter's last error code. Always returns the U_ZERO_ERROR class constant value. * * @return int The error code from last formatter call * * @see https://php.net/numberformatter.geterrorcode */ public function getErrorCode() { return $this->errorCode; } /** * Returns formatter's last error message. Always returns the U_ZERO_ERROR_MESSAGE class constant value. * * @return string The error message from last formatter call * * @see https://php.net/numberformatter.geterrormessage */ public function getErrorMessage() { return $this->errorMessage; } /** * Returns the formatter's locale. * * The parameter $type is currently ignored. * * @param int $type Not supported. The locale name type to return (Locale::VALID_LOCALE or Locale::ACTUAL_LOCALE) * * @return string The locale used to create the formatter. Currently always * returns "en". * * @see https://php.net/numberformatter.getlocale */ public function getLocale(int $type = Locale::ACTUAL_LOCALE) { return 'en'; } /** * Not supported. Returns the formatter's pattern. * * @return string|false The pattern string used by the formatter or false on error * * @see https://php.net/numberformatter.getpattern * * @throws MethodNotImplementedException */ public function getPattern() { throw new MethodNotImplementedException(__METHOD__); } /** * Not supported. Returns a formatter symbol value. * * @return string|false The symbol value or false on error * * @see https://php.net/numberformatter.getsymbol */ public function getSymbol(int $symbol) { return \array_key_exists($this->style, self::$enSymbols) && \array_key_exists($symbol, self::$enSymbols[$this->style]) ? self::$enSymbols[$this->style][$symbol] : false; } /** * Not supported. Returns a formatter text attribute value. * * @return string|false The attribute value or false on error * * @see https://php.net/numberformatter.gettextattribute */ public function getTextAttribute(int $attribute) { return \array_key_exists($this->style, self::$enTextAttributes) && \array_key_exists($attribute, self::$enTextAttributes[$this->style]) ? self::$enTextAttributes[$this->style][$attribute] : false; } /** * Not supported. Parse a currency number. * * @return float|false The parsed numeric value or false on error * * @see https://php.net/numberformatter.parsecurrency * * @throws MethodNotImplementedException */ public function parseCurrency(string $string, &$currency, &$offset = null) { throw new MethodNotImplementedException(__METHOD__); } /** * Parse a number. * * @return int|float|false The parsed value or false on error * * @see https://php.net/numberformatter.parse */ public function parse(string $string, int $type = self::TYPE_DOUBLE, &$offset = null) { if (self::TYPE_DEFAULT === $type || self::TYPE_CURRENCY === $type) { if (\PHP_VERSION_ID >= 80000) { throw new \ValueError(sprintf('The format type must be a NumberFormatter::TYPE_* constant (%d given).', $type)); } trigger_error(__METHOD__.'(): Unsupported format type '.$type, \E_USER_WARNING); return false; } // Any invalid number at the end of the string is removed. // Only numbers and the fraction separator is expected in the string. // If grouping is used, grouping separator also becomes a valid character. $groupingMatch = $this->getAttribute(self::GROUPING_USED) ? '|(?P\d++(,{1}\d+)++(\.\d*+)?)' : ''; if (preg_match("/^-?(?:\.\d++{$groupingMatch}|\d++(\.\d*+)?)/", $string, $matches)) { $string = $matches[0]; $offset = \strlen($string); // value is not valid if grouping is used, but digits are not grouped in groups of three if ($error = isset($matches['grouping']) && !preg_match('/^-?(?:\d{1,3}+)?(?:(?:,\d{3})++|\d*+)(?:\.\d*+)?$/', $string)) { // the position on error is 0 for positive and 1 for negative numbers $offset = 0 === strpos($string, '-') ? 1 : 0; } } else { $error = true; $offset = 0; } if ($error) { Icu::setError(Icu::U_PARSE_ERROR, 'Number parsing failed'); $this->errorCode = Icu::getErrorCode(); $this->errorMessage = Icu::getErrorMessage(); return false; } $string = str_replace(',', '', $string); $string = $this->convertValueDataType($string, $type); // behave like the intl extension $this->resetError(); return $string; } /** * Set an attribute. * * @param int|float $value * * @return bool true on success or false on failure * * @see https://php.net/numberformatter.setattribute * * @throws MethodArgumentValueNotImplementedException When the $attribute is not supported * @throws MethodArgumentValueNotImplementedException When the $value is not supported */ public function setAttribute(int $attribute, $value) { if (!\in_array($attribute, self::$supportedAttributes)) { $message = sprintf( 'The available attributes are: %s', implode(', ', array_keys(self::$supportedAttributes)) ); throw new MethodArgumentValueNotImplementedException(__METHOD__, 'attribute', $value, $message); } if (self::$supportedAttributes['ROUNDING_MODE'] === $attribute && $this->isInvalidRoundingMode($value)) { $message = sprintf( 'The supported values for ROUNDING_MODE are: %s', implode(', ', array_keys(self::$roundingModes)) ); throw new MethodArgumentValueNotImplementedException(__METHOD__, 'attribute', $value, $message); } if (self::$supportedAttributes['GROUPING_USED'] === $attribute) { $value = $this->normalizeGroupingUsedValue($value); } if (self::$supportedAttributes['FRACTION_DIGITS'] === $attribute) { $value = $this->normalizeFractionDigitsValue($value); if ($value < 0) { // ignore negative values but do not raise an error return true; } } $this->attributes[$attribute] = $value; $this->initializedAttributes[$attribute] = true; return true; } /** * Not supported. Set the formatter's pattern. * * @return bool true on success or false on failure * * @see https://php.net/numberformatter.setpattern * @see http://www.icu-project.org/apiref/icu4c/classDecimalFormat.html#_details * * @throws MethodNotImplementedException */ public function setPattern(string $pattern) { throw new MethodNotImplementedException(__METHOD__); } /** * Not supported. Set the formatter's symbol. * * @return bool true on success or false on failure * * @see https://php.net/numberformatter.setsymbol * * @throws MethodNotImplementedException */ public function setSymbol(int $symbol, string $value) { throw new MethodNotImplementedException(__METHOD__); } /** * Not supported. Set a text attribute. * * @return bool true on success or false on failure * * @see https://php.net/numberformatter.settextattribute * * @throws MethodNotImplementedException */ public function setTextAttribute(int $attribute, string $value) { throw new MethodNotImplementedException(__METHOD__); } /** * Set the error to the default U_ZERO_ERROR. */ protected function resetError() { Icu::setError(Icu::U_ZERO_ERROR); $this->errorCode = Icu::getErrorCode(); $this->errorMessage = Icu::getErrorMessage(); } /** * Rounds a currency value, applying increment rounding if applicable. * * When a currency have a rounding increment, an extra round is made after the first one. The rounding factor is * determined in the ICU data and is explained as of: * * "the rounding increment is given in units of 10^(-fraction_digits)" * * The only actual rounding data as of this writing, is CHF. * * @see http://en.wikipedia.org/wiki/Swedish_rounding * @see http://www.docjar.com/html/api/com/ibm/icu/util/Currency.java.html#1007 */ private function roundCurrency(float $value, string $currency): float { $fractionDigits = Currencies::getFractionDigits($currency); $roundingIncrement = Currencies::getRoundingIncrement($currency); // Round with the formatter rounding mode $value = $this->round($value, $fractionDigits); // Swiss rounding if (0 < $roundingIncrement && 0 < $fractionDigits) { $roundingFactor = $roundingIncrement / 10 ** $fractionDigits; $value = round($value / $roundingFactor) * $roundingFactor; } return $value; } /** * Rounds a value. * * @param int|float $value The value to round * * @return int|float The rounded value */ private function round($value, int $precision) { $precision = $this->getUninitializedPrecision($value, $precision); $roundingModeAttribute = $this->getAttribute(self::ROUNDING_MODE); if (isset(self::$phpRoundingMap[$roundingModeAttribute])) { $value = round($value, $precision, self::$phpRoundingMap[$roundingModeAttribute]); } elseif (isset(self::$customRoundingList[$roundingModeAttribute])) { $roundingCoef = 10 ** $precision; $value *= $roundingCoef; $value = (float) (string) $value; switch ($roundingModeAttribute) { case self::ROUND_CEILING: $value = ceil($value); break; case self::ROUND_FLOOR: $value = floor($value); break; case self::ROUND_UP: $value = $value > 0 ? ceil($value) : floor($value); break; case self::ROUND_DOWN: $value = $value > 0 ? floor($value) : ceil($value); break; } $value /= $roundingCoef; } return $value; } /** * Formats a number. * * @param int|float $value The numeric value to format */ private function formatNumber($value, int $precision): string { $precision = $this->getUninitializedPrecision($value, $precision); return number_format($value, $precision, '.', $this->getAttribute(self::GROUPING_USED) ? ',' : ''); } /** * Returns the precision value if the DECIMAL style is being used and the FRACTION_DIGITS attribute is uninitialized. * * @param int|float $value The value to get the precision from if the FRACTION_DIGITS attribute is uninitialized */ private function getUninitializedPrecision($value, int $precision): int { if (self::CURRENCY === $this->style) { return $precision; } if (!$this->isInitializedAttribute(self::FRACTION_DIGITS)) { preg_match('/.*\.(.*)/', (string) $value, $digits); if (isset($digits[1])) { $precision = \strlen($digits[1]); } } return $precision; } /** * Check if the attribute is initialized (value set by client code). */ private function isInitializedAttribute(string $attr): bool { return isset($this->initializedAttributes[$attr]); } /** * Returns the numeric value using the $type to convert to the right data type. * * @param mixed $value The value to be converted * * @return int|float|false The converted value */ private function convertValueDataType($value, int $type) { if (self::TYPE_DOUBLE === $type) { $value = (float) $value; } elseif (self::TYPE_INT32 === $type) { $value = $this->getInt32Value($value); } elseif (self::TYPE_INT64 === $type) { $value = $this->getInt64Value($value); } return $value; } /** * Convert the value data type to int or returns false if the value is out of the integer value range. * * @return int|false The converted value */ private function getInt32Value($value) { if ($value > self::$int32Max || $value < -self::$int32Max - 1) { return false; } return (int) $value; } /** * Convert the value data type to int or returns false if the value is out of the integer value range. * * @return int|float|false The converted value */ private function getInt64Value($value) { if ($value > self::$int64Max || $value < -self::$int64Max - 1) { return false; } if (\PHP_INT_SIZE !== 8 && ($value > self::$int32Max || $value < -self::$int32Max - 1)) { return (float) $value; } return (int) $value; } /** * Check if the rounding mode is invalid. */ private function isInvalidRoundingMode(int $value): bool { if (\in_array($value, self::$roundingModes, true)) { return false; } return true; } /** * Returns the normalized value for the GROUPING_USED attribute. Any value that can be converted to int will be * cast to Boolean and then to int again. This way, negative values are converted to 1 and string values to 0. */ private function normalizeGroupingUsedValue($value): int { return (int) (bool) (int) $value; } /** * Returns the normalized value for the FRACTION_DIGITS attribute. */ private function normalizeFractionDigitsValue($value): int { return (int) $value; } }