Skip to content

Commit fd61638

Browse files
authored
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.
1 parent 26987ae commit fd61638

File tree

6 files changed

+79
-13
lines changed

6 files changed

+79
-13
lines changed

src/PhpSpreadsheet/Calculation/DateTimeExcel/DateParts.php

+3
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public static function day($dateValue)
4747

4848
// Execute function
4949
$PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue);
50+
SharedDateHelper::roundMicroseconds($PHPDateObject);
5051

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

8687
// Execute function
8788
$PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue);
89+
SharedDateHelper::roundMicroseconds($PHPDateObject);
8890

8991
return (int) $PHPDateObject->format('n');
9092
}
@@ -123,6 +125,7 @@ public static function year($dateValue)
123125
}
124126
// Execute function
125127
$PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue);
128+
SharedDateHelper::roundMicroseconds($PHPDateObject);
126129

127130
return (int) $PHPDateObject->format('Y');
128131
}

src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeParts.php

+3
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public static function hour($timeValue)
4646
// Execute function
4747
$timeValue = fmod($timeValue, 1);
4848
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
49+
SharedDateHelper::roundMicroseconds($timeValue);
4950

5051
return (int) $timeValue->format('H');
5152
}
@@ -86,6 +87,7 @@ public static function minute($timeValue)
8687
// Execute function
8788
$timeValue = fmod($timeValue, 1);
8889
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
90+
SharedDateHelper::roundMicroseconds($timeValue);
8991

9092
return (int) $timeValue->format('i');
9193
}
@@ -126,6 +128,7 @@ public static function second($timeValue)
126128
// Execute function
127129
$timeValue = fmod($timeValue, 1);
128130
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
131+
SharedDateHelper::roundMicroseconds($timeValue);
129132

130133
return (int) $timeValue->format('s');
131134
}

src/PhpSpreadsheet/Shared/Date.php

+24-10
Original file line numberDiff line numberDiff line change
@@ -223,19 +223,21 @@ public static function excelToDateTimeObject($excelTimestamp, $timeZone = null)
223223

224224
$days = floor($excelTimestamp);
225225
$partDay = $excelTimestamp - $days;
226-
$hours = floor($partDay * 24);
227-
$partDay = $partDay * 24 - $hours;
228-
$minutes = floor($partDay * 60);
229-
$partDay = $partDay * 60 - $minutes;
230-
$seconds = round($partDay * 60);
226+
$hms = 86400 * $partDay;
227+
$microseconds = (int) round(fmod($hms, 1) * 1000000);
228+
$hms = (int) floor($hms);
229+
$hours = intdiv($hms, 3600);
230+
$hms -= $hours * 3600;
231+
$minutes = intdiv($hms, 60);
232+
$seconds = $hms % 60;
231233

232234
if ($days >= 0) {
233235
$days = '+' . $days;
234236
}
235237
$interval = $days . ' days';
236238

237239
return $baseDate->modify($interval)
238-
->setTime((int) $hours, (int) $minutes, (int) $seconds);
240+
->setTime((int) $hours, (int) $minutes, (int) $seconds, (int) $microseconds);
239241
}
240242

241243
/**
@@ -252,8 +254,10 @@ public static function excelToDateTimeObject($excelTimestamp, $timeZone = null)
252254
*/
253255
public static function excelToTimestamp($excelTimestamp, $timeZone = null)
254256
{
255-
return (int) self::excelToDateTimeObject($excelTimestamp, $timeZone)
256-
->format('U');
257+
$dto = self::excelToDateTimeObject($excelTimestamp, $timeZone);
258+
self::roundMicroseconds($dto);
259+
260+
return (int) $dto->format('U');
257261
}
258262

259263
/**
@@ -287,13 +291,15 @@ public static function PHPToExcel($dateValue)
287291
*/
288292
public static function dateTimeToExcel(DateTimeInterface $dateValue)
289293
{
294+
$seconds = (float) sprintf('%d.%06d', $dateValue->format('s'), $dateValue->format('u'));
295+
290296
return self::formattedPHPToExcel(
291297
(int) $dateValue->format('Y'),
292298
(int) $dateValue->format('m'),
293299
(int) $dateValue->format('d'),
294300
(int) $dateValue->format('H'),
295301
(int) $dateValue->format('i'),
296-
(int) $dateValue->format('s')
302+
$seconds
297303
);
298304
}
299305

