diff --git a/src/fbt/Runtime/Shared/intlNumUtils.php b/src/fbt/Runtime/Shared/intlNumUtils.php index f218b7d..1ec6c98 100644 --- a/src/fbt/Runtime/Shared/intlNumUtils.php +++ b/src/fbt/Runtime/Shared/intlNumUtils.php @@ -4,179 +4,41 @@ use fbt\Lib\NumberFormatConsts; -define("DEFAULT_GROUPING_SIZE", 3); - -define("CURRENCIES_WITH_DOTS", [ - "\u{0433}\u{0440}\u{043d}.", - "\u{0434}\u{0435}\u{043d}.", - "\u{043b}\u{0432}.", - "\u{043c}\u{0430}\u{043d}.", - "\u{0564}\u{0580}.", - "\u{062c}.\u{0645}.", - "\u{062f}.\u{0625}.", - "\u{062f}.\u{0627}.", - "\u{062f}.\u{0628}.", - "\u{062f}.\u{062a}.", - "\u{062f}.\u{062c}.", - "\u{062f}.\u{0639}.", - "\u{062f}.\u{0643}.", - "\u{062f}.\u{0644}.", - "\u{062f}.\u{0645}.", - "\u{0631}.\u{0633}.", - "\u{0631}.\u{0639}.", - "\u{0631}.\u{0642}.", - "\u{0631}.\u{064a}.", - "\u{0644}.\u{0633}.", - "\u{0644}.\u{0644}.", - "\u{0783}.", - 'B\/.', - 'Bs.', - 'Fr.', - 'kr.', - 'L.', - 'p.', - 'S\/.', -]); - -function _buildRegex($pattern) -{ - static $_regexCache; - - if (! isset($_regexCache[$pattern])) { - $_regexCache[$pattern] = '/' . $pattern . '/iu'; - } - - return $_regexCache[$pattern]; -} - -/** - * Escapes regex special characters from a string, so it can be - * used as a raw search term inside an actual regex. - */ -function escapeRegex($str): string -{ - return preg_quote($str, '/'); -} - -function matchCurrenciesWithDots() -{ - return _buildRegex(array_reduce(CURRENCIES_WITH_DOTS, function ($regex, $representation) { - return $regex . ($regex ? '|' : '') . '(' . $representation . ')'; - }, '')); -} - -function _replaceWithNativeDigits(string $number, string $digits): string -{ - $result = ''; - $digitsArray = mb_str_split($digits); - - for ($i = 0; $i < mb_strlen($number); $i++) { - $char = mb_substr($number, $i, 1); - $charCode = ord($char); - if ($charCode >= 48 && $charCode <= 57) { - $nativeDigit = $digitsArray[$charCode - 48] ?? null; - $result .= $nativeDigit !== null ? $nativeDigit : $char; - } else { - $result .= $char; - } - } - - return $result; -} - -/** - * Calculate how many powers of 10 there are in a given number - * I.e. 1.23 has 0, 100 and 999 have 2, and 1000 has 3. - * Used in the inflation and rounding calculations below. - */ -function _getNumberOfPowersOfTen(int $value): float -{ - if ($value === 0) { - return 0; - } - - return floor(log10(abs($value))); -} - - -function _roundNumber($valueParam, $decimalsParam = null): string -{ - $decimals = $decimalsParam ?? 0; - $pow = 10 ** $decimals; - $value = $valueParam; - $value = round($value * $pow) / $pow; - $value = (string)$value; - if (! $decimals) { - return $value; - } - - // if value is small and - // was converted to scientific notation, don't append anything - // as we are already done - if (strstr($value, 'E-')) { - return $value; - } - - $pos = strpos($value, '.'); - - if ($pos === false) { - $value .= '.'; - $zeros = $decimals; - } else { - $zeros = $decimals - (strlen($value) - $pos - 1); - } - for ($i = 0, $l = $zeros; $i < $l; $i++) { - $value .= '0'; - } - - return $value; -} - -function addZeros($x, $count): string -{ - $result = $x; - if ($count > 0) { - $result .= str_repeat('0', $count); - } - - return $result; -} - -/** - * A codified number has \u0001 in the place of a decimal separator and a - * \u0002 in the place of a negative sign. - */ -function _parseCodifiedNumber($text): ?float -{ - $_text = preg_replace("/[^0-9\u{0001}\u{0002}]/", '', $text);// decimal separator and negative sign - $_text = preg_replace("/\u{0001}/", '.', $_text);// restore decimal separator - $_text = preg_replace("/\u{0002}/", '-', $_text);// restore negative sign - - $value = floatval($_text); - - return $_text === '' || is_nan($value) ? null : $value; -} - -function _getNativeDigitsMap(): ?array -{ - $numberFormatConfig = intlNumUtils::config(); - $nativeDigitMap = []; - $digits = $numberFormatConfig['numberingSystemData']['digits'] ?? $numberFormatConfig['numberingSystemData']; - - if ($digits == null) { - return null; - } - - foreach (mb_str_split($digits) as $i => $char) { - $nativeDigitMap[$char] = (string)$i; - } - - return $nativeDigitMap; -} - class intlNumUtils { protected static $config = []; + public const DEFAULT_GROUPING_SIZE = 3; + public const CURRENCIES_WITH_DOTS = [ + "\u{0433}\u{0440}\u{043d}.", + "\u{0434}\u{0435}\u{043d}.", + "\u{043b}\u{0432}.", + "\u{043c}\u{0430}\u{043d}.", + "\u{0564}\u{0580}.", + "\u{062c}.\u{0645}.", + "\u{062f}.\u{0625}.", + "\u{062f}.\u{0627}.", + "\u{062f}.\u{0628}.", + "\u{062f}.\u{062a}.", + "\u{062f}.\u{062c}.", + "\u{062f}.\u{0639}.", + "\u{062f}.\u{0643}.", + "\u{062f}.\u{0644}.", + "\u{062f}.\u{0645}.", + "\u{0631}.\u{0633}.", + "\u{0631}.\u{0639}.", + "\u{0631}.\u{0642}.", + "\u{0631}.\u{064a}.", + "\u{0644}.\u{0633}.", + "\u{0644}.\u{0644}.", + "\u{0783}.", + 'B\/.', + 'Bs.', + 'Fr.', + 'kr.', + 'L.', + 'p.', + 'S\/.', + ]; public static function config(?array $config = null): ?array { @@ -221,12 +83,12 @@ public static function formatNumberRaw( string $decimalDelimiter = '.', int $minDigitsForThousandDelimiter = 0, array $standardPatternInfo = [ - 'primaryGroupSize' => DEFAULT_GROUPING_SIZE, - 'secondaryGroupSize' => DEFAULT_GROUPING_SIZE, + 'primaryGroupSize' => self::DEFAULT_GROUPING_SIZE, + 'secondaryGroupSize' => self::DEFAULT_GROUPING_SIZE, ], ?array $numberingSystemData = null ): string { - $primaryGroupingSize = $standardPatternInfo['primaryGroupSize'] ?? DEFAULT_GROUPING_SIZE; + $primaryGroupingSize = $standardPatternInfo['primaryGroupSize'] ?? self::DEFAULT_GROUPING_SIZE; $secondaryGroupingSize = $standardPatternInfo['secondaryGroupSize'] ?? $primaryGroupingSize; $digits = $numberingSystemData['digits'] ?? null; @@ -238,7 +100,7 @@ public static function formatNumberRaw( } elseif (is_string($value)) { $v = self::truncateLongNumber($value, $decimals); } else { - $v = _roundNumber($value, $decimals); + $v = self::_roundNumber($value, $decimals); } $valueParts = explode('.', $v); @@ -248,20 +110,20 @@ public static function formatNumberRaw( if (abs(mb_strlen(strval(intval($wholeNumber)))) >= $minDigitsForThousandDelimiter) { $replaceWith = '$1' . $thousandDelimiter . '$2$3'; $primaryPattern = '(\\d)(\\d{' . ($primaryGroupingSize - 0) . '})($|\\D)'; - $replaced = preg_replace(_buildRegex($primaryPattern), $replaceWith, $wholeNumber); + $replaced = preg_replace(self::_buildRegex($primaryPattern), $replaceWith, $wholeNumber); if ($replaced != $wholeNumber) { $wholeNumber = $replaced; - $secondaryPatternString = '(\\d)(\\d{' . ($secondaryGroupingSize - 0) . '})(' . escapeRegex($thousandDelimiter) . ')'; - $secondaryPattern = _buildRegex($secondaryPatternString); + $secondaryPatternString = '(\\d)(\\d{' . ($secondaryGroupingSize - 0) . '})(' . self::escapeRegex($thousandDelimiter) . ')'; + $secondaryPattern = self::_buildRegex($secondaryPatternString); while (($replaced = preg_replace($secondaryPattern, $replaceWith, $wholeNumber)) != $wholeNumber) { $wholeNumber = $replaced; } } } if ($digits !== null) { - $wholeNumber = _replaceWithNativeDigits($wholeNumber, $digits); + $wholeNumber = self::_replaceWithNativeDigits($wholeNumber, $digits); if ($decimal) { - $decimal = _replaceWithNativeDigits($decimal, $digits); + $decimal = self::_replaceWithNativeDigits($decimal, $digits); } } @@ -318,13 +180,13 @@ public static function formatNumberWithThousandDelimiters(float $value, ?int $de public static function formatNumberWithLimitedSigFig(float $value, ?int $decimals, int $numSigFigs): string { // First make the number sufficiently integer-like. - $power = _getNumberOfPowersOfTen($value); + $power = self::_getNumberOfPowersOfTen((int) $value); $inflatedValue = $value; if ($power < $numSigFigs) { $inflatedValue = $value * pow(10, -$power + $numSigFigs); } // Now that we have a large enough integer, round to cut off some digits. - $roundTo = pow(10, _getNumberOfPowersOfTen($inflatedValue) - $numSigFigs + 1); + $roundTo = pow(10, self::_getNumberOfPowersOfTen((int) $inflatedValue) - $numSigFigs + 1); $truncatedValue = round($inflatedValue / $roundTo) * $roundTo; // Bring it back to whatever the number's magnitude was before. if ($power < $numSigFigs) { @@ -339,7 +201,7 @@ public static function formatNumberWithLimitedSigFig(float $value, ?int $decimal return self::formatNumberWithThousandDelimiters($truncatedValue, $decimals); } - public static function parseNumber($text): ?float + public static function parseNumber(string $text): ?float { $numberFormatConfig = self::config(); @@ -360,7 +222,7 @@ public static function parseNumber($text): ?float public static function parseNumberRaw(string $text, string $decimalDelimiter, string $numberDelimiter = ''): ?float { // Replace numerals based on current locale data - $digitsMap = _getNativeDigitsMap(); + $digitsMap = self::_getNativeDigitsMap(); $_text = $text; if ($digitsMap) { $_text = trim(implode('', array_map(function ($character) use ($digitsMap) { @@ -369,39 +231,39 @@ public static function parseNumberRaw(string $text, string $decimalDelimiter, st } $_text = preg_replace("/^[^\d]*\-/", "\u{0002}", $_text); // preserve negative sign - $_text = preg_replace(matchCurrenciesWithDots(), '', $_text); // remove some currencies + $_text = preg_replace(self::matchCurrenciesWithDots(), '', $_text); // remove some currencies - $decimalExp = escapeRegex($decimalDelimiter); - $numberExp = escapeRegex($numberDelimiter); + $decimalExp = self::escapeRegex($decimalDelimiter); + $numberExp = self::escapeRegex($numberDelimiter); - $isThereADecimalSeparatorInBetween = _buildRegex('^[^\\d]*\\d.*' . $decimalExp . '.*\\d[^\\d]*$'); + $isThereADecimalSeparatorInBetween = self::_buildRegex('^[^\\d]*\\d.*' . $decimalExp . '.*\\d[^\\d]*$'); if (! preg_match($isThereADecimalSeparatorInBetween, $_text)) { - $isValidWithDecimalBeforeHand = _buildRegex('(^[^\\d]*)' . $decimalExp . '(\\d*[^\\d]*$)'); + $isValidWithDecimalBeforeHand = self::_buildRegex('(^[^\\d]*)' . $decimalExp . '(\\d*[^\\d]*$)'); if (preg_match($isValidWithDecimalBeforeHand, $_text)) { $_text = preg_replace($isValidWithDecimalBeforeHand, "$1\u{0001}$2", $_text); - return _parseCodifiedNumber($_text); + return self::_parseCodifiedNumber($_text); } - $isValidWithoutDecimal = _buildRegex('^[^\\d]*[\\d ' . escapeRegex($numberExp) . ']*[^\\d]*$'); + $isValidWithoutDecimal = self::_buildRegex('^[^\\d]*[\\d ' . self::escapeRegex($numberExp) . ']*[^\\d]*$'); if (! preg_match($isValidWithoutDecimal, $_text)) { $_text = ''; } - return _parseCodifiedNumber($_text); + return self::_parseCodifiedNumber($_text); } - $isValid = _buildRegex('(^[^\\d]*[\\d ' . $numberExp . ']*)' . $decimalExp . '(\\d*[^\\d]*$)'); + $isValid = self::_buildRegex('(^[^\\d]*[\\d ' . $numberExp . ']*)' . $decimalExp . '(\\d*[^\\d]*$)'); $_text = preg_match($isValid, $_text) ? preg_replace($isValid, "$1\u{0001}$2", $_text) : ''; - return _parseCodifiedNumber($_text); + return self::_parseCodifiedNumber($_text); } public static function truncateLongNumber(string $number, int $decimals = null): string { - $pos = mb_strpos($number, '.'); - $dividend = $pos === false ? $number : mb_substr($number, 0, $pos); - $remainder = $pos === false ? '' : mb_substr($number, $pos + 1); + $pos = strpos($number, '.'); + $dividend = $pos === false ? $number : substr($number, 0, $pos); + $remainder = $pos === false ? '' : substr($number, $pos + 1); - return $decimals !== null ? $dividend . '.' . addZeros(mb_substr($remainder, 0, $decimals), $decimals - mb_strlen($remainder)) : $dividend; + return $decimals !== null ? $dividend . '.' . self::addZeros(substr($remainder, 0, $decimals), $decimals - strlen($remainder)) : $dividend; } /** @@ -412,7 +274,7 @@ public static function truncateLongNumber(string $number, int $decimals = null): * gets edge cases for Norwegian and Spanish right. * */ - public static function getFloatString($num, $thousandDelimiter, $decimalDelimiter): string + public static function getFloatString(float $num, string $thousandDelimiter, string $decimalDelimiter): string { $str = (string)$num; $pieces = explode('.', $str); @@ -451,4 +313,139 @@ public static function getIntegerString(int $num, string $thousandDelimiter): st return $str; } + + public static function addZeros(string $x, int $count): string + { + $result = $x; + if ($count > 0) { + $result .= str_repeat('0', $count); + } + + return $result; + } + + public static function _roundNumber(string $valueParam, ?int $decimalsParam = null): string + { + $decimals = $decimalsParam ?? 0; + $pow = 10 ** $decimals; + $value = $valueParam; + $value = round($value * $pow) / $pow; + $value = (string)$value; + if (! $decimals) { + return $value; + } + + // if value is small and + // was converted to scientific notation, don't append anything + // as we are already done + if (strstr($value, 'E-')) { + return $value; + } + + $pos = strpos($value, '.'); + + if ($pos === false) { + $value .= '.'; + $zeros = $decimals; + } else { + $zeros = $decimals - (strlen($value) - $pos - 1); + } + for ($i = 0, $l = $zeros; $i < $l; $i++) { + $value .= '0'; + } + + return $value; + } + + /** + * A codified number has \u0001 in the place of a decimal separator and a + * \u0002 in the place of a negative sign. + */ + public static function _parseCodifiedNumber(string $text): ?float + { + $_text = preg_replace("/[^0-9\u{0001}\u{0002}]/", '', $text);// decimal separator and negative sign + $_text = preg_replace("/\u{0001}/", '.', $_text);// restore decimal separator + $_text = preg_replace("/\u{0002}/", '-', $_text);// restore negative sign + + $value = floatval($_text); + + return $_text === '' || is_nan($value) ? null : $value; + } + + public static function _getNativeDigitsMap(): ?array + { + $numberFormatConfig = intlNumUtils::config(); + $nativeDigitMap = []; + $digits = $numberFormatConfig['numberingSystemData']['digits'] ?? $numberFormatConfig['numberingSystemData']; + + if ($digits == null) { + return null; + } + + foreach (mb_str_split($digits) as $i => $char) { + $nativeDigitMap[$char] = (string)$i; + } + + return $nativeDigitMap; + } + + public static function matchCurrenciesWithDots() + { + return self::_buildRegex(array_reduce(intlNumUtils::CURRENCIES_WITH_DOTS, function ($regex, $representation) { + return $regex . ($regex ? '|' : '') . '(' . $representation . ')'; + }, '')); + } + + /** + * Escapes regex special characters from a string, so it can be + * used as a raw search term inside an actual regex. + */ + public static function escapeRegex(string $str): string + { + return preg_quote($str, '/'); + } + + protected static function _buildRegex(string $pattern) + { + static $_regexCache; + + if (! isset($_regexCache[$pattern])) { + $_regexCache[$pattern] = '/' . $pattern . '/iu'; + } + + return $_regexCache[$pattern]; + } + + protected static function _replaceWithNativeDigits(string $number, string $digits): string + { + $result = ''; + $digitsArray = mb_str_split($digits); + + for ($i = 0; $i < mb_strlen($number); $i++) { + $char = mb_substr($number, $i, 1); + $charCode = ord($char); + if ($charCode >= 48 && $charCode <= 57) { + $nativeDigit = $digitsArray[$charCode - 48] ?? null; + $result .= $nativeDigit !== null ? $nativeDigit : $char; + } else { + $result .= $char; + } + } + + return $result; + } + + /** + * Calculate how many powers of 10 there are in a given number + * I.e. 1.23 has 0, 100 and 999 have 2, and 1000 has 3. + * Used in the inflation and rounding calculations below. + */ + protected static function _getNumberOfPowersOfTen(int $value): float + { + if ($value === 0) { + return 0; + } + + return floor(log10(abs($value))); + } } diff --git a/tests/numbers/intlNumberAmericanTest.php b/tests/numbers/intlNumberAmericanTest.php index a02e5af..9786f75 100644 --- a/tests/numbers/intlNumberAmericanTest.php +++ b/tests/numbers/intlNumberAmericanTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace numbers; +namespace tests\numbers; use fbt\Runtime\Shared\intlNumUtils; diff --git a/tests/numbers/intlNumberArabicTest.php b/tests/numbers/intlNumberArabicTest.php index 78ccd0c..8361a9e 100644 --- a/tests/numbers/intlNumberArabicTest.php +++ b/tests/numbers/intlNumberArabicTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace numbers; +namespace tests\numbers; use fbt\Runtime\Shared\intlNumUtils; diff --git a/tests/numbers/intlNumberBrazilianTest.php b/tests/numbers/intlNumberBrazilianTest.php index 1c1860b..13f0aa4 100644 --- a/tests/numbers/intlNumberBrazilianTest.php +++ b/tests/numbers/intlNumberBrazilianTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace numbers; +namespace tests\numbers; use fbt\Runtime\Shared\intlNumUtils; diff --git a/tests/numbers/intlNumberHindiTest.php b/tests/numbers/intlNumberHindiTest.php index b8eac71..74d9185 100644 --- a/tests/numbers/intlNumberHindiTest.php +++ b/tests/numbers/intlNumberHindiTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace numbers; +namespace tests\numbers; use fbt\Runtime\Shared\intlNumUtils; diff --git a/tests/numbers/intlNumberPersianTest.php b/tests/numbers/intlNumberPersianTest.php index 08745c6..7d48506 100644 --- a/tests/numbers/intlNumberPersianTest.php +++ b/tests/numbers/intlNumberPersianTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace numbers; +namespace tests\numbers; use fbt\Runtime\Shared\intlNumUtils; diff --git a/tests/numbers/intlNumberTest.php b/tests/numbers/intlNumberTest.php index 2aa6fdf..e535de5 100644 --- a/tests/numbers/intlNumberTest.php +++ b/tests/numbers/intlNumberTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace numbers; +namespace tests\numbers; use fbt\Runtime\Shared\FbtHooks; use fbt\Runtime\Shared\intlNumUtils;