Skip to content

Commit ebdbdd4

Browse files
committed
Option To Display Numbers With Less Precision
Fix #4626. Previous changes had increased the precision of floating point numbers when cast to string, making for greater accuracy after save and load operations, without affecting the values displayed by Excel. Although the results of the cast are now more accurate computationally, they can appear unexpected to humans. A new boolean parameter `lessFloatPrecision` (defaulting to false) is added to `StringHelper::convertToString`, to `NumberFormat::toFormattedString` and `NumberFormat\Formatter::toFormattedString`, and to the entire `Worksheet::toArray` family of functions. When the new parameter is set to true, the result can be less surprising to humans. It should not, however, be used in subsequent computations. In the case of the NumberFormat functions, the new parameter will be considered only when the NumberFormat for the cell in question is `General` or equivalent. Setting an actual numeric format for the cell is probably a better solution than using the new parameter.
1 parent 889d26a commit ebdbdd4

File tree

7 files changed

+105
-25
lines changed

7 files changed

+105
-25
lines changed

.php-cs-fixer.dist.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@
166166
'php_unit_test_class_requires_covers' => false, // We don't care as much as we should about coverage
167167
'phpdoc_add_missing_param_annotation' => false, // Don't add things that bring no value
168168
'phpdoc_align' => false, // Waste of time
169-
'phpdoc_annotation_without_dot' => true,
169+
'phpdoc_annotation_without_dot' => false,
170170
'phpdoc_indent' => true,
171171
'phpdoc_line_span' => false, // Unfortunately our old comments turn even uglier with this
172172
'phpdoc_no_access' => true,

docs/topics/Looping the Loop.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ It can return the raw cell value (which isn't particularly useful if the cell co
5757
| $calculateFormulas | boolean | false | Flag to indicate if formula values should be calculated before returning. |
5858
| $formatData | boolean | false | Flag to request that values should be formatting before returning. |
5959
| $returnCellRef | boolean | false | False - Return a simple enumerated array of rows and columns (indexed by number counting from zero)<br />True - Return rows and columns indexed by their actual row and column IDs. |
60+
| $ignoreHidden | boolean | false | True - Ignore hidden rows and columns. |
61+
| $reduceArrays | boolean | false | True - If calculated value is an array, reduce it to top leftmost value. |
62+
| $lessFloatPrecision | boolean | false | True - PhpSpreadsheet 5.2+ - Floats, if formatted, will display as a more human-friendly but possibly less accurate value. |
6063

6164
### Dealing with empty rows
6265

src/PhpSpreadsheet/Shared/StringHelper.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -669,13 +669,16 @@ public static function strlenAllowNull(?string $string): int
669669
return strlen("$string");
670670
}
671671

