Skip to content

More Precision for Float to String Casts #4479

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
- Print Area and Row Break. [Issue #1275](https://github.com/PHPOffice/PhpSpreadsheet/issues/1275) [PR #4450](https://github.com/PHPOffice/PhpSpreadsheet/pull/4450)
- Copy Styles after insertNewColumnBefore. [Issue #1425](https://github.com/PHPOffice/PhpSpreadsheet/issues/1425) [PR #4468](https://github.com/PHPOffice/PhpSpreadsheet/pull/4468)
- Xls Writer Treat Hyperlink Starting with # as Internal. [Issue #56](https://github.com/PHPOffice/PhpSpreadsheet/issues/56) [PR #4453](https://github.com/PHPOffice/PhpSpreadsheet/pull/4453)
- More Precision for Float to String Casts. [Issue #3899](https://github.com/PHPOffice/PhpSpreadsheet/issues/3899) [PR #4479](https://github.com/PHPOffice/PhpSpreadsheet/pull/4479)
- Hyperlink Styles. [Issue #1632](https://github.com/PHPOffice/PhpSpreadsheet/issues/1632) [PR #4478](https://github.com/PHPOffice/PhpSpreadsheet/pull/4478)
- ODS Handling of Ceiling and Floor. [Issue #477](https://github.com/PHPOffice/PhpSpreadsheet/issues/407) [PR #4466](https://github.com/PHPOffice/PhpSpreadsheet/pull/4466)
- Xlsx Reader Do Not Process Printer Settings for Dataonly. [Issue #4477](https://github.com/PHPOffice/PhpSpreadsheet/issues/4477) [PR #4480](https://github.com/PHPOffice/PhpSpreadsheet/pull/4480)
Expand Down
2 changes: 1 addition & 1 deletion src/PhpSpreadsheet/Shared/Date.php
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ public static function stringToExcel(string $dateValue): bool|float
if (strlen($dateValue) < 2) {
return false;
}
if (!preg_match('/^(\d{1,4}[ \.\/\-][A-Z]{3,9}([ \.\/\-]\d{1,4})?|[A-Z]{3,9}[ \.\/\-]\d{1,4}([ \.\/\-]\d{1,4})?|\d{1,4}[ \.\/\-]\d{1,4}([ \.\/\-]\d{1,4})?)( \d{1,2}:\d{1,2}(:\d{1,2})?)?$/iu', $dateValue)) {
if (!preg_match('/^(\d{1,4}[ \.\/\-][A-Z]{3,9}([ \.\/\-]\d{1,4})?|[A-Z]{3,9}[ \.\/\-]\d{1,4}([ \.\/\-]\d{1,4})?|\d{1,4}[ \.\/\-]\d{1,4}([ \.\/\-]\d{1,4})?)( \d{1,2}:\d{1,2}(:\d{1,2}([.]\d+))?)?$/iu', $dateValue)) {
return false;
}

Expand Down
22 changes: 20 additions & 2 deletions src/PhpSpreadsheet/Shared/StringHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace PhpOffice\PhpSpreadsheet\Shared;

use Composer\Pcre\Preg;
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException;
use Stringable;
Expand Down Expand Up @@ -494,9 +495,9 @@ public static function mbStrSplit(string $string): array
{
// Split at all position not after the start: ^
// and not before the end: $
$split = preg_split('/(?<!^)(?!$)/u', $string);
$split = Preg::split('/(?<!^)(?!$)/u', $string);

return ($split === false) ? [] : $split;
return $split;
}

/**
Expand Down Expand Up @@ -530,6 +531,7 @@ private static function getLocaleValue(string $key, string $altKey, string $defa
$localeconv = localeconv();
$rslt = $localeconv[$key];
// win-1252 implements Euro as 0x80 plus other symbols
// Not suitable for Composer\Pcre\Preg
if (preg_match('//u', $rslt) !== 1) {
$rslt = '';
}
Expand Down Expand Up @@ -659,6 +661,22 @@ public static function convertToString(mixed $value, bool $throw = true, string
if ($convertBool && is_bool($value)) {
return $value ? Calculation::getTRUE() : Calculation::getFALSE();
}
if (is_float($value)) {
$string = (string) $value;
// look out for scientific notation
if (!Preg::isMatch('/[^-+0-9.]/', $string)) {
$minus = $value < 0 ? '-' : '';
$positive = abs($value);
$floor = floor($positive);
$oldFrac = (string) ($positive - $floor);
$frac = Preg::replace('/^0[.](\d+)$/', '$1', $oldFrac);
if ($frac !== $oldFrac) {
return "$minus$floor.$frac";
}
}

return $string;
}
if ($value === null || is_scalar($value) || $value instanceof Stringable) {
return (string) $value;
}
Expand Down
12 changes: 4 additions & 8 deletions src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php
Original file line number Diff line number Diff line change
Expand Up @@ -1463,15 +1463,11 @@ private function writeCellString(XMLWriter $objWriter, string $mappedType, RichT

private function writeCellNumeric(XMLWriter $objWriter, float|int $cellValue): void
{
//force a decimal to be written if the type is float
if (is_float($cellValue)) {
// force point as decimal separator in case current locale uses comma
$cellValue = str_replace(',', '.', (string) $cellValue);
if (!str_contains($cellValue, '.')) {
$cellValue = $cellValue . '.0';
}
$result = StringHelper::convertToString($cellValue);
if (is_float($cellValue) && !str_contains($result, '.')) {
$result .= '.0';
}
$objWriter->writeElement('v', "$cellValue");
$objWriter->writeElement('v', $result);
}

private function writeCellBoolean(XMLWriter $objWriter, string $mappedType, bool $cellValue): void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ public function testTooMuchPrecision(mixed $expectedResult, float|int|string $va
$sheet = $this->getSheet();
$sheet->getCell('E1')->setValue($value);
$sheet->getCell('E2')->setValue("=TRUNC(E1,$digits)");
$result = $sheet->getCell('E2')->getCalculatedValueString();
self::assertSame($expectedResult, $result);
/** @var float|string */
$result = $sheet->getCell('E2')->getCalculatedValue();
self::assertSame($expectedResult, (string) $result);
}

public static function providerTooMuchPrecision(): array
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\TextData;

use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
use PHPUnit\Framework\Attributes\DataProvider;

class ArrayToTextTest extends AllSetupTeardown
Expand All @@ -17,6 +18,9 @@ public function testArrayToText(string $expectedResult, array $testData, int $mo
$worksheet->getCell('H1')->setValue("=ARRAYTOTEXT(A1:C5, {$mode})");

$result = $worksheet->getCell('H1')->getCalculatedValue();
$b1SimpleCast = '12345.6789';
$b1AccurateCast = StringHelper::convertToString(12345.6789);
$expectedResult = str_replace($b1SimpleCast, $b1AccurateCast, $expectedResult);
self::assertSame($expectedResult, $result);
}

Expand Down
24 changes: 24 additions & 0 deletions tests/PhpSpreadsheetTests/FloatImprecisionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace PhpOffice\PhpSpreadsheetTests;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

class FloatImprecisionTest extends TestCase
{
#[DataProvider('providerFloats')]
public function testCompareFloats(float $float1, float $float2): void
{
self::assertSame($float1, $float2);
}

public static function providerFloats(): array
{
return [
[12345.6789, 12345.67890000000079],
];
}
}
50 changes: 36 additions & 14 deletions tests/PhpSpreadsheetTests/Writer/Ods/ContentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ protected function setUp(): void
parent::setUp();

$this->compatibilityMode = Functions::getCompatibilityMode();
Functions::setCompatibilityMode(Functions::COMPATIBILITY_OPENOFFICE);
Functions::setCompatibilityMode(
Functions::COMPATIBILITY_OPENOFFICE
);
}

protected function tearDown(): void
Expand All @@ -57,6 +59,8 @@ public function testWriteSpreadsheet(): void
$worksheet1 = $workbook->getActiveSheet();
$worksheet1->setCellValue('A1', 1); // Number
$worksheet1->setCellValue('B1', 12345.6789); // Number
$b1SimpleCast = '12345.6789';
$b1AccurateCast = StringHelper::convertToString(12345.6789);
$worksheet1->setCellValue('C1', '1'); // Number without cast
$worksheet1->setCellValueExplicit('D1', '01234', DataType::TYPE_STRING); // Number casted to string
$worksheet1->setCellValue('E1', 'Lorem ipsum'); // String
Expand All @@ -74,6 +78,11 @@ public function testWriteSpreadsheet(): void
$worksheet1->getStyle('D2')
->getNumberFormat()
->setFormatCode(NumberFormat::FORMAT_DATE_DATETIME);
/** @var float */
$d2SimpleCast = $worksheet1->getCell('D2')->getValue();
$d2SimpleCast = (string) $d2SimpleCast;
$d2AccurateCast = $worksheet1
->getCell('D2')->getValueString();

$worksheet1->setCellValueExplicit('F1', null, DataType::TYPE_ERROR);
$worksheet1->setCellValueExplicit('G1', 'Lorem ipsum', DataType::TYPE_INLINE);
Expand All @@ -85,12 +94,17 @@ public function testWriteSpreadsheet(): void
$worksheet1->getStyle('C1')->getFont()->setSize(14);
$worksheet1->getStyle('C1')->getFont()->setColor(new Color(Color::COLOR_BLUE));

$worksheet1->getStyle('C1')->getFill()->setFillType(Fill::FILL_SOLID);
$worksheet1->getStyle('C1')->getFill()->setStartColor(new Color(Color::COLOR_RED));
$worksheet1->getStyle('C1')
->getFill()->setFillType(Fill::FILL_SOLID);
$worksheet1->getStyle('C1')
->getFill()->setStartColor(new Color(Color::COLOR_RED));

$worksheet1->getStyle('C1')->getFont()->setUnderline(Font::UNDERLINE_SINGLE);
$worksheet1->getStyle('C2')->getFont()->setUnderline(Font::UNDERLINE_DOUBLE);
$worksheet1->getStyle('D2')->getFont()->setUnderline(Font::UNDERLINE_NONE);
$worksheet1->getStyle('C1')->getFont()
->setUnderline(Font::UNDERLINE_SINGLE);
$worksheet1->getStyle('C2')->getFont()
->setUnderline(Font::UNDERLINE_DOUBLE);
$worksheet1->getStyle('D2')->getFont()
->setUnderline(Font::UNDERLINE_NONE);

// Worksheet 2
$worksheet2 = $workbook->createSheet();
Expand All @@ -101,7 +115,12 @@ public function testWriteSpreadsheet(): void
$content = new Content(new Ods($workbook));
$xml = $content->write();

self::assertXmlStringEqualsXmlFile($this->samplesPath . '/content-with-data.xml', $xml);
$xmlFile = $this->samplesPath . '/content-with-data.xml';
$xmlContents = (string) file_get_contents($xmlFile);
$xmlContents = str_replace($b1SimpleCast, $b1AccurateCast, $xmlContents);
$xmlContents = str_replace($d2SimpleCast, $d2AccurateCast, $xmlContents);
self::assertXmlStringEqualsXmlString($xmlContents, $xml);
$workbook->disconnectWorksheets();
}

public function testWriteWithHiddenWorksheet(): void
Expand All @@ -124,19 +143,21 @@ public function testWriteWithHiddenWorksheet(): void
$xml = $content->write();

self::assertXmlStringEqualsXmlFile($this->samplesPath . '/content-hidden-worksheet.xml', $xml);
$workbook->disconnectWorksheets();
}

public function testWriteBorderStyle(): void
{
$spreadsheet = new Spreadsheet();
$spreadsheet->getActiveSheet()->getStyle('A1:B2')->applyFromArray([
'borders' => [
'outline' => [
'borderStyle' => Border::BORDER_THICK,
'color' => ['argb' => 'AA22DD00'],
$spreadsheet->getActiveSheet()
->getStyle('A1:B2')->applyFromArray([
'borders' => [
'outline' => [
'borderStyle' => Border::BORDER_THICK,
'color' => ['argb' => 'AA22DD00'],
],
],
],
]);
]);

$content = new Content(new Ods($spreadsheet));
$xml = $content->write();
Expand All @@ -161,5 +182,6 @@ public function testWriteBorderStyle(): void
}
}
}
$spreadsheet->disconnectWorksheets();
}
}
50 changes: 50 additions & 0 deletions tests/PhpSpreadsheetTests/Writer/Ods/MicrosecondsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace PhpOffice\PhpSpreadsheetTests\Writer\Ods;