@@ -323,7 +329,7 @@ public static function timestampToExcel($unixTimestamp)
323329
* @param int $day
324330
* @param int $hours
325331
* @param int $minutes
326-
* @param int $seconds
332+
* @param float|int $seconds
327333
*
328334
* @return float Excel date/time value
329335
*/
@@ -553,4 +559,12 @@ public static function formattedDateTimeFromTimestamp(string $date, string $form
553559

554560
return $dtobj->format($format);
555561
}
562+
563+
public static function roundMicroseconds(DateTime $dti): void
564+
{
565+
$microseconds = (int) $dti->format('u');
566+
if ($microseconds >= 500000) {
567+
$dti->modify('+1 second');
568+
}
569+
}
556570
}

src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php

+31
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,37 @@ public static function format($value, string $format): string
166166
// Excel 2003 XML formats, m will not have been changed to i above.
167167
// Change it now.
168168
$format = (string) \preg_replace('/\\\\:m/', ':i', $format);
169+
$microseconds = (int) $dateObj->format('u');
170+
if (strpos($format, ':s.000') !== false) {
171+
$milliseconds = (int) round($microseconds / 1000.0);
172+
if ($milliseconds === 1000) {
173+
$milliseconds = 0;
174+
$dateObj->modify('+1 second');
175+
}
176+
$dateObj->modify("-$microseconds microseconds");
177+
$format = str_replace(':s.000', ':s.' . sprintf('%03d', $milliseconds), $format);
178+
} elseif (strpos($format, ':s.00') !== false) {
179+
$centiseconds = (int) round($microseconds / 10000.0);
180+
if ($centiseconds === 100) {
181+
$centiseconds = 0;
182+
$dateObj->modify('+1 second');
183+
}
184+
$dateObj->modify("-$microseconds microseconds");
185+
$format = str_replace(':s.00', ':s.' . sprintf('%02d', $centiseconds), $format);
186+
} elseif (strpos($format, ':s.0') !== false) {
187+
$deciseconds = (int) round($microseconds / 100000.0);
188+
if ($deciseconds === 10) {
189+
$deciseconds = 0;
190+
$dateObj->modify('+1 second');
191+
}
192+
$dateObj->modify("-$microseconds microseconds");
193+
$format = str_replace(':s.0', ':s.' . sprintf('%1d', $deciseconds), $format);
194+
} else { // no fractional second
195+
if ($microseconds >= 500000) {
196+
$dateObj->modify('+1 second');
197+
}
198+
$dateObj->modify("-$microseconds microseconds");
199+
}
169200

170201
return $dateObj->format($format);
171202
}

tests/PhpSpreadsheetTests/Reader/Xlsx/ExplicitDateTest.php

+2-2
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public static function testExplicitDate(): void
3535
$value = $sheet->getCell('A3')->getValue();
3636
$formatted = $sheet->getCell('A3')->getFormattedValue();
3737
self::assertEqualsWithDelta(44561.98948, $value, 0.00001);
38-
self::assertSame('2021-12-31 23:44:51', $formatted);
38+
self::assertSame('2021-12-31 23:44:52', $formatted);
3939
// Date only
4040
$value = $sheet->getCell('B3')->getValue();
4141
$formatted = $sheet->getCell('B3')->getFormattedValue();
@@ -45,7 +45,7 @@ public static function testExplicitDate(): void
4545
$value = $sheet->getCell('C3')->getValue();
4646
$formatted = $sheet->getCell('C3')->getFormattedValue();
4747
self::assertEqualsWithDelta(0.98948, $value, 0.00001);
48-
self::assertSame('23:44:51', $formatted);
48+
self::assertSame('23:44:52', $formatted);
4949

5050
$spreadsheet->disconnectWorksheets();
5151
}

tests/data/Style/NumberFormatDates.php

+16-1
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,25 @@
4343
'yyyy/mm/dd\ h:mm:ss.000',
4444
],
4545
[
46-
'2023/02/28 07:35:02.000',
46+
'2023/02/28 07:35:02.400',
4747
44985.316,
4848
'yyyy/mm/dd\ hh:mm:ss.000',
4949
],
50+
[
51+
'2023/02/28 07:35:13.067',
52+
44985.316123456,
53+
'yyyy/mm/dd\ hh:mm:ss.000',
54+
],
55+
[
56+
'2023/02/28 07:35:13.07',
57+
44985.316123456,
58+
'yyyy/mm/dd\ hh:mm:ss.00',
59+
],
60+
[
61+
'2023/02/28 07:35:13.1',
62+
44985.316123456,
63+
'yyyy/mm/dd\ hh:mm:ss.0',
64+
],
5065
[
5166
'07:35:00 AM',
5267
43270.315972222,

0 commit comments

Comments
 (0)