1<?php 2/** 3 * Copyright since 2007 PrestaShop SA and Contributors 4 * PrestaShop is an International Registered Trademark & Property of PrestaShop SA 5 * 6 * NOTICE OF LICENSE 7 * 8 * This source file is subject to the Open Software License (OSL 3.0) 9 * that is bundled with this package in the file LICENSE.md. 10 * It is also available through the world-wide-web at this URL: 11 * https://opensource.org/licenses/OSL-3.0 12 * If you did not receive a copy of the license and are unable to 13 * obtain it through the world-wide-web, please send an email 14 * to license@prestashop.com so we can send you a copy immediately. 15 * 16 * DISCLAIMER 17 * 18 * Do not edit or add to this file if you wish to upgrade PrestaShop to newer 19 * versions in the future. If you wish to customize PrestaShop for your 20 * needs please refer to https://devdocs.prestashop.com/ for more information. 21 * 22 * @author PrestaShop SA and Contributors <contact@prestashop.com> 23 * @copyright Since 2007 PrestaShop SA and Contributors 24 * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) 25 */ 26 27namespace PrestaShop\PrestaShop\Core\Localization\Number; 28 29use InvalidArgumentException as SPLInvalidArgumentException; 30use PrestaShop\Decimal\DecimalNumber; 31use PrestaShop\Decimal\Operation\Rounding; 32use PrestaShop\PrestaShop\Core\Localization\Exception\LocalizationException; 33use PrestaShop\PrestaShop\Core\Localization\Specification\NumberInterface as NumberSpecification; 34use PrestaShop\PrestaShop\Core\Localization\Specification\Price as PriceSpecification; 35 36/** 37 * Formats a number (raw, price, percentage) according to passed specifications. 38 */ 39class Formatter 40{ 41 /** 42 * These placeholders are used in CLDR number formatting templates. 43 * They are meant to be replaced by the correct localized symbols in the number formatting process. 44 */ 45 public const CURRENCY_SYMBOL_PLACEHOLDER = '¤'; 46 public const DECIMAL_SEPARATOR_PLACEHOLDER = '.'; 47 public const GROUP_SEPARATOR_PLACEHOLDER = ','; 48 public const MINUS_SIGN_PLACEHOLDER = '-'; 49 public const PERCENT_SYMBOL_PLACEHOLDER = '%'; 50 public const PLUS_SIGN_PLACEHOLDER = '+'; 51 52 /** 53 * @var string The wanted rounding mode when formatting numbers. 54 * Cf. PrestaShop\Decimal\Operation\Rounding::ROUND_* values 55 */ 56 protected $roundingMode; 57 58 /** 59 * @var string Numbering system to use when formatting numbers 60 * 61 * @see http://cldr.unicode.org/translation/numbering-systems 62 */ 63 protected $numberingSystem; 64 65 /** 66 * Number specification to be used when formatting a number. 67 * 68 * @var NumberSpecification 69 */ 70 protected $numberSpecification; 71 72 /** 73 * Create a number formatter instance. 74 * 75 * @param string $roundingMode The wanted rounding mode when formatting numbers 76 * Cf. PrestaShop\Decimal\Operation\Rounding::ROUND_* values 77 * @param string $numberingSystem Numbering system to use when formatting numbers 78 * 79 * @see http://cldr.unicode.org/translation/numbering-systems 80 */ 81 public function __construct($roundingMode, $numberingSystem) 82 { 83 $this->roundingMode = $roundingMode; 84 $this->numberingSystem = $numberingSystem; 85 } 86 87 /** 88 * Formats the passed number according to specifications. 89 * 90 * @param int|float|string $number 91 * The number to format 92 * @param NumberSpecification $specification 93 * Number specification to be used (can be a number spec, a price spec, a percentage spec) 94 * 95 * @return string 96 * The formatted number 97 * You should use this this value for display, without modifying it 98 * 99 * @throws LocalizationException 100 */ 101 public function format($number, NumberSpecification $specification) 102 { 103 $this->numberSpecification = $specification; 104 105 try { 106 $decimalNumber = $this->prepareNumber($number); 107 } catch (SPLInvalidArgumentException $e) { 108 throw new LocalizationException('Invalid $number parameter: ' . $e->getMessage(), 0, $e); 109 } 110 111 /* 112 * We need to work on the absolute value first. 113 * Then the CLDR pattern will add the sign if relevant (at the end). 114 */ 115 $isNegative = $decimalNumber->isNegative(); 116 $decimalNumber = $decimalNumber->toPositive(); 117 118 list($majorDigits, $minorDigits) = $this->extractMajorMinorDigits($decimalNumber); 119 $majorDigits = $this->splitMajorGroups($majorDigits); 120 $minorDigits = $this->adjustMinorDigitsZeroes($minorDigits); 121 122 // Assemble the final number 123 $formattedNumber = $majorDigits; 124 if (strlen($minorDigits)) { 125 $formattedNumber .= self::DECIMAL_SEPARATOR_PLACEHOLDER . $minorDigits; 126 } 127 128 // Get the good CLDR formatting pattern. Sign is important here ! 129 $pattern = $this->getCldrPattern($isNegative); 130 $formattedNumber = $this->addPlaceholders($formattedNumber, $pattern); 131 $formattedNumber = $this->localizeNumber($formattedNumber); 132 133 $formattedNumber = $this->performSpecificReplacements($formattedNumber); 134 135 return $formattedNumber; 136 } 137 138 /** 139 * Prepares a basic number (either a string, an integer or a float) to be formatted. 140 * 141 * @param string|float|int $number The number to be prepared 142 * 143 * @return DecimalNumber The prepared number 144 */ 145 protected function prepareNumber($number) 146 { 147 $decimalNumber = new DecimalNumber((string) $number); 148 $precision = $this->numberSpecification->getMaxFractionDigits(); 149 $roundedNumber = (new Rounding())->compute( 150 $decimalNumber, 151 $precision, 152 $this->roundingMode 153 ); 154 155 return $roundedNumber; 156 } 157 158 /** 159 * Get $number's major and minor digits. 160 * 161 * Major digits are the "integer" part (before decimal separator), minor digits are the fractional part 162 * Result will be an array of exactly 2 items: [$majorDigits, $minorDigits] 163 * 164 * Usage example: 165 * list($majorDigits, $minorDigits) = $this->getMajorMinorDigits($decimalNumber); 166 * 167 * @param DecimalNumber $number 168 * 169 * @return string[] 170 */ 171 protected function extractMajorMinorDigits(DecimalNumber $number) 172 { 173 // Get the number's major and minor digits. 174 $majorDigits = $number->getIntegerPart(); 175 $minorDigits = $number->getFractionalPart(); 176 $minorDigits = ('0' === $minorDigits) ? '' : $minorDigits; 177 178 return [$majorDigits, $minorDigits]; 179 } 180 181 /** 182 * Splits major digits into groups. 183 * 184 * e.g.: Given the major digits "1234567", and major group size 185 * configured to 3 digits, the result would be "1 234 567" 186 * 187 * @param string $majorDigits The major digits to be grouped 188 * 189 * @return string The grouped major digits 190 */ 191 protected function splitMajorGroups($majorDigits) 192 { 193 if ($this->numberSpecification->isGroupingUsed()) { 194 // Reverse the major digits, since they are grouped from the right. 195 $majorDigits = array_reverse(str_split($majorDigits)); 196 // Group the major digits. 197 $groups = []; 198 $groups[] = array_splice($majorDigits, 0, $this->numberSpecification->getPrimaryGroupSize()); 199 while (!empty($majorDigits)) { 200 $groups[] = array_splice($majorDigits, 0, $this->numberSpecification->getSecondaryGroupSize()); 201 } 202 // Reverse back the digits and the groups 203 $groups = array_reverse($groups); 204 foreach ($groups as &$group) { 205 $group = implode('', array_reverse($group)); 206 } 207 // Reconstruct the major digits. 208 $majorDigits = implode(self::GROUP_SEPARATOR_PLACEHOLDER, $groups); 209 } 210 211 return $majorDigits; 212 } 213 214 /** 215 * Adds or remove trailing zeroes, depending on specified min and max fraction digits numbers. 216 * 217 * @param string $minorDigits Digits to be adjusted with (trimmed or padded) zeroes 218 * 219 * @return string The adjusted minor digits 220 */ 221 protected function adjustMinorDigitsZeroes($minorDigits) 222 { 223 if (strlen($minorDigits) < $this->numberSpecification->getMinFractionDigits()) { 224 // Re-add needed zeroes 225 $minorDigits = str_pad( 226 $minorDigits, 227 $this->numberSpecification->getMinFractionDigits(), 228 '0' 229 ); 230 } 231 232 if (strlen($minorDigits) > $this->numberSpecification->getMaxFractionDigits()) { 233 // Strip any trailing zeroes. 234 $minorDigits = rtrim($minorDigits, '0'); 235 } 236 237 return $minorDigits; 238 } 239 240 /** 241 * Get the CLDR formatting pattern. 242 * 243 * @see http://cldr.unicode.org/translation/number-patterns 244 * 245 * @param bool $isNegative 246 * If true, the negative pattern will be returned instead of the positive one 247 * 248 * @return string 249 * The CLDR formatting pattern 250 */ 251 protected function getCldrPattern($isNegative) 252 { 253 if ((bool) $isNegative) { 254 return $this->numberSpecification->getNegativePattern(); 255 } 256 257 return $this->numberSpecification->getPositivePattern(); 258 } 259 260 /** 261 * Localize the passed number. 262 * 263 * If needed, occidental ("latn") digits are replaced with the relevant 264 * ones (for instance with arab digits). 265 * Symbol placeholders will also be replaced by the real symbols (configured 266 * in number specification) 267 * 268 * @param string $number 269 * The number to be processed 270 * 271 * @return string 272 * The number after digits and symbols replacement 273 */ 274 protected function localizeNumber($number) 275 { 276 // If locale uses non-latin digits 277 $number = $this->replaceDigits($number); 278 279 // Placeholders become real localized symbols 280 $number = $this->replaceSymbols($number); 281 282 return $number; 283 } 284 285 /** 286 * Replace latin digits with relevant numbering system's digits. 287 * 288 * @param string $number 289 * The number to process 290 * 291 * @return string 292 * The number with replaced digits 293 */ 294 protected function replaceDigits($number) 295 { 296 // TODO use digits set from the locale (cf. /localization/CLDR/core/common/supplemental/numberingSystems.xml) 297 return $number; 298 } 299 300 /** 301 * Replace placeholder number symbols with relevant numbering system's symbols. 302 * 303 * @param string $number 304 * The number to process 305 * 306 * @return string 307 * The number with replaced symbols 308 */ 309 protected function replaceSymbols($number) 310 { 311 $symbols = $this->numberSpecification->getSymbolsByNumberingSystem($this->numberingSystem); 312 $replacements = [ 313 self::DECIMAL_SEPARATOR_PLACEHOLDER => $symbols->getDecimal(), 314 self::GROUP_SEPARATOR_PLACEHOLDER => $symbols->getGroup(), 315 self::MINUS_SIGN_PLACEHOLDER => $symbols->getMinusSign(), 316 self::PERCENT_SYMBOL_PLACEHOLDER => $symbols->getPercentSign(), 317 self::PLUS_SIGN_PLACEHOLDER => $symbols->getPlusSign(), 318 ]; 319 320 return strtr($number, $replacements); 321 } 322 323 /** 324 * Add missing placeholders to the number using the passed CLDR pattern. 325 * 326 * Missing placeholders can be the percent sign, currency symbol, etc. 327 * 328 * e.g. with a currency CLDR pattern: 329 * - Passed number (partially formatted): 1,234.567 330 * - Returned number: 1,234.567 ¤ 331 * ("¤" symbol is the currency symbol placeholder) 332 * 333 * @see http://cldr.unicode.org/translation/number-patterns 334 * 335 * @param string $formattedNumber Number to process 336 * @param string $pattern CLDR formatting pattern to use 337 * 338 * @return string 339 */ 340 protected function addPlaceholders($formattedNumber, $pattern) 341 { 342 /* 343 * Regex groups explanation: 344 * # : literal "#" character. Once. 345 * (,#+)* : any other "#" characters group, separated by ",". Zero to infinity times. 346 * 0 : literal "0" character. Once. 347 * (\.[0#]+)* : any combination of "0" and "#" characters groups, separated by '.'. Zero to infinity times. 348 */ 349 $formattedNumber = preg_replace('/#?(,#+)*0(\.[0#]+)*/', $formattedNumber, $pattern); 350 351 return $formattedNumber; 352 } 353 354 /** 355 * Perform some more specific replacements. 356 * 357 * Specific replacements are needed when number specification is extended. 358 * For instance, prices have an extended number specification in order to 359 * add currency symbol to the formatted number. 360 * 361 * @param string $formattedNumber 362 * 363 * @return mixed 364 */ 365 public function performSpecificReplacements($formattedNumber) 366 { 367 $formattedNumber = $this->tryCurrencyReplacement($formattedNumber); 368 369 return $formattedNumber; 370 } 371 372 /** 373 * Try to replace currency placeholder by actual currency. 374 * 375 * Placeholder will be replaced either by the symbol or the ISO code, depending on price specification 376 * 377 * @param string $formattedNumber The number to format 378 * 379 * @return string The number after currency replacement 380 */ 381 protected function tryCurrencyReplacement($formattedNumber) 382 { 383 if ($this->numberSpecification instanceof PriceSpecification) { 384 $currency = PriceSpecification::CURRENCY_DISPLAY_CODE == $this->numberSpecification->getCurrencyDisplay() 385 ? $this->numberSpecification->getCurrencyCode() 386 : $this->numberSpecification->getCurrencySymbol(); 387 388 $formattedNumber = str_replace(self::CURRENCY_SYMBOL_PLACEHOLDER, $currency, $formattedNumber); 389 } 390 391 return $formattedNumber; 392 } 393} 394