use DateTime;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheetTests\Functional\AbstractFunctional;

class MicrosecondsTest extends AbstractFunctional
{
/**
* Test save and load XLSX file for round-trip DateTime.
* Ods Writer does not support date formats,
* and Reader does not support styles, so this
* test is slightly different than its Xls/Xlsx counterparts.
*/
public function testIssue4476(): void
{
$date = '2020-10-21';
$time = '14:55:31';
$originalDateTime = new DateTime("{$date}T{$time}");
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setCellValue('A1', Date::dateTimeToExcel($originalDateTime));
$reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Ods');
$spreadsheet->disconnectWorksheets();

$rsheet = $reloadedSpreadsheet->getActiveSheet();
$rsheet->getStyle('A1')
->getNumberFormat()
->setFormatCode('yyyy-mm-dd hh:mm:ss.000');
/** @var float */
$reread = $rsheet->getCell('A1')->getValue();
$temp = Date::excelToDateTimeObject($reread)
->format('Y-m-d H:i:s.u');
self::assertSame("{$date} {$time}.000000", $temp, 'round trip works with float value');
$formatted = $rsheet->getCell('A1')->getFormattedValue();
self::assertSame("{$date} {$time}.000", $formatted, 'round trip works with formatted value');
/** @var float */
$temp = Date::stringToExcel($formatted);
$temp = Date::excelToDateTimeObject($temp)
->format('Y-m-d H:i:s.u');
self::assertSame("{$date} {$time}.000000", $temp, 'round trip works using stringToExcel on formatted value');

$reloadedSpreadsheet->disconnectWorksheets();
}
}
47 changes: 47 additions & 0 deletions tests/PhpSpreadsheetTests/Writer/Xls/MicrosecondsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace PhpOffice\PhpSpreadsheetTests\Writer\Xls;

