NumberFormatter.php 30 KB

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