Skip to content

Commit 014a120

Browse files
authored
Merge pull request #3394 from PHPOffice/NumberFormat_decimal-placing-with-question-mark
Improved handling for ? placeholder in Number Format Masks
2 parents 4cefd7a + acdcb0b commit 014a120

File tree

3 files changed

+146
-34
lines changed

3 files changed

+146
-34
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org).
1717

1818
### Changed
1919

20-
- Improved handling for @ in Number Format Masks [PR #3344](https://github.com/PHPOffice/PhpSpreadsheet/pull/3344)
20+
- Improved handling for @ placeholder in Number Format Masks [PR #3344](https://github.com/PHPOffice/PhpSpreadsheet/pull/3344)
21+
- Improved handling for ? placeholder in Number Format Masks [PR #3394](https://github.com/PHPOffice/PhpSpreadsheet/pull/3394)
2122
- Improved support for locale settings and currency codes when matching formatted strings to numerics in the Calculation Engine [PR #3373](https://github.com/PHPOffice/PhpSpreadsheet/pull/3373) and [PR #3374](https://github.com/PHPOffice/PhpSpreadsheet/pull/3374)
2223
- Improved support for locale settings and matching in the Advanced Value Binder [PR #3376](https://github.com/PHPOffice/PhpSpreadsheet/pull/3376)
2324

src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ private static function formatStraightNumericValue($value, string $format, array
186186
}
187187

188188
$sprintf_pattern = "%0$minWidth." . strlen($right) . 'f';
189+
189190
/** @var float */
190191
$valueFloat = $value;
191192
$value = sprintf($sprintf_pattern, round($valueFloat, strlen($right)));
@@ -201,54 +202,39 @@ public static function format($value, string $format): string
201202
// The "_" in this string has already been stripped out,
202203
// so this test is never true. Furthermore, testing
203204
// on Excel shows this format uses Euro symbol, not "EUR".
204-
//if ($format === NumberFormat::FORMAT_CURRENCY_EUR_SIMPLE) {
205-
// return 'EUR ' . sprintf('%1.2f', $value);
206-
//}
207-
208-
// Find out if we need thousands separator
209-
// This is indicated by a comma enclosed by a digit placeholder:
210-
// #,# or 0,0
211-
$useThousands = (bool) preg_match('/(#,#|0,0)/', $format);
212-
if ($useThousands) {
213-
$format = self::pregReplace('/0,0/', '00', $format);
214-
$format = self::pregReplace('/#,#/', '##', $format);
215-
}
205+
// if ($format === NumberFormat::FORMAT_CURRENCY_EUR_SIMPLE) {
206+
// return 'EUR ' . sprintf('%1.2f', $value);
207+
// }
216208

217-
// Scale thousands, millions,...
218-
// This is indicated by a number of commas after a digit placeholder:
219-
// #, or 0.0,,
220-
$scale = 1; // same as no scale
221-
$matches = [];
222-
if (preg_match('/(#|0)(,+)/', $format, $matches)) {
223-
$scale = 1000 ** strlen($matches[2]);
209+
$baseFormat = $format;
224210

225-
// strip the commas
226-
$format = self::pregReplace('/0,+/', '0', $format);
227-
$format = self::pregReplace('/#,+/', '#', $format);
228-
}
211+
$useThousands = self::areThousandsRequired($format);
212+
$scale = self::scaleThousandsMillions($format);
229213

230214
if (preg_match('/#?.*\?\/(\?+|\d+)/', $format)) {
231215
$value = FractionFormatter::format($value, $format);
232216
} else {
233217
// Handle the number itself
234-
235218
// scale number
236219
$value = $value / $scale;
220+
$paddingPlaceholder = (strpos($format, '?') !== false);
237221

238-
// Strip #
239-
$format = self::pregReplace('/\\#(?=(?:[^"]*"[^"]*")*[^"]*\Z)/', '0', $format);
240-
// Remove locale code [$-###]
222+
// Replace # or ? with 0
223+
$format = self::pregReplace('/[\\#\?](?=(?:[^"]*"[^"]*")*[^"]*\Z)/', '0', $format);
224+
// Remove locale code [$-###] for an LCID
241225
$format = self::pregReplace('/\[\$\-.*\]/', '', $format);
242226

243227
$n = '/\\[[^\\]]+\\]/';
244228
$m = self::pregReplace($n, '', $format);
245229

246230
// Some non-number strings are quoted, so we'll get rid of the quotes, likewise any positional * symbols
247231
$format = self::makeString(str_replace(['"', '*'], '', $format));
248-
249232
if (preg_match(self::NUMBER_REGEX, $m, $matches)) {
250233
// There are placeholders for digits, so inject digits from the value into the mask
251234
$value = self::formatStraightNumericValue($value, $format, $matches, $useThousands);
235+
if ($paddingPlaceholder === true) {
236+
$value = self::padValue($value, $baseFormat);
237+
}
252238
} elseif ($format !== NumberFormat::FORMAT_GENERAL) {
253239
// Yes, I know that this is basically just a hack;
254240
// if there's no placeholders for digits, just return the format mask "as is"
@@ -266,6 +252,13 @@ public static function format($value, string $format): string
266252
$value = self::pregReplace('/\[\$([^\]]*)\]/u', $currencyCode, (string) $value);
267253
}
268254

255+
if (
256+
(strpos((string) $value, '0.') !== false) &&
257+
((strpos($baseFormat, '#.') !== false) || (strpos($baseFormat, '?.') !== false))
258+
) {
259+
$value = preg_replace('/(\b)0\.|([^\d])0\./', '${2}.', (string) $value);
260+
}
261+
269262
return (string) $value;
270263
}
271264

@@ -281,4 +274,50 @@ private static function pregReplace(string $pattern, string $replacement, string
281274
{
282275
return self::makeString(preg_replace($pattern, $replacement, $subject) ?? '');
283276
}
277+
278+
public static function padValue(string $value, string $baseFormat): string
279+
{
280+
/** @phpstan-ignore-next-line */
281+
[$preDecimal, $postDecimal] = preg_split('/\.(?=(?:[^"]*"[^"]*")*[^"]*\Z)/miu', $baseFormat . '.?');
282+
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+
}
284323
}

tests/data/Style/NumberFormat.php

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@
364364
'_-€* #,##0.00_-;"-€"* #,##0.00_-;_-€* -??_-;_-@_-',
365365
],
366366
[
367-
' € - ',
367+
' € - ',
368368
0,
369369
'_-€* #,##0.00_-;"-€"* #,##0.00_-;_-€* -??_-;_-@_-',
370370
],
@@ -1326,7 +1326,7 @@
13261326
NumberFormat::FORMAT_CURRENCY_EUR_INTEGER,
13271327
],
13281328
[
1329-
' $ - ',
1329+
' $ - ',
13301330
'0',
13311331
NumberFormat::FORMAT_ACCOUNTING_USD,
13321332
],
@@ -1356,7 +1356,7 @@
13561356
NumberFormat::FORMAT_ACCOUNTING_USD,
13571357
],
13581358
[
1359-
' $ - ',
1359+
' $ - ',
13601360
'-0',
13611361
NumberFormat::FORMAT_ACCOUNTING_USD,
13621362
],
@@ -1386,7 +1386,7 @@
13861386
NumberFormat::FORMAT_ACCOUNTING_USD,
13871387
],
13881388
[
1389-
' € - ',
1389+
' € - ',
13901390
'0',
13911391
NumberFormat::FORMAT_ACCOUNTING_EUR,
13921392
],
@@ -1416,7 +1416,7 @@
14161416
NumberFormat::FORMAT_ACCOUNTING_EUR,
14171417
],
14181418
[
1419-
' € - ',
1419+
' € - ',
14201420
'-0',
14211421
NumberFormat::FORMAT_ACCOUNTING_EUR,
14221422
],
@@ -1550,4 +1550,76 @@
15501550
1025132.36,
15511551
'#,###,.##',
15521552
],
1553+
[
1554+
'.05',
1555+
50,
1556+
'#.00,',
1557+
],
1558+
[
1559+
'50.05',
1560+
50050,
1561+
'#.00,',
1562+
],
1563+
[
1564+
'555.50',
1565+
555500,
1566+
'#.00,',
1567+
],
1568+
[
1569+
'.56',
1570+
555500,
1571+
'#.00,,',
1572+
],
1573+
// decimal placement
1574+
[
1575+
' 44.398',
1576+
44.398,
1577+
'???.???',
1578+
],
1579+
[
1580+
'102.65 ',
1581+
102.65,
1582+
'???.???',
1583+
],
1584+
[
1585+
' 2.8 ',
1586+
2.8,
1587+
'???.???',
1588+
],
1589+
[
1590+
' 3',
1591+
2.8,
1592+
'???',
1593+
],
1594+
[
1595+
'12,345',
1596+
12345,
1597+
'?,???',
1598+
],
1599+
[
1600+
'123',
1601+
123,
1602+
'?,???',
1603+
],
1604+
[
1605+
'$.50',
1606+
0.5,
1607+
'$?.00',
1608+
],
1609+
[
1610+
'Part Cost $.50',
1611+
0.5,
1612+
'Part Cost $?.00',
1613+
],
1614+
// Empty Section
1615+
[
1616+
'',
1617+
-12345.6789,
1618+
'#,##0.00;',
1619+
],
1620+
[
1621+
'',
1622+
-12345.6789,
1623+
'#,##0.00;;"---"',
1624+
],
15531625
];

0 commit comments

Comments
 (0)