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
|
|
|
|