From b1012b8299903cc12c1279f4af401ef0446c0a40 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Wed, 16 Aug 2023 22:08:34 -0700 Subject: [PATCH] Fractional Seconds in Date/Time Values This is a replacement for PR #2404 which has been open for almost 2 years, and which I will close now. As submitted, it broke many unit tests, and no attempt was made to fix those and add others. However, while reviewing it, I found that, among all the tests which it accidentally broke, there were tests which it "broke" (in ExplicitDateTest) which were actually wrong in the first place. That seemed a good enough reason to investigate further. The original PR suggested the change was needed because "there is now support for microseconds when reading Datetime cells". I'm not sure that's true. Time of day is stored as a fraction of a day, and nothing prevents microseconds from being part of that fraction. It is true that Excel does not allow you to format a date/time cell to display more than 3 decimal positions for the seconds value, even if the value turns out to be accurate to the microsecond, and that has not changed. It is also true that Php supports microsecond accuracy in its DateTime objects, and it behooves PhpSpreadsheet to accommodate that. PhpSpreadsheet, like Excel, nominally supported the use of one, two, or three decimals when displaying seconds. However, it did not do it correctly, and there had been no tests of this using a value where the decimals were anything other than 0. One existing test, in NumberFormatDates, was wrong. It is fixed and new tests added. --- .../Calculation/DateTimeExcel/DateParts.php | 3 ++ .../Calculation/DateTimeExcel/TimeParts.php | 3 ++ src/PhpSpreadsheet/Shared/Date.php | 34 +++++++++++++------ .../Style/NumberFormat/DateFormatter.php | 31 +++++++++++++++++ .../Reader/Xlsx/ExplicitDateTest.php | 4 +-- tests/data/Style/NumberFormatDates.php | 17 +++++++++- 6 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src/PhpSpreadsheet/Calculation/DateTimeExcel/DateParts.php b/src/PhpSpreadsheet/Calculation/DateTimeExcel/DateParts.php index b669eb0a1e..7bb03c9da8 100644 --- a/src/PhpSpreadsheet/Calculation/DateTimeExcel/DateParts.php +++ b/src/PhpSpreadsheet/Calculation/DateTimeExcel/DateParts.php @@ -47,6 +47,7 @@ public static function day($dateValue) // Execute function $PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue); + SharedDateHelper::roundMicroseconds($PHPDateObject); return (int) $PHPDateObject->format('j'); } @@ -85,6 +86,7 @@ public static function month($dateValue) // Execute function $PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue); + SharedDateHelper::roundMicroseconds($PHPDateObject); return (int) $PHPDateObject->format('n'); } @@ -123,6 +125,7 @@ public static function year($dateValue) } // Execute function $PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue); + SharedDateHelper::roundMicroseconds($PHPDateObject); return (int) $PHPDateObject->format('Y'); } diff --git a/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeParts.php b/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeParts.php index d9b99f3c36..e1331b0109 100644 --- a/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeParts.php +++ b/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeParts.php @@ -46,6 +46,7 @@ public static function hour($timeValue) // Execute function $timeValue = fmod($timeValue, 1); $timeValue = SharedDateHelper::excelToDateTimeObject($timeValue); + SharedDateHelper::roundMicroseconds($timeValue); return (int) $timeValue->format('H'); } @@ -86,6 +87,7 @@ public static function minute($timeValue) // Execute function $timeValue = fmod($timeValue, 1); $timeValue = SharedDateHelper::excelToDateTimeObject($timeValue); + SharedDateHelper::roundMicroseconds($timeValue); return (int) $timeValue->format('i'); } @@ -126,6 +128,7 @@ public static function second($timeValue) // Execute function $timeValue = fmod($timeValue, 1); $timeValue = SharedDateHelper::excelToDateTimeObject($timeValue); + SharedDateHelper::roundMicroseconds($timeValue); return (int) $timeValue->format('s'); } diff --git a/src/PhpSpreadsheet/Shared/Date.php b/src/PhpSpreadsheet/Shared/Date.php index 4f19673113..9f5abe34d9 100644 --- a/src/PhpSpreadsheet/Shared/Date.php +++ b/src/PhpSpreadsheet/Shared/Date.php @@ -223,11 +223,13 @@ public static function excelToDateTimeObject($excelTimestamp, $timeZone = null) $days = floor($excelTimestamp); $partDay = $excelTimestamp - $days; - $hours = floor($partDay * 24); - $partDay = $partDay * 24 - $hours; - $minutes = floor($partDay * 60); - $partDay = $partDay * 60 - $minutes; - $seconds = round($partDay * 60); + $hms = 86400 * $partDay; + $microseconds = (int) round(fmod($hms, 1) * 1000000); + $hms = (int) floor($hms); + $hours = intdiv($hms, 3600); + $hms -= $hours * 3600; + $minutes = intdiv($hms, 60); + $seconds = $hms % 60; if ($days >= 0) { $days = '+' . $days; @@ -235,7 +237,7 @@ public static function excelToDateTimeObject($excelTimestamp, $timeZone = null) $interval = $days . ' days'; return $baseDate->modify($interval) - ->setTime((int) $hours, (int) $minutes, (int) $seconds); + ->setTime((int) $hours, (int) $minutes, (int) $seconds, (int) $microseconds); } /** @@ -252,8 +254,10 @@ public static function excelToDateTimeObject($excelTimestamp, $timeZone = null) */ public static function excelToTimestamp($excelTimestamp, $timeZone = null) { - return (int) self::excelToDateTimeObject($excelTimestamp, $timeZone) - ->format('U'); + $dto = self::excelToDateTimeObject($excelTimestamp, $timeZone); + self::roundMicroseconds($dto); + + return (int) $dto->format('U'); } /** @@ -287,13 +291,15 @@ public static function PHPToExcel($dateValue) */ public static function dateTimeToExcel(DateTimeInterface $dateValue) { + $seconds = (float) sprintf('%d.%06d', $dateValue->format('s'), $dateValue->format('u')); + return self::formattedPHPToExcel( (int) $dateValue->format('Y'), (int) $dateValue->format('m'), (int) $dateValue->format('d'), (int) $dateValue->format('H'), (int) $dateValue->format('i'), - (int) $dateValue->format('s') + $seconds ); } @@ -323,7 +329,7 @@ public static function timestampToExcel($unixTimestamp) * @param int $day * @param int $hours * @param int $minutes - * @param int $seconds + * @param float|int $seconds * * @return float Excel date/time value */ @@ -553,4 +559,12 @@ public static function formattedDateTimeFromTimestamp(string $date, string $form return $dtobj->format($format); } + + public static function roundMicroseconds(DateTime $dti): void + { + $microseconds = (int) $dti->format('u'); + if ($microseconds >= 500000) { + $dti->modify('+1 second'); + } + } } diff --git a/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php b/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php index ba54b53593..d1826ea77b 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php +++ b/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php @@ -166,6 +166,37 @@ public static function format($value, string $format): string // Excel 2003 XML formats, m will not have been changed to i above. // Change it now. $format = (string) \preg_replace('/\\\\:m/', ':i', $format); + $microseconds = (int) $dateObj->format('u'); + if (strpos($format, ':s.000') !== false) { + $milliseconds = (int) round($microseconds / 1000.0); + if ($milliseconds === 1000) { + $milliseconds = 0; + $dateObj->modify('+1 second'); + } + $dateObj->modify("-$microseconds microseconds"); + $format = str_replace(':s.000', ':s.' . sprintf('%03d', $milliseconds), $format); + } elseif (strpos($format, ':s.00') !== false) { + $centiseconds = (int) round($microseconds / 10000.0); + if ($centiseconds === 100) { + $centiseconds = 0; + $dateObj->modify('+1 second'); + } + $dateObj->modify("-$microseconds microseconds"); + $format = str_replace(':s.00', ':s.' . sprintf('%02d', $centiseconds), $format); + } elseif (strpos($format, ':s.0') !== false) { + $deciseconds = (int) round($microseconds / 100000.0); + if ($deciseconds === 10) { + $deciseconds = 0; + $dateObj->modify('+1 second'); + } + $dateObj->modify("-$microseconds microseconds"); + $format = str_replace(':s.0', ':s.' . sprintf('%1d', $deciseconds), $format); + } else { // no fractional second + if ($microseconds >= 500000) { + $dateObj->modify('+1 second'); + } + $dateObj->modify("-$microseconds microseconds"); + } return $dateObj->format($format); } diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/ExplicitDateTest.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/ExplicitDateTest.php index 23af522269..4f13264ffd 100644 --- a/tests/PhpSpreadsheetTests/Reader/Xlsx/ExplicitDateTest.php +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/ExplicitDateTest.php @@ -35,7 +35,7 @@ public static function testExplicitDate(): void $value = $sheet->getCell('A3')->getValue(); $formatted = $sheet->getCell('A3')->getFormattedValue(); self::assertEqualsWithDelta(44561.98948, $value, 0.00001); - self::assertSame('2021-12-31 23:44:51', $formatted); + self::assertSame('2021-12-31 23:44:52', $formatted); // Date only $value = $sheet->getCell('B3')->getValue(); $formatted = $sheet->getCell('B3')->getFormattedValue(); @@ -45,7 +45,7 @@ public static function testExplicitDate(): void $value = $sheet->getCell('C3')->getValue(); $formatted = $sheet->getCell('C3')->getFormattedValue(); self::assertEqualsWithDelta(0.98948, $value, 0.00001); - self::assertSame('23:44:51', $formatted); + self::assertSame('23:44:52', $formatted); $spreadsheet->disconnectWorksheets(); } diff --git a/tests/data/Style/NumberFormatDates.php b/tests/data/Style/NumberFormatDates.php index 42fbc4ea09..632b967e03 100644 --- a/tests/data/Style/NumberFormatDates.php +++ b/tests/data/Style/NumberFormatDates.php @@ -43,10 +43,25 @@ 'yyyy/mm/dd\ h:mm:ss.000', ], [ - '2023/02/28 07:35:02.000', + '2023/02/28 07:35:02.400', 44985.316, 'yyyy/mm/dd\ hh:mm:ss.000', ], + [ + '2023/02/28 07:35:13.067', + 44985.316123456, + 'yyyy/mm/dd\ hh:mm:ss.000', + ], + [ + '2023/02/28 07:35:13.07', + 44985.316123456, + 'yyyy/mm/dd\ hh:mm:ss.00', + ], + [ + '2023/02/28 07:35:13.1', + 44985.316123456, + 'yyyy/mm/dd\ hh:mm:ss.0', + ], [ '07:35:00 AM', 43270.315972222,