Fractional Seconds in Date/Time Values (#3677)

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.
This commit is contained in:
oleibman
2023-08-23 07:22:46 -07:00
committed by GitHub
parent 26987ae779
commit fd61638206
6 changed files with 79 additions and 13 deletions
@@ -47,6 +47,7 @@ class DateParts
// Execute function
$PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue);
SharedDateHelper::roundMicroseconds($PHPDateObject);
return (int) $PHPDateObject->format('j');
}
@@ -85,6 +86,7 @@ class DateParts
// Execute function
$PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue);
SharedDateHelper::roundMicroseconds($PHPDateObject);
return (int) $PHPDateObject->format('n');
}
@@ -123,6 +125,7 @@ class DateParts
}
// Execute function
$PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue);
SharedDateHelper::roundMicroseconds($PHPDateObject);
return (int) $PHPDateObject->format('Y');
}
@@ -46,6 +46,7 @@ class TimeParts
// Execute function
$timeValue = fmod($timeValue, 1);
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
SharedDateHelper::roundMicroseconds($timeValue);
return (int) $timeValue->format('H');
}
@@ -86,6 +87,7 @@ class TimeParts
// Execute function
$timeValue = fmod($timeValue, 1);
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
SharedDateHelper::roundMicroseconds($timeValue);
return (int) $timeValue->format('i');
}
@@ -126,6 +128,7 @@ class TimeParts
// Execute function
$timeValue = fmod($timeValue, 1);
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
SharedDateHelper::roundMicroseconds($timeValue);
return (int) $timeValue->format('s');
}
+24 -10
View File
@@ -223,11 +223,13 @@ class Date
$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 @@ class Date
$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 @@ class Date
*/
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 @@ class Date
*/
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 @@ class Date
* @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 @@ class Date
return $dtobj->format($format);
}
public static function roundMicroseconds(DateTime $dti): void
{
$microseconds = (int) $dti->format('u');
if ($microseconds >= 500000) {
$dti->modify('+1 second');
}
}
}
@@ -166,6 +166,37 @@ class DateFormatter
// 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);
}
@@ -35,7 +35,7 @@ class ExplicitDateTest extends \PHPUnit\Framework\TestCase
$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 @@ class ExplicitDateTest extends \PHPUnit\Framework\TestCase
$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();
}
+16 -1
View File
@@ -43,10 +43,25 @@ return [
'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,