use DateTime;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheetTests\Functional\AbstractFunctional;

class MicrosecondsTest extends AbstractFunctional
{
/**
* Test save and load XLSX file for round-trip DateTime.
*/
public function testIssue4476(): void
{
$date = '2020-10-21';
$time = '14:55:31';
$originalDateTime = new DateTime("{$date}T{$time}");
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setCellValue('A1', Date::dateTimeToExcel($originalDateTime));
$sheet->getStyle('A1')
->getNumberFormat()
->setFormatCode('yyyy-mm-dd hh:mm:ss.000');
$reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Xls');
$spreadsheet->disconnectWorksheets();

$rsheet = $reloadedSpreadsheet->getActiveSheet();
/** @var float */
$reread = $rsheet->getCell('A1')->getValue();
$temp = Date::excelToDateTimeObject($reread)
->format('Y-m-d H:i:s.u');
self::assertSame("{$date} {$time}.000000", $temp, 'round trip works with float value');
$formatted = $rsheet->getCell('A1')->getFormattedValue();
self::assertSame("{$date} {$time}.000", $formatted, 'round trip works with formatted value');
/** @var float */
$temp = Date::stringToExcel($formatted);
$temp = Date::excelToDateTimeObject($temp)
->format('Y-m-d H:i:s.u');
self::assertSame("{$date} {$time}.000000", $temp, 'round trip works using stringToExcel on formatted value');

$reloadedSpreadsheet->disconnectWorksheets();
}
}
47 changes: 47 additions & 0 deletions tests/PhpSpreadsheetTests/Writer/Xlsx/MicrosecondsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace PhpOffice\PhpSpreadsheetTests\Writer\Xlsx;

