1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\Intl\NumberFormatter;
13
14use Symfony\Component\Intl\Exception\MethodArgumentNotImplementedException;
15use Symfony\Component\Intl\Exception\MethodArgumentValueNotImplementedException;
16use Symfony\Component\Intl\Exception\MethodNotImplementedException;
17use Symfony\Component\Intl\Exception\NotImplementedException;
18use Symfony\Component\Intl\Globals\IntlGlobals;
19use Symfony\Component\Intl\Intl;
20use Symfony\Component\Intl\Locale\Locale;
21
22/**
23 * Replacement for PHP's native {@link \NumberFormatter} class.
24 *
25 * The only methods currently supported in this class are:
26 *
27 *  - {@link __construct}
28 *  - {@link create}
29 *  - {@link formatCurrency}
30 *  - {@link format}
31 *  - {@link getAttribute}
32 *  - {@link getErrorCode}
33 *  - {@link getErrorMessage}
34 *  - {@link getLocale}
35 *  - {@link parse}
36 *  - {@link setAttribute}
37 *
38 * @author Eriksen Costa <eriksen.costa@infranology.com.br>
39 * @author Bernhard Schussek <bschussek@gmail.com>
40 *
41 * @internal
42 */
43class NumberFormatter
44{
45    /* Format style constants */
46    const PATTERN_DECIMAL = 0;
47    const DECIMAL = 1;
48    const CURRENCY = 2;
49    const PERCENT = 3;
50    const SCIENTIFIC = 4;
51    const SPELLOUT = 5;
52    const ORDINAL = 6;
53    const DURATION = 7;
54    const PATTERN_RULEBASED = 9;
55    const IGNORE = 0;
56    const DEFAULT_STYLE = 1;
57
58    /* Format type constants */
59    const TYPE_DEFAULT = 0;
60    const TYPE_INT32 = 1;
61    const TYPE_INT64 = 2;
62    const TYPE_DOUBLE = 3;
63    const TYPE_CURRENCY = 4;
64
65    /* Numeric attribute constants */
66    const PARSE_INT_ONLY = 0;
67    const GROUPING_USED = 1;
68    const DECIMAL_ALWAYS_SHOWN = 2;
69    const MAX_INTEGER_DIGITS = 3;
70    const MIN_INTEGER_DIGITS = 4;
71    const INTEGER_DIGITS = 5;
72    const MAX_FRACTION_DIGITS = 6;
73    const MIN_FRACTION_DIGITS = 7;
74    const FRACTION_DIGITS = 8;
75    const MULTIPLIER = 9;
76    const GROUPING_SIZE = 10;
77    const ROUNDING_MODE = 11;
78    const ROUNDING_INCREMENT = 12;
79    const FORMAT_WIDTH = 13;
80    const PADDING_POSITION = 14;
81    const SECONDARY_GROUPING_SIZE = 15;
82    const SIGNIFICANT_DIGITS_USED = 16;
83    const MIN_SIGNIFICANT_DIGITS = 17;
84    const MAX_SIGNIFICANT_DIGITS = 18;
85    const LENIENT_PARSE = 19;
86
87    /* Text attribute constants */
88    const POSITIVE_PREFIX = 0;
89    const POSITIVE_SUFFIX = 1;
90    const NEGATIVE_PREFIX = 2;
91    const NEGATIVE_SUFFIX = 3;
92    const PADDING_CHARACTER = 4;
93    const CURRENCY_CODE = 5;
94    const DEFAULT_RULESET = 6;
95    const PUBLIC_RULESETS = 7;
96
97    /* Format symbol constants */
98    const DECIMAL_SEPARATOR_SYMBOL = 0;
99    const GROUPING_SEPARATOR_SYMBOL = 1;
100    const PATTERN_SEPARATOR_SYMBOL = 2;
101    const PERCENT_SYMBOL = 3;
102    const ZERO_DIGIT_SYMBOL = 4;
103    const DIGIT_SYMBOL = 5;
104    const MINUS_SIGN_SYMBOL = 6;
105    const PLUS_SIGN_SYMBOL = 7;
106    const CURRENCY_SYMBOL = 8;
107    const INTL_CURRENCY_SYMBOL = 9;
108    const MONETARY_SEPARATOR_SYMBOL = 10;
109    const EXPONENTIAL_SYMBOL = 11;
110    const PERMILL_SYMBOL = 12;
111    const PAD_ESCAPE_SYMBOL = 13;
112    const INFINITY_SYMBOL = 14;
113    const NAN_SYMBOL = 15;
114    const SIGNIFICANT_DIGIT_SYMBOL = 16;
115    const MONETARY_GROUPING_SEPARATOR_SYMBOL = 17;
116
117    /* Rounding mode values used by NumberFormatter::setAttribute() with NumberFormatter::ROUNDING_MODE attribute */
118    const ROUND_CEILING = 0;
119    const ROUND_FLOOR = 1;
120    const ROUND_DOWN = 2;
121    const ROUND_UP = 3;
122    const ROUND_HALFEVEN = 4;
123    const ROUND_HALFDOWN = 5;
124    const ROUND_HALFUP = 6;
125
126    /* Pad position values used by NumberFormatter::setAttribute() with NumberFormatter::PADDING_POSITION attribute */
127    const PAD_BEFORE_PREFIX = 0;
128    const PAD_AFTER_PREFIX = 1;
129    const PAD_BEFORE_SUFFIX = 2;
130    const PAD_AFTER_SUFFIX = 3;
131
132    /**
133     * The error code from the last operation.
134     *
135     * @var int
136     */
137    protected $errorCode = IntlGlobals::U_ZERO_ERROR;
138
139    /**
140     * The error message from the last operation.
141     *
142     * @var string
143     */
144    protected $errorMessage = 'U_ZERO_ERROR';
145
146    /**
147     * @var int
148     */
149    private $style;
150
151    /**
152     * Default values for the en locale.
153     */
154    private $attributes = [
155        self::FRACTION_DIGITS => 0,
156        self::GROUPING_USED => 1,
157        self::ROUNDING_MODE => self::ROUND_HALFEVEN,
158    ];
159
160    /**
161     * Holds the initialized attributes code.
162     */
163    private $initializedAttributes = [];
164
165    /**
166     * The supported styles to the constructor $styles argument.
167     */
168    private static $supportedStyles = [
169        'CURRENCY' => self::CURRENCY,
170        'DECIMAL' => self::DECIMAL,
171    ];
172
173    /**
174     * Supported attributes to the setAttribute() $attr argument.
175     */
176    private static $supportedAttributes = [
177        'FRACTION_DIGITS' => self::FRACTION_DIGITS,
178        'GROUPING_USED' => self::GROUPING_USED,
179        'ROUNDING_MODE' => self::ROUNDING_MODE,
180    ];
181
182    /**
183     * The available rounding modes for setAttribute() usage with
184     * NumberFormatter::ROUNDING_MODE. NumberFormatter::ROUND_DOWN
185     * and NumberFormatter::ROUND_UP does not have a PHP only equivalent.
186     */
187    private static $roundingModes = [
188        'ROUND_HALFEVEN' => self::ROUND_HALFEVEN,
189        'ROUND_HALFDOWN' => self::ROUND_HALFDOWN,
190        'ROUND_HALFUP' => self::ROUND_HALFUP,
191        'ROUND_CEILING' => self::ROUND_CEILING,
192        'ROUND_FLOOR' => self::ROUND_FLOOR,
193        'ROUND_DOWN' => self::ROUND_DOWN,
194        'ROUND_UP' => self::ROUND_UP,
195    ];
196
197    /**
198     * The mapping between NumberFormatter rounding modes to the available
199     * modes in PHP's round() function.
200     *
201     * @see https://php.net/round
202     */
203    private static $phpRoundingMap = [
204        self::ROUND_HALFDOWN => \PHP_ROUND_HALF_DOWN,
205        self::ROUND_HALFEVEN => \PHP_ROUND_HALF_EVEN,
206        self::ROUND_HALFUP => \PHP_ROUND_HALF_UP,
207    ];
208
209    /**
210     * The list of supported rounding modes which aren't available modes in
211     * PHP's round() function, but there's an equivalent. Keys are rounding
212     * modes, values does not matter.
213     */
214    private static $customRoundingList = [
215        self::ROUND_CEILING => true,
216        self::ROUND_FLOOR => true,
217        self::ROUND_DOWN => true,
218        self::ROUND_UP => true,
219    ];
220
221    /**
222     * The maximum value of the integer type in 32 bit platforms.
223     */
224    private static $int32Max = 2147483647;
225
226    /**
227     * The maximum value of the integer type in 64 bit platforms.
228     *
229     * @var int|float
230     */
231    private static $int64Max = 9223372036854775807;
232
233    private static $enSymbols = [
234        self::DECIMAL => ['.', ',', ';', '%', '0', '#', '-', '+', '¤', '¤¤', '.', 'E', '‰', '*', '∞', 'NaN', '@', ','],
235        self::CURRENCY => ['.', ',', ';', '%', '0', '#', '-', '+', '¤', '¤¤', '.', 'E', '‰', '*', '∞', 'NaN', '@', ','],
236    ];
237
238    private static $enTextAttributes = [
239        self::DECIMAL => ['', '', '-', '', ' ', 'XXX', ''],
240        self::CURRENCY => ['¤', '', '-¤', '', ' ', 'XXX'],
241    ];
242
243    /**
244     * @param string|null $locale  The locale code. The only currently supported locale is "en" (or null using the default locale, i.e. "en")
245     * @param int         $style   Style of the formatting, one of the format style constants.
246     *                             The only supported styles are NumberFormatter::DECIMAL
247     *                             and NumberFormatter::CURRENCY.
248     * @param string      $pattern Not supported. A pattern string in case $style is NumberFormat::PATTERN_DECIMAL or
249     *                             NumberFormat::PATTERN_RULEBASED. It must conform to  the syntax
250     *                             described in the ICU DecimalFormat or ICU RuleBasedNumberFormat documentation
251     *
252     * @see https://php.net/numberformatter.create
253     * @see http://www.icu-project.org/apiref/icu4c/classDecimalFormat.html#_details
254     * @see http://www.icu-project.org/apiref/icu4c/classRuleBasedNumberFormat.html#_details
255     *
256     * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed
257     * @throws MethodArgumentValueNotImplementedException When the $style is not supported
258     * @throws MethodArgumentNotImplementedException      When the pattern value is different than null
259     */
260    public function __construct($locale = 'en', $style = null, $pattern = null)
261    {
262        if ('en' !== $locale && null !== $locale) {
263            throw new MethodArgumentValueNotImplementedException(__METHOD__, 'locale', $locale, 'Only the locale "en" is supported');
264        }
265
266        if (!\in_array($style, self::$supportedStyles)) {
267            $message = sprintf('The available styles are: %s.', implode(', ', array_keys(self::$supportedStyles)));
268            throw new MethodArgumentValueNotImplementedException(__METHOD__, 'style', $style, $message);
269        }
270
271        if (null !== $pattern) {
272            throw new MethodArgumentNotImplementedException(__METHOD__, 'pattern');
273        }
274
275        $this->style = null !== $style ? (int) $style : null;
276    }
277
278    /**
279     * Static constructor.
280     *
281     * @param string|null $locale  The locale code. The only supported locale is "en" (or null using the default locale, i.e. "en")
282     * @param int         $style   Style of the formatting, one of the format style constants.
283     *                             The only currently supported styles are NumberFormatter::DECIMAL
284     *                             and NumberFormatter::CURRENCY.
285     * @param string      $pattern Not supported. A pattern string in case $style is NumberFormat::PATTERN_DECIMAL or
286     *                             NumberFormat::PATTERN_RULEBASED. It must conform to  the syntax
287     *                             described in the ICU DecimalFormat or ICU RuleBasedNumberFormat documentation
288     *
289     * @return self
290     *
291     * @see https://php.net/numberformatter.create
292     * @see http://www.icu-project.org/apiref/icu4c/classDecimalFormat.html#_details
293     * @see http://www.icu-project.org/apiref/icu4c/classRuleBasedNumberFormat.html#_details
294     *
295     * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed
296     * @throws MethodArgumentValueNotImplementedException When the $style is not supported
297     * @throws MethodArgumentNotImplementedException      When the pattern value is different than null
298     */
299    public static function create($locale = 'en', $style = null, $pattern = null)
300    {
301        return new self($locale, $style, $pattern);
302    }
303
304    /**
305     * Format a currency value.
306     *
307     * @param float  $value    The numeric currency value
308     * @param string $currency The 3-letter ISO 4217 currency code indicating the currency to use
309     *
310     * @return string The formatted currency value
311     *
312     * @see https://php.net/numberformatter.formatcurrency
313     * @see https://en.wikipedia.org/wiki/ISO_4217#Active_codes
314     */
315    public function formatCurrency($value, $currency)
316    {
317        if (self::DECIMAL === $this->style) {
318            return $this->format($value);
319        }
320
321        $symbol = Intl::getCurrencyBundle()->getCurrencySymbol($currency, 'en');
322        $fractionDigits = Intl::getCurrencyBundle()->getFractionDigits($currency);
323
324        $value = $this->roundCurrency($value, $currency);
325
326        $negative = false;
327        if (0 > $value) {
328            $negative = true;
329            $value *= -1;
330        }
331
332        $value = $this->formatNumber($value, $fractionDigits);
333
334        // 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).
335        $ret = $symbol.(mb_strlen($symbol, 'UTF-8') > 2 ? "\xc2\xa0" : '').$value;
336
337        return $negative ? '-'.$ret : $ret;
338    }
339
340    /**
341     * Format a number.
342     *
343     * @param int|float $value The value to format
344     * @param int       $type  Type of the formatting, one of the format type constants.
345     *                         Only type NumberFormatter::TYPE_DEFAULT is currently supported.
346     *
347     * @return bool|string The formatted value or false on error
348     *
349     * @see https://php.net/numberformatter.format
350     *
351     * @throws NotImplementedException                    If the method is called with the class $style 'CURRENCY'
352     * @throws MethodArgumentValueNotImplementedException If the $type is different than TYPE_DEFAULT
353     */
354    public function format($value, $type = self::TYPE_DEFAULT)
355    {
356        $type = (int) $type;
357
358        // The original NumberFormatter does not support this format type
359        if (self::TYPE_CURRENCY === $type) {
360            if (\PHP_VERSION_ID >= 80000) {
361                throw new \ValueError(sprintf('The format type must be a NumberFormatter::TYPE_* constant (%s given).', $type));
362            }
363
364            trigger_error(__METHOD__.'(): Unsupported format type '.$type, \E_USER_WARNING);
365
366            return false;
367        }
368
369        if (self::CURRENCY === $this->style) {
370            throw new NotImplementedException(sprintf('"%s()" method does not support the formatting of currencies (instance with CURRENCY style). "%s".', __METHOD__, NotImplementedException::INTL_INSTALL_MESSAGE));
371        }
372
373        // Only the default type is supported.
374        if (self::TYPE_DEFAULT !== $type) {
375            throw new MethodArgumentValueNotImplementedException(__METHOD__, 'type', $type, 'Only TYPE_DEFAULT is supported');
376        }
377
378        $fractionDigits = $this->getAttribute(self::FRACTION_DIGITS);
379
380        $value = $this->round($value, $fractionDigits);
381        $value = $this->formatNumber($value, $fractionDigits);
382
383        // behave like the intl extension
384        $this->resetError();
385
386        return $value;
387    }
388
389    /**
390     * Returns an attribute value.
391     *
392     * @param int $attr An attribute specifier, one of the numeric attribute constants
393     *
394     * @return int|false The attribute value on success or false on error
395     *
396     * @see https://php.net/numberformatter.getattribute
397     */
398    public function getAttribute($attr)
399    {
400        return isset($this->attributes[$attr]) ? $this->attributes[$attr] : null;
401    }
402
403    /**
404     * Returns formatter's last error code. Always returns the U_ZERO_ERROR class constant value.
405     *
406     * @return int The error code from last formatter call
407     *
408     * @see https://php.net/numberformatter.geterrorcode
409     */
410    public function getErrorCode()
411    {
412        return $this->errorCode;
413    }
414
415    /**
416     * Returns formatter's last error message. Always returns the U_ZERO_ERROR_MESSAGE class constant value.
417     *
418     * @return string The error message from last formatter call
419     *
420     * @see https://php.net/numberformatter.geterrormessage
421     */
422    public function getErrorMessage()
423    {
424        return $this->errorMessage;
425    }
426
427    /**
428     * Returns the formatter's locale.
429     *
430     * The parameter $type is currently ignored.
431     *
432     * @param int $type Not supported. The locale name type to return (Locale::VALID_LOCALE or Locale::ACTUAL_LOCALE)
433     *
434     * @return string The locale used to create the formatter. Currently always
435     *                returns "en".
436     *
437     * @see https://php.net/numberformatter.getlocale
438     */
439    public function getLocale($type = Locale::ACTUAL_LOCALE)
440    {
441        return 'en';
442    }
443
444    /**
445     * Not supported. Returns the formatter's pattern.
446     *
447     * @return string|false The pattern string used by the formatter or false on error
448     *
449     * @see https://php.net/numberformatter.getpattern
450     *
451     * @throws MethodNotImplementedException
452     */
453    public function getPattern()
454    {
455        throw new MethodNotImplementedException(__METHOD__);
456    }
457
458    /**
459     * Not supported. Returns a formatter symbol value.
460     *
461     * @param int $attr A symbol specifier, one of the format symbol constants
462     *
463     * @return string|false The symbol value or false on error
464     *
465     * @see https://php.net/numberformatter.getsymbol
466     */
467    public function getSymbol($attr)
468    {
469        return \array_key_exists($this->style, self::$enSymbols) && \array_key_exists($attr, self::$enSymbols[$this->style]) ? self::$enSymbols[$this->style][$attr] : false;
470    }
471
472    /**
473     * Not supported. Returns a formatter text attribute value.
474     *
475     * @param int $attr An attribute specifier, one of the text attribute constants
476     *
477     * @return string|false The attribute value or false on error
478     *
479     * @see https://php.net/numberformatter.gettextattribute
480     */
481    public function getTextAttribute($attr)
482    {
483        return \array_key_exists($this->style, self::$enTextAttributes) && \array_key_exists($attr, self::$enTextAttributes[$this->style]) ? self::$enTextAttributes[$this->style][$attr] : false;
484    }
485
486    /**
487     * Not supported. Parse a currency number.
488     *
489     * @param string $value    The value to parse
490     * @param string $currency Parameter to receive the currency name (reference)
491     * @param int    $position Offset to begin the parsing on return this value will hold the offset at which the parsing ended
492     *
493     * @return float|false The parsed numeric value or false on error
494     *
495     * @see https://php.net/numberformatter.parsecurrency
496     *
497     * @throws MethodNotImplementedException
498     */
499    public function parseCurrency($value, &$currency, &$position = null)
500    {
501        throw new MethodNotImplementedException(__METHOD__);
502    }
503
504    /**
505     * Parse a number.
506     *
507     * @param string $value    The value to parse
508     * @param int    $type     Type of the formatting, one of the format type constants. NumberFormatter::TYPE_DOUBLE by default.
509     * @param int    $position Offset to begin the parsing on return this value will hold the offset at which the parsing ended
510     *
511     * @return int|float|false The parsed value or false on error
512     *
513     * @see https://php.net/numberformatter.parse
514     */
515    public function parse($value, $type = self::TYPE_DOUBLE, &$position = 0)
516    {
517        $type = (int) $type;
518
519        if (self::TYPE_DEFAULT === $type || self::TYPE_CURRENCY === $type) {
520            if (\PHP_VERSION_ID >= 80000) {
521                throw new \ValueError(sprintf('The format type must be a NumberFormatter::TYPE_* constant (%d given).', $type));
522            }
523
524            trigger_error(__METHOD__.'(): Unsupported format type '.$type, \E_USER_WARNING);
525
526            return false;
527        }
528
529        // Any invalid number at the end of the string is removed.
530        // Only numbers and the fraction separator is expected in the string.
531        // If grouping is used, grouping separator also becomes a valid character.
532        $groupingMatch = $this->getAttribute(self::GROUPING_USED) ? '|(?P<grouping>\d++(,{1}\d+)++(\.\d*+)?)' : '';
533        if (preg_match("/^-?(?:\.\d++{$groupingMatch}|\d++(\.\d*+)?)/", $value, $matches)) {
534            $value = $matches[0];
535            $position = \strlen($value);
536            // value is not valid if grouping is used, but digits are not grouped in groups of three
537            if ($error = isset($matches['grouping']) && !preg_match('/^-?(?:\d{1,3}+)?(?:(?:,\d{3})++|\d*+)(?:\.\d*+)?$/', $value)) {
538                // the position on error is 0 for positive and 1 for negative numbers
539                $position = 0 === strpos($value, '-') ? 1 : 0;
540            }
541        } else {
542            $error = true;
543            $position = 0;
544        }
545
546        if ($error) {
547            IntlGlobals::setError(IntlGlobals::U_PARSE_ERROR, 'Number parsing failed');
548            $this->errorCode = IntlGlobals::getErrorCode();
549            $this->errorMessage = IntlGlobals::getErrorMessage();
550
551            return false;
552        }
553
554        $value = str_replace(',', '', $value);
555        $value = $this->convertValueDataType($value, $type);
556
557        // behave like the intl extension
558        $this->resetError();
559
560        return $value;
561    }
562
563    /**
564     * Set an attribute.
565     *
566     * @param int $attr  An attribute specifier, one of the numeric attribute constants.
567     *                   The only currently supported attributes are NumberFormatter::FRACTION_DIGITS,
568     *                   NumberFormatter::GROUPING_USED and NumberFormatter::ROUNDING_MODE.
569     * @param int $value The attribute value
570     *
571     * @return bool true on success or false on failure
572     *
573     * @see https://php.net/numberformatter.setattribute
574     *
575     * @throws MethodArgumentValueNotImplementedException When the $attr is not supported
576     * @throws MethodArgumentValueNotImplementedException When the $value is not supported
577     */
578    public function setAttribute($attr, $value)
579    {
580        $attr = (int) $attr;
581
582        if (!\in_array($attr, self::$supportedAttributes)) {
583            $message = sprintf(
584                'The available attributes are: %s',
585                implode(', ', array_keys(self::$supportedAttributes))
586            );
587
588            throw new MethodArgumentValueNotImplementedException(__METHOD__, 'attr', $value, $message);
589        }
590
591        if (self::$supportedAttributes['ROUNDING_MODE'] === $attr && $this->isInvalidRoundingMode($value)) {
592            $message = sprintf(
593                'The supported values for ROUNDING_MODE are: %s',
594                implode(', ', array_keys(self::$roundingModes))
595            );
596
597            throw new MethodArgumentValueNotImplementedException(__METHOD__, 'attr', $value, $message);
598        }
599
600        if (self::$supportedAttributes['GROUPING_USED'] === $attr) {
601            $value = $this->normalizeGroupingUsedValue($value);
602        }
603
604        if (self::$supportedAttributes['FRACTION_DIGITS'] === $attr) {
605            $value = $this->normalizeFractionDigitsValue($value);
606            if ($value < 0) {
607                // ignore negative values but do not raise an error
608                return true;
609            }
610        }
611
612        $this->attributes[$attr] = $value;
613        $this->initializedAttributes[$attr] = true;
614
615        return true;
616    }
617
618    /**
619     * Not supported. Set the formatter's pattern.
620     *
621     * @param string $pattern A pattern string in conformance with the ICU DecimalFormat documentation
622     *
623     * @return bool true on success or false on failure
624     *
625     * @see https://php.net/numberformatter.setpattern
626     * @see http://www.icu-project.org/apiref/icu4c/classDecimalFormat.html#_details
627     *
628     * @throws MethodNotImplementedException
629     */
630    public function setPattern($pattern)
631    {
632        throw new MethodNotImplementedException(__METHOD__);
633    }
634
635    /**
636     * Not supported. Set the formatter's symbol.
637     *
638     * @param int    $attr  A symbol specifier, one of the format symbol constants
639     * @param string $value The value for the symbol
640     *
641     * @return bool true on success or false on failure
642     *
643     * @see https://php.net/numberformatter.setsymbol
644     *
645     * @throws MethodNotImplementedException
646     */
647    public function setSymbol($attr, $value)
648    {
649        throw new MethodNotImplementedException(__METHOD__);
650    }
651
652    /**
653     * Not supported. Set a text attribute.
654     *
655     * @param int    $attr  An attribute specifier, one of the text attribute constants
656     * @param string $value The attribute value
657     *
658     * @return bool true on success or false on failure
659     *
660     * @see https://php.net/numberformatter.settextattribute
661     *
662     * @throws MethodNotImplementedException
663     */
664    public function setTextAttribute($attr, $value)
665    {
666        throw new MethodNotImplementedException(__METHOD__);
667    }
668
669    /**
670     * Set the error to the default U_ZERO_ERROR.
671     */
672    protected function resetError()
673    {
674        IntlGlobals::setError(IntlGlobals::U_ZERO_ERROR);
675        $this->errorCode = IntlGlobals::getErrorCode();
676        $this->errorMessage = IntlGlobals::getErrorMessage();
677    }
678
679    /**
680     * Rounds a currency value, applying increment rounding if applicable.
681     *
682     * When a currency have a rounding increment, an extra round is made after the first one. The rounding factor is
683     * determined in the ICU data and is explained as of:
684     *
685     * "the rounding increment is given in units of 10^(-fraction_digits)"
686     *
687     * The only actual rounding data as of this writing, is CHF.
688     *
689     * @param float  $value    The numeric currency value
690     * @param string $currency The 3-letter ISO 4217 currency code indicating the currency to use
691     *
692     * @return float The rounded numeric currency value
693     *
694     * @see http://en.wikipedia.org/wiki/Swedish_rounding
695     * @see http://www.docjar.com/html/api/com/ibm/icu/util/Currency.java.html#1007
696     */
697    private function roundCurrency($value, $currency)
698    {
699        $fractionDigits = Intl::getCurrencyBundle()->getFractionDigits($currency);
700        $roundingIncrement = Intl::getCurrencyBundle()->getRoundingIncrement($currency);
701
702        // Round with the formatter rounding mode
703        $value = $this->round($value, $fractionDigits);
704
705        // Swiss rounding
706        if (0 < $roundingIncrement && 0 < $fractionDigits) {
707            $roundingFactor = $roundingIncrement / pow(10, $fractionDigits);
708            $value = round($value / $roundingFactor) * $roundingFactor;
709        }
710
711        return $value;
712    }
713
714    /**
715     * Rounds a value.
716     *
717     * @param int|float $value     The value to round
718     * @param int       $precision The number of decimal digits to round to
719     *
720     * @return int|float The rounded value
721     */
722    private function round($value, $precision)
723    {
724        $precision = $this->getUninitializedPrecision($value, $precision);
725
726        $roundingModeAttribute = $this->getAttribute(self::ROUNDING_MODE);
727        if (isset(self::$phpRoundingMap[$roundingModeAttribute])) {
728            $value = round($value, $precision, self::$phpRoundingMap[$roundingModeAttribute]);
729        } elseif (isset(self::$customRoundingList[$roundingModeAttribute])) {
730            $roundingCoef = pow(10, $precision);
731            $value *= $roundingCoef;
732            $value = (float) (string) $value;
733
734            switch ($roundingModeAttribute) {
735                case self::ROUND_CEILING:
736                    $value = ceil($value);
737                    break;
738                case self::ROUND_FLOOR:
739                    $value = floor($value);
740                    break;
741                case self::ROUND_UP:
742                    $value = $value > 0 ? ceil($value) : floor($value);
743                    break;
744                case self::ROUND_DOWN:
745                    $value = $value > 0 ? floor($value) : ceil($value);
746                    break;
747            }
748
749            $value /= $roundingCoef;
750        }
751
752        return $value;
753    }
754
755    /**
756     * Formats a number.
757     *
758     * @param int|float $value     The numeric value to format
759     * @param int       $precision The number of decimal digits to use
760     *
761     * @return string The formatted number
762     */
763    private function formatNumber($value, $precision)
764    {
765        $precision = $this->getUninitializedPrecision($value, $precision);
766
767        return number_format($value, $precision, '.', $this->getAttribute(self::GROUPING_USED) ? ',' : '');
768    }
769
770    /**
771     * Returns the precision value if the DECIMAL style is being used and the FRACTION_DIGITS attribute is uninitialized.
772     *
773     * @param int|float $value     The value to get the precision from if the FRACTION_DIGITS attribute is uninitialized
774     * @param int       $precision The precision value to returns if the FRACTION_DIGITS attribute is initialized
775     *
776     * @return int The precision value
777     */
778    private function getUninitializedPrecision($value, $precision)
779    {
780        if (self::CURRENCY === $this->style) {
781            return $precision;
782        }
783
784        if (!$this->isInitializedAttribute(self::FRACTION_DIGITS)) {
785            preg_match('/.*\.(.*)/', (string) $value, $digits);
786            if (isset($digits[1])) {
787                $precision = \strlen($digits[1]);
788            }
789        }
790
791        return $precision;
792    }
793
794    /**
795     * Check if the attribute is initialized (value set by client code).
796     *
797     * @param string $attr The attribute name
798     *
799     * @return bool true if the value was set by client, false otherwise
800     */
801    private function isInitializedAttribute($attr)
802    {
803        return isset($this->initializedAttributes[$attr]);
804    }
805
806    /**
807     * Returns the numeric value using the $type to convert to the right data type.
808     *
809     * @param mixed $value The value to be converted
810     * @param int   $type  The type to convert. Can be TYPE_DOUBLE (float) or TYPE_INT32 (int)
811     *
812     * @return int|float|false The converted value
813     */
814    private function convertValueDataType($value, $type)
815    {
816        $type = (int) $type;
817
818        if (self::TYPE_DOUBLE === $type) {
819            $value = (float) $value;
820        } elseif (self::TYPE_INT32 === $type) {
821            $value = $this->getInt32Value($value);
822        } elseif (self::TYPE_INT64 === $type) {
823            $value = $this->getInt64Value($value);
824        }
825
826        return $value;
827    }
828
829    /**
830     * Convert the value data type to int or returns false if the value is out of the integer value range.
831     *
832     * @param mixed $value The value to be converted
833     *
834     * @return int|false The converted value
835     */
836    private function getInt32Value($value)
837    {
838        if ($value > self::$int32Max || $value < -self::$int32Max - 1) {
839            return false;
840        }
841
842        return (int) $value;
843    }
844
845    /**
846     * Convert the value data type to int or returns false if the value is out of the integer value range.
847     *
848     * @param mixed $value The value to be converted
849     *
850     * @return int|float|false The converted value
851     */
852    private function getInt64Value($value)
853    {
854        if ($value > self::$int64Max || $value < -self::$int64Max - 1) {
855            return false;
856        }
857
858        if (\PHP_INT_SIZE !== 8 && ($value > self::$int32Max || $value < -self::$int32Max - 1)) {
859            return (float) $value;
860        }
861
862        return (int) $value;
863    }
864
865    /**
866     * Check if the rounding mode is invalid.
867     *
868     * @param int $value The rounding mode value to check
869     *
870     * @return bool true if the rounding mode is invalid, false otherwise
871     */
872    private function isInvalidRoundingMode($value)
873    {
874        if (\in_array($value, self::$roundingModes, true)) {
875            return false;
876        }
877
878        return true;
879    }
880
881    /**
882     * Returns the normalized value for the GROUPING_USED attribute. Any value that can be converted to int will be
883     * cast to Boolean and then to int again. This way, negative values are converted to 1 and string values to 0.
884     *
885     * @param mixed $value The value to be normalized
886     *
887     * @return int The normalized value for the attribute (0 or 1)
888     */
889    private function normalizeGroupingUsedValue($value)
890    {
891        return (int) (bool) (int) $value;
892    }
893
894    /**
895     * Returns the normalized value for the FRACTION_DIGITS attribute.
896     *
897     * @param mixed $value The value to be normalized
898     *
899     * @return int The normalized value for the attribute
900     */
901    private function normalizeFractionDigitsValue($value)
902    {
903        return (int) $value;
904    }
905}
906