Failed Conditions
Push — master ( 4cefd7...014a12 )
by Mark
36s queued 28s
created

NumberFormatter::f2s()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
crap 1
1
<?php
2
3
namespace PhpOffice\PhpSpreadsheet\Style\NumberFormat;
4
5
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
6
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
7
8
class NumberFormatter
9
{
10
    private const NUMBER_REGEX = '/(0+)(\\.?)(0*)/';
11
12 4
    private static function mergeComplexNumberFormatMasks(array $numbers, array $masks): array
13
    {
14 4
        $decimalCount = strlen($numbers[1]);
15 4
        $postDecimalMasks = [];
16
17
        do {
18 4
            $tempMask = array_pop($masks);
19 4
            if ($tempMask !== null) {
20 4
                $postDecimalMasks[] = $tempMask;
21 4
                $decimalCount -= strlen($tempMask);
22
            }
23 4
        } while ($tempMask !== null && $decimalCount > 0);
24
25 4
        return [
26 4
            implode('.', $masks),
27 4
            implode('.', array_reverse($postDecimalMasks)),
28 4
        ];
29
    }
30
31
    /**
32
     * @param mixed $number
33
     */
34 56
    private static function processComplexNumberFormatMask($number, string $mask): string
35
    {
36
        /** @var string */
37 56
        $result = $number;
38 56
        $maskingBlockCount = preg_match_all('/0+/', $mask, $maskingBlocks, PREG_OFFSET_CAPTURE);
39
40 56
        if ($maskingBlockCount > 1) {
41 56
            $maskingBlocks = array_reverse($maskingBlocks[0]);
42
43 56
            $offset = 0;
44 56
            foreach ($maskingBlocks as $block) {
45 56
                $size = strlen($block[0]);
46 56
                $divisor = 10 ** $size;
47 56
                $offset = $block[1];
48
49
                /** @var float */
50 56
                $numberFloat = $number;
51 56
                $blockValue = sprintf("%0{$size}d", fmod($numberFloat, $divisor));
52 56
                $number = floor($numberFloat / $divisor);
53 56
                $mask = substr_replace($mask, $blockValue, $offset, $size);
54
            }
55
            /** @var string */
56 56
            $numberString = $number;
57 56
            if ($number > 0) {
58 18
                $mask = substr_replace($mask, $numberString, $offset, 0);
59
            }
60 56
            $result = $mask;
61
        }
62
63 56
        return self::makeString($result);
64
    }
65
66
    /**
67
     * @param mixed $number
68
     */
69 56
    private static function complexNumberFormatMask($number, string $mask, bool $splitOnPoint = true): string
70
    {
71
        /** @var float */
72 56
        $numberFloat = $number;
73 56
        if ($splitOnPoint) {
74 56
            $masks = explode('.', $mask);
75 56
            if (count($masks) <= 2) {
76 50
                $decmask = $masks[1] ?? '';
77 50
                $decpos = substr_count($decmask, '0');
78 50
                $numberFloat = round($numberFloat, $decpos);
79
            }
80
        }
81 56
        $sign = ($numberFloat < 0.0) ? '-' : '';
82 56
        $number = self::f2s(abs($numberFloat));
83
84 56
        if ($splitOnPoint && strpos($mask, '.') !== false && strpos($number, '.') !== false) {
85 24
            $numbers = explode('.', $number);
86 24
            $masks = explode('.', $mask);
87 24
            if (count($masks) > 2) {
88 4
                $masks = self::mergeComplexNumberFormatMasks($numbers, $masks);
89
            }
90 24
            $integerPart = self::complexNumberFormatMask($numbers[0], $masks[0], false);
91 24
            $numlen = strlen($numbers[1]);
92 24
            $msklen = strlen($masks[1]);
93 24
            if ($numlen < $msklen) {
94 6
                $numbers[1] .= str_repeat('0', $msklen - $numlen);
95
            }
96 24
            $decimalPart = strrev(self::complexNumberFormatMask(strrev($numbers[1]), strrev($masks[1]), false));
97 24
            $decimalPart = substr($decimalPart, 0, $msklen);
98
99 24
            return "{$sign}{$integerPart}.{$decimalPart}";
100
        }
101
102 56
        if (strlen($number) < strlen($mask)) {
103 42
            $number = str_repeat('0', strlen($mask) - strlen($number)) . $number;
104
        }
105 56
        $result = self::processComplexNumberFormatMask($number, $mask);
106
107 56
        return "{$sign}{$result}";
108
    }
109
110 56
    public static function f2s(float $f): string
111
    {
112 56
        return self::floatStringConvertScientific((string) $f);
113
    }
114
115 71
    public static function floatStringConvertScientific(string $s): string
116
    {
117
        // convert only normalized form of scientific notation:
118
        //  optional sign, single digit 1-9,
119
        //    decimal point and digits (allowed to be omitted),
120
        //    E (e permitted), optional sign, one or more digits
121 71
        if (preg_match('/^([+-])?([1-9])([.]([0-9]+))?[eE]([+-]?[0-9]+)$/', $s, $matches) === 1) {
122 28
            $exponent = (int) $matches[5];
123 28
            $sign = ($matches[1] === '-') ? '-' : '';
124 28
            if ($exponent >= 0) {
125 15
                $exponentPlus1 = $exponent + 1;
126 15
                $out = $matches[2] . $matches[4];
127 15
                $len = strlen($out);
128 15
                if ($len < $exponentPlus1) {
129 9
                    $out .= str_repeat('0', $exponentPlus1 - $len);
130
                }
131 15
                $out = substr($out, 0, $exponentPlus1) . ((strlen($out) === $exponentPlus1) ? '' : ('.' . substr($out, $exponentPlus1)));
132 15
                $s = "$sign$out";
133
            } else {
134 13
                $s = $sign . '0.' . str_repeat('0', -$exponent - 1) . $matches[2] . $matches[4];
135
            }
136
        }
137
138 71
        return $s;
139
    }
140
141
    /**
142
     * @param mixed $value
143
     */
144 541
    private static function formatStraightNumericValue($value, string $format, array $matches, bool $useThousands): string
145
    {
146
        /** @var float */
147 541
        $valueFloat = $value;
148 541
        $left = $matches[1];
149 541
        $dec = $matches[2];
150 541
        $right = $matches[3];
151
152
        // minimun width of formatted number (including dot)
153 541
        $minWidth = strlen($left) + strlen($dec) + strlen($right);
154 541
        if ($useThousands) {
155 341
            $value = number_format(
156 341
                $valueFloat,
157 341
                strlen($right),
158 341
                StringHelper::getDecimalSeparator(),
159 341
                StringHelper::getThousandsSeparator()
160 341
            );
161
162 341
            return self::pregReplace(self::NUMBER_REGEX, $value, $format);
163
        }
164
165 207
        if (preg_match('/[0#]E[+-]0/i', $format)) {
166
            //    Scientific format
167 13
            $decimals = strlen($right);
168 13
            $size = $decimals + 3;
169
170 13
            return sprintf("%{$size}.{$decimals}E", $valueFloat);
171 198
        } elseif (preg_match('/0([^\d\.]+)0/', $format) || substr_count($format, '.') > 1) {
172 56
            if ($valueFloat == floor($valueFloat) && substr_count($format, '.') === 1) {
173 8
                $value *= 10 ** strlen(explode('.', $format)[1]);
174
            }
175
176 56
            $result = self::complexNumberFormatMask($value, $format);
177 56
            if (strpos($result, 'E') !== false) {
178
                // This is a hack and doesn't match Excel.
179
                // It will, at least, be an accurate representation,
180
                //  even if formatted incorrectly.
181
                // This is needed for absolute values >=1E18.
182 4
                $result = self::f2s($valueFloat);
183
            }
184
185 56
            return $result;
186
        }
187
188 142
        $sprintf_pattern = "%0$minWidth." . strlen($right) . 'f';
189
190 142
        /** @var float */
191 142
        $valueFloat = $value;
192
        $value = sprintf($sprintf_pattern, round($valueFloat, strlen($right)));
193 142
194
        return self::pregReplace(self::NUMBER_REGEX, $value, $format);
195
    }
196
197
    /**
198
     * @param mixed $value
199 587
     */
200
    public static function format($value, string $format): string
201
    {
202
        // The "_" in this string has already been stripped out,
203
        // so this test is never true. Furthermore, testing
204
        // on Excel shows this format uses Euro symbol, not "EUR".
205
        // if ($format === NumberFormat::FORMAT_CURRENCY_EUR_SIMPLE) {
206
        //     return 'EUR ' . sprintf('%1.2f', $value);
207
        // }
208
209
        $baseFormat = $format;
210
211 587
        $useThousands = self::areThousandsRequired($format);
212 587
        $scale = self::scaleThousandsMillions($format);
213 341
214 341
        if (preg_match('/#?.*\?\/(\?+|\d+)/', $format)) {
215
            $value = FractionFormatter::format($value, $format);
216
        } else {
217
            // Handle the number itself
218
            // scale number
219
            $value = $value / $scale;
220 587
            $paddingPlaceholder = (strpos($format, '?') !== false);
221 587
222 587
            // Replace # or ? with 0
223 4
            $format = self::pregReplace('/[\\#\?](?=(?:[^"]*"[^"]*")*[^"]*\Z)/', '0', $format);
224
            // Remove locale code [$-###] for an LCID
225
            $format = self::pregReplace('/\[\$\-.*\]/', '', $format);
226 4
227 4
            $n = '/\\[[^\\]]+\\]/';
228
            $m = self::pregReplace($n, '', $format);
229
230 587
            // Some non-number strings are quoted, so we'll get rid of the quotes, likewise any positional * symbols
231 28
            $format = self::makeString(str_replace(['"', '*'], '', $format));
232
            if (preg_match(self::NUMBER_REGEX, $m, $matches)) {
233
                // There are placeholders for digits, so inject digits from the value into the mask
234
                $value = self::formatStraightNumericValue($value, $format, $matches, $useThousands);
235
                if ($paddingPlaceholder === true) {
236 559
                    $value = self::padValue($value, $baseFormat);
237
                }
238
            } elseif ($format !== NumberFormat::FORMAT_GENERAL) {
239 559
                // Yes, I know that this is basically just a hack;
240
                //      if there's no placeholders for digits, just return the format mask "as is"
241 559
                $value = self::makeString(str_replace('?', '', $format));
242
            }
243 559
        }
244 559
245
        if (preg_match('/\[\$(.*)\]/u', $format, $m)) {
246
            //  Currency or Accounting
247 559
            $currencyCode = $m[1];
248
            [$currencyCode] = explode('-', $currencyCode);
249 559
            if ($currencyCode == '') {
250
                $currencyCode = StringHelper::getCurrencyCode();
251 541
            }
252 19
            $value = self::pregReplace('/\[\$([^\]]*)\]/u', $currencyCode, (string) $value);
253
        }
254
255 11
        if (
256
            (strpos((string) $value, '0.') !== false) &&
257
            ((strpos($baseFormat, '#.') !== false) || (strpos($baseFormat, '?.') !== false))
258
        ) {
259 587
            $value = preg_replace('/(\b)0\.|([^\d])0\./', '${2}.', (string) $value);
260
        }
261 71
262 71
        return (string) $value;
263 71
    }
264 3
265
    /**
266 71
     * @param array|string $value
267
     */
268
    private static function makeString($value): string
269 587
    {
270
        return is_array($value) ? '' : "$value";
271
    }
272
273
    private static function pregReplace(string $pattern, string $replacement, string $subject): string
274
    {
275 559
        return self::makeString(preg_replace($pattern, $replacement, $subject) ?? '');
276
    }
277 559
278
    public static function padValue(string $value, string $baseFormat): string
279
    {
280 559
        /** @phpstan-ignore-next-line */
281
        [$preDecimal, $postDecimal] = preg_split('/\.(?=(?:[^"]*"[^"]*")*[^"]*\Z)/miu', $baseFormat . '.?');
282 559
283
        $length = strlen($value);
284
        if (strpos($postDecimal, '?') !== false) {
285
            $value = str_pad(rtrim($value, '0. '), $length, ' ', STR_PAD_RIGHT);
286
        }
287
        if (strpos($preDecimal, '?') !== false) {
288
            $value = str_pad(ltrim($value, '0, '), $length, ' ', STR_PAD_LEFT);
289
        }
290
291
        return $value;
292
    }
293
294
    /**
295
     * Find out if we need thousands separator
296
     * This is indicated by a comma enclosed by a digit placeholders: #, 0 or ?
297
     */
298
    public static function areThousandsRequired(string &$format): bool
299
    {
300
        $useThousands = (bool) preg_match('/([#\?0]),([#\?0])/', $format);
301
        if ($useThousands) {
302
            $format = self::pregReplace('/([#\?0]),([#\?0])/', '${1}${2}', $format);
303
        }
304
305
        return $useThousands;
306
    }
307
308
    /**
309
     * Scale thousands, millions,...
310
     * This is indicated by a number of commas after a digit placeholder: #, or 0.0,, or ?,.
311
     */
312
    public static function scaleThousandsMillions(string &$format): int
313
    {
314
        $scale = 1; // same as no scale
315
        if (preg_match('/(#|0|\?)(,+)/', $format, $matches)) {
316
            $scale = 1000 ** strlen($matches[2]);
317
            // strip the commas
318
            $format = self::pregReplace('/([#\?0]),+/', '${1}', $format);
319
        }
320
321
        return $scale;
322
    }
323
}
324