Skip to content

Commit

Permalink
Fractional Seconds in Date/Time Values (#3677)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
oleibman authored Aug 23, 2023
1 parent 26987ae commit fd61638
Show file tree
Hide file tree
Showing 6 changed files with 79 additions and 13 deletions.
3 changes: 3 additions & 0 deletions src/PhpSpreadsheet/Calculation/DateTimeExcel/DateParts.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public static function day($dateValue)

// Execute function
$PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue);
SharedDateHelper::roundMicroseconds($PHPDateObject);

return (int) $PHPDateObject->format('j');
}
Expand Down Expand Up @@ -85,6 +86,7 @@ public static function month($dateValue)

// Execute function
$PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue);
SharedDateHelper::roundMicroseconds($PHPDateObject);

return (int) $PHPDateObject->format('n');
}
Expand Down Expand Up @@ -123,6 +125,7 @@ public static function year($dateValue)
}
// Execute function
$PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue);
SharedDateHelper::roundMicroseconds($PHPDateObject);

return (int) $PHPDateObject->format('Y');
}
Expand Down
3 changes: 3 additions & 0 deletions src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeParts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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');
}
Expand Down
34 changes: 24 additions & 10 deletions src/PhpSpreadsheet/Shared/Date.php
Original file line number Diff line number Diff line change
Expand Up @@ -223,19 +223,21 @@ 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;
}
$interval = $days . ' days';

return $baseDate->modify($interval)
->setTime((int) $hours, (int) $minutes, (int) $seconds);
->setTime((int) $hours, (int) $minutes, (int) $seconds, (int) $microseconds);
}

/**
Expand All @@ -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');
}

/**
Expand Down Expand Up @@ -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
);
}

Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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');
}
}
}
31 changes: 31 additions & 0 deletions src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
4 changes: 2 additions & 2 deletions tests/PhpSpreadsheetTests/Reader/Xlsx/ExplicitDateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
}
Expand Down
17 changes: 16 additions & 1 deletion tests/data/Style/NumberFormatDates.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit fd61638

Please sign in to comment.