672-
/** @param bool $convertBool If true, convert bool to locale-aware TRUE/FALSE rather than 1/null-string */
673-
public static function convertToString(mixed $value, bool $throw = true, string $default = '', bool $convertBool = false): string
672+
/**
673+
* @param bool $convertBool If true, convert bool to locale-aware TRUE/FALSE rather than 1/null-string
674+
* @param bool $lessFloatPrecision If true, floats will be converted to a more human-friendly but less computationally accurate value
675+
*/
676+
public static function convertToString(mixed $value, bool $throw = true, string $default = '', bool $convertBool = false, bool $lessFloatPrecision = false): string
674677
{
675678
if ($convertBool && is_bool($value)) {
676679
return $value ? Calculation::getTRUE() : Calculation::getFALSE();
677680
}
678-
if (is_float($value)) {
681+
if (is_float($value) && !$lessFloatPrecision) {
679682
$string = (string) $value;
680683
// look out for scientific notation
681684
if (!Preg::isMatch('/[^-+0-9.]/', $string)) {

src/PhpSpreadsheet/Style/NumberFormat.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -467,12 +467,13 @@ public function getHashCode(): string
467467
* @param string $format Format code: see = self::FORMAT_* for predefined values;
468468
* or can be any valid MS Excel custom format string
469469
* @param ?mixed[] $callBack Callback function for additional formatting of string
470+
* @param bool $lessFloatPrecision If true, unstyled floats will be converted to a more human-friendly but less computationally accurate value
470471
*
471472
* @return string Formatted string
472473
*/
473-
public static function toFormattedString(mixed $value, string $format, ?array $callBack = null): string
474+
public static function toFormattedString(mixed $value, string $format, ?array $callBack = null, bool $lessFloatPrecision = false): string
474475
{
475-
return NumberFormat\Formatter::toFormattedString($value, $format, $callBack);
476+
return NumberFormat\Formatter::toFormattedString($value, $format, $callBack, $lessFloatPrecision);
476477
}
477478

478479
/** @return mixed[] */

src/PhpSpreadsheet/Style/NumberFormat/Formatter.php

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,11 @@ private static function splitFormatForSectionSelection(array $sections, mixed $v
119119
* @param string $format Format code: see = self::FORMAT_* for predefined values;
120120
* or can be any valid MS Excel custom format string
121121
* @param null|array<mixed>|callable $callBack Callback function for additional formatting of string
122+
* @param bool $lessFloatPrecision If true, unstyled floats will be converted to a more human-friendly but less computationally accurate value
122123
*
123124
* @return string Formatted string
124125
*/
125-
public static function toFormattedString($value, string $format, null|array|callable $callBack = null): string
126+
public static function toFormattedString($value, string $format, null|array|callable $callBack = null, bool $lessFloatPrecision = false): string
126127
{
127128
while (is_array($value)) {
128129
$value = array_shift($value);
@@ -135,13 +136,13 @@ public static function toFormattedString($value, string $format, null|array|call
135136
$formatx = str_replace('\"', self::QUOTE_REPLACEMENT, $format);
136137
if (preg_match(self::SECTION_SPLIT, $format) === 0 && preg_match(self::SYMBOL_AT, $formatx) === 1) {
137138
if (!str_contains($format, '"')) {
138-
return str_replace('@', StringHelper::convertToString($value), $format);
139+
return str_replace('@', StringHelper::convertToString($value, lessFloatPrecision: $lessFloatPrecision), $format);
139140
}
140141
//escape any dollar signs on the string, so they are not replaced with an empty value
141142
$value = str_replace(
142143
['$', '"'],
143144
['\$', self::QUOTE_REPLACEMENT],
144-
StringHelper::convertToString($value)
145+
StringHelper::convertToString($value, lessFloatPrecision: $lessFloatPrecision)
145146
);
146147

147148
return str_replace(
@@ -153,14 +154,18 @@ public static function toFormattedString($value, string $format, null|array|call
153154

154155
// If we have a text value, return it "as is"
155156
if (!is_numeric($value)) {
156-
return StringHelper::convertToString($value);
157+
return StringHelper::convertToString($value, lessFloatPrecision: $lessFloatPrecision);
157158
}
158159

159160
// For 'General' format code, we just pass the value although this is not entirely the way Excel does it,
160161
// it seems to round numbers to a total of 10 digits.
161162
if (($format === NumberFormat::FORMAT_GENERAL) || ($format === NumberFormat::FORMAT_TEXT)) {
163+
if (is_float($value) && $lessFloatPrecision) {
164+
return self::adjustSeparators((string) $value);
165+
}
166+
162167
return self::adjustSeparators(
163-
StringHelper::convertToString($value)
168+
StringHelper::convertToString($value, lessFloatPrecision: $lessFloatPrecision)
164169
);
165170
}
166171

src/PhpSpreadsheet/Worksheet/Worksheet.php

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2949,12 +2949,15 @@ public function fromArray(array $source, mixed $nullValue = null, string $startC
29492949
}
29502950

29512951
/**
2952+
* @param bool $calculateFormulas Whether to calculate cell's value if it is a formula.
29522953
* @param null|bool|float|int|RichText|string $nullValue value to use when null
2954+
* @param bool $formatData Whether to format data according to cell's style.
2955+
* @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value
29532956
*
29542957
* @throws Exception
29552958
* @throws \PhpOffice\PhpSpreadsheet\Calculation\Exception
29562959
*/
2957-
protected function cellToArray(Cell $cell, bool $calculateFormulas, bool $formatData, mixed $nullValue): mixed
2960+
protected function cellToArray(Cell $cell, bool $calculateFormulas, bool $formatData, mixed $nullValue, bool $lessFloatPrecision = false): mixed
29582961
{
29592962
$returnValue = $nullValue;
29602963

@@ -2971,7 +2974,8 @@ protected function cellToArray(Cell $cell, bool $calculateFormulas, bool $format
29712974
$returnValuex = $returnValue;
29722975
$returnValue = NumberFormat::toFormattedString(
29732976
$returnValuex,
2974-
$style->getNumberFormat()->getFormatCode() ?? NumberFormat::FORMAT_GENERAL
2977+
$style->getNumberFormat()->getFormatCode() ?? NumberFormat::FORMAT_GENERAL,
2978+
lessFloatPrecision: $lessFloatPrecision
29752979
);
29762980
}
29772981
}
@@ -2989,6 +2993,8 @@ protected function cellToArray(Cell $cell, bool $calculateFormulas, bool $format
29892993
* True - Return rows and columns indexed by their actual row and column IDs
29902994
* @param bool $ignoreHidden False - Return values for rows/columns even if they are defined as hidden.
29912995
* True - Don't return values for rows/columns that are defined as hidden.
2996+
* @param bool $reduceArrays If true and result is a formula which evaluates to an array, reduce it to the top leftmost value.
2997+
* @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value
29922998
*
29932999
* @return mixed[][]
29943000
*/
@@ -2999,12 +3005,13 @@ public function rangeToArray(
29993005
bool $formatData = true,
30003006
bool $returnCellRef = false,
30013007
bool $ignoreHidden = false,
3002-
bool $reduceArrays = false
3008+
bool $reduceArrays = false,
3009+
bool $lessFloatPrecision = false
30033010
): array {
30043011
$returnValue = [];
30053012

30063013
// Loop through rows
3007-
foreach ($this->rangeToArrayYieldRows($range, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays) as $rowRef => $rowArray) {
3014+
foreach ($this->rangeToArrayYieldRows($range, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision) as $rowRef => $rowArray) {
30083015
$returnValue[$rowRef] = $rowArray;
30093016
}
30103017

@@ -3022,6 +3029,8 @@ public function rangeToArray(
30223029
* True - Return rows and columns indexed by their actual row and column IDs
30233030
* @param bool $ignoreHidden False - Return values for rows/columns even if they are defined as hidden.
30243031
* True - Don't return values for rows/columns that are defined as hidden.
3032+
* @param bool $reduceArrays If true and result is a formula which evaluates to an array, reduce it to the top leftmost value.
3033+
* @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value
30253034
*
30263035
* @return mixed[][]
30273036
*/
@@ -3032,14 +3041,15 @@ public function rangesToArray(
30323041
bool $formatData = true,
30333042
bool $returnCellRef = false,
30343043
bool $ignoreHidden = false,
3035-
bool $reduceArrays = false
3044+
bool $reduceArrays = false,
3045+
bool $lessFloatPrecision = false,
30363046
): array {
30373047
$returnValue = [];
30383048

30393049
$parts = explode(',', $ranges);
30403050
foreach ($parts as $part) {
30413051
// Loop through rows
3042-
foreach ($this->rangeToArrayYieldRows($part, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays) as $rowRef => $rowArray) {
3052+
foreach ($this->rangeToArrayYieldRows($part, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision) as $rowRef => $rowArray) {
30433053
$returnValue[$rowRef] = $rowArray;
30443054
}
30453055
}
@@ -3058,6 +3068,8 @@ public function rangesToArray(
30583068
* True - Return rows and columns indexed by their actual row and column IDs
30593069
* @param bool $ignoreHidden False - Return values for rows/columns even if they are defined as hidden.
30603070
* True - Don't return values for rows/columns that are defined as hidden.
3071+
* @param bool $reduceArrays If true and result is a formula which evaluates to an array, reduce it to the top leftmost value.
3072+
* @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value
30613073
*
30623074
* @return Generator<array<mixed>>
30633075
*/
@@ -3068,7 +3080,8 @@ public function rangeToArrayYieldRows(
30683080
bool $formatData = true,
30693081
bool $returnCellRef = false,
30703082
bool $ignoreHidden = false,
3071-
bool $reduceArrays = false
3083+
bool $reduceArrays = false,
3084+
bool $lessFloatPrecision = false
30723085
) {
30733086
$range = Validations::validateCellOrCellRange($range);
30743087

@@ -3113,7 +3126,7 @@ public function rangeToArrayYieldRows(
31133126
$columnRef = $returnCellRef ? $col : ($thisCol - $minColInt);
31143127
$cell = $this->cellCollection->get("{$col}{$thisRow}");
31153128
if ($cell !== null) {
3116-
$value = $this->cellToArray($cell, $calculateFormulas, $formatData, $nullValue);
3129+
$value = $this->cellToArray($cell, $calculateFormulas, $formatData, $nullValue, lessFloatPrecision: $lessFloatPrecision);
31173130
if ($reduceArrays) {
31183131
while (is_array($value)) {
31193132
$value = array_shift($value);
@@ -3214,6 +3227,8 @@ private function validateNamedRange(string $definedName, bool $returnNullIfInval
32143227
* True - Return rows and columns indexed by their actual row and column IDs
32153228
* @param bool $ignoreHidden False - Return values for rows/columns even if they are defined as hidden.
32163229
* True - Don't return values for rows/columns that are defined as hidden.
3230+
* @param bool $reduceArrays If true and result is a formula which evaluates to an array, reduce it to the top leftmost value.
3231+
* @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value
32173232
*
32183233
* @return mixed[][]
32193234
*/
@@ -3224,7 +3239,8 @@ public function namedRangeToArray(
32243239
bool $formatData = true,
32253240
bool $returnCellRef = false,
32263241
bool $ignoreHidden = false,
3227-
bool $reduceArrays = false
3242+
bool $reduceArrays = false,
3243+
bool $lessFloatPrecision = false
32283244
): array {
32293245
$retVal = [];
32303246
$namedRange = $this->validateNamedRange($definedName);
@@ -3233,7 +3249,7 @@ public function namedRangeToArray(
32333249
$cellRange = str_replace('$', '', $cellRange);
32343250
$workSheet = $namedRange->getWorksheet();
32353251
if ($workSheet !== null) {
3236-
$retVal = $workSheet->rangeToArray($cellRange, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays);
3252+
$retVal = $workSheet->rangeToArray($cellRange, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision);
32373253
}
32383254
}
32393255

@@ -3250,6 +3266,8 @@ public function namedRangeToArray(
32503266
* True - Return rows and columns indexed by their actual row and column IDs
32513267
* @param bool $ignoreHidden False - Return values for rows/columns even if they are defined as hidden.
32523268
* True - Don't return values for rows/columns that are defined as hidden.
3269+
* @param bool $reduceArrays If true and result is a formula which evaluates to an array, reduce it to the top leftmost value.
3270+
* @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value
32533271
*
32543272
* @return mixed[][]
32553273
*/
@@ -3259,7 +3277,8 @@ public function toArray(
32593277
bool $formatData = true,
32603278
bool $returnCellRef = false,
32613279
bool $ignoreHidden = false,
3262-
bool $reduceArrays = false
3280+
bool $reduceArrays = false,
3281+
bool $lessFloatPrecision = false
32633282
): array {
32643283
// Garbage collect...
32653284
$this->garbageCollect();
@@ -3270,7 +3289,7 @@ public function toArray(
32703289
$maxRow = $this->getHighestRow();
32713290

32723291
// Return
3273-
return $this->rangeToArray("A1:{$maxCol}{$maxRow}", $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays);
3292+
return $this->rangeToArray("A1:{$maxCol}{$maxRow}", $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision);
32743293
}
32753294

32763295
/**

tests/PhpSpreadsheetTests/Shared/Issue1324Test.php

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@
1010

1111
class Issue1324Test extends AbstractFunctional
1212
{
13-
protected static int $version = 80400;
14-
1513
public function testPrecision(): void
1614
{
1715
$string1 = '753.149999999999';
@@ -45,4 +43,55 @@ public function testCsv(): void
4543
self::assertStringContainsString($string1, $output);
4644
$spreadsheet->disconnectWorksheets();
4745
}
46+
47+
public static function testLessPrecision(): void
48+
{
49+
$spreadsheet = new Spreadsheet();
50+
$sheet = $spreadsheet->getActiveSheet();
51+
$inArray = [
52+
[123.4, 753.149999999999, 12.04, 224.245],
53+
[12345.6789, 44125.62188657407387],
54+
[true, 16, 'hello'],
55+
[753.149999999999, 44125.62188657407387],
56+
];
57+
$sheet->fromArray($inArray, null, 'A1', true);
58+
$sheet->getStyle('A4:B4')->getNumberFormat()
59+
->setFormatCode('#.##');
60+
$precise = $sheet->toArray();
61+
$expectedPrecise = [
62+
['123.40000000000001', '753.14999999999895', '12.039999999999999', '224.245'],
63+
['12345.67890000000079', '44125.62188657407387', null, null],
64+
['TRUE', '16', 'hello', null],
65+
['753.15', '44125.62', null, null],
66+
];
67+
self::assertSame($expectedPrecise, $precise);
68+
$lessPrecise = $sheet->toArray(lessFloatPrecision: true);
69+
$expectedLessPrecise = [
70+
['123.4', '753.15', '12.04', '224.245'],
71+
['12345.6789', '44125.621886574', null, null],
72+
['TRUE', '16', 'hello', null],
73+
['753.15', '44125.62', null, null],
74+
];
75+
self::assertSame($expectedLessPrecise, $lessPrecise);
76+
$spreadsheet->disconnectWorksheets();
77+
$preg = '/^[-+]?\d+[.]\d+$/';
78+
$diffs = [];
79+
for ($i = 0; $i < 2; ++$i) {
80+
$preciseRow = $precise[$i];
81+
$lessPreciseRow = $lessPrecise[$i];
82+
$entries = count($preciseRow);
83+
for ($j = 0; $j < $entries; ++$j) {
84+
$comparand1 = $preciseRow[$j];
85+
$comparand2 = $lessPreciseRow[$j];
86+
if (preg_match($preg, $comparand1 ?? '') && preg_match($preg, $comparand2 ?? '')) {
87+
$comparand1 = (float) $comparand1;
88+
$comparand2 = (float) $comparand2;
89+
}
90+
if ($comparand1 !== $comparand2) {
91+
$diffs[] = [$i, $j];
92+
}
93+
}
94+
}
95+
self::assertSame([[0, 1], [1, 1]], $diffs, 'cells which do not evaluate to same float value');
96+
}
4897
}

0 commit comments

Comments
 (0)