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