use DateTime;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheetTests\Functional\AbstractFunctional;

class MicrosecondsTest extends AbstractFunctional
{
/**
* Test save and load XLSX file for round-trip DateTime.
*/
public function testIssue4476(): void
{
$date = '2020-10-21';
$time = '14:55:31';
$originalDateTime = new DateTime("{$date}T{$time}");
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setCellValue('A1', Date::dateTimeToExcel($originalDateTime));
$sheet->getStyle('A1')
->getNumberFormat()
->setFormatCode('yyyy-mm-dd hh:mm:ss.000');
$reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Xlsx');
$spreadsheet->disconnectWorksheets();

$rsheet = $reloadedSpreadsheet->getActiveSheet();
/** @var float */
$reread = $rsheet->getCell('A1')->getValue();
$temp = Date::excelToDateTimeObject($reread)
->format('Y-m-d H:i:s.u');
self::assertSame("{$date} {$time}.000000", $temp, 'round trip works with float value');
$formatted = $rsheet->getCell('A1')->getFormattedValue();
self::assertSame("{$date} {$time}.000", $formatted, 'round trip works with formatted value');
/** @var float */
$temp = Date::stringToExcel($formatted);
$temp = Date::excelToDateTimeObject($temp)
->format('Y-m-d H:i:s.u');
self::assertSame("{$date} {$time}.000000", $temp, 'round trip works using stringToExcel on formatted value');

$reloadedSpreadsheet->disconnectWorksheets();
}
}