Skip to content

Commit 7fe5ee8

Browse files
committed
Time Interval Formatting
Fix #2768. DateFormatter handles only one of six special formats for time intervals `[h] [hh] [m] [mm] [s] [ss]`. This PR extends support to the rest. There should be no more than one of these in any format string. Although it certainly could make sense to treat `[d] [dd]` in the same manner, Excel does not seem to support those. Interesting observations - hours and minutes are truncated (presumably because they may be followed by minutes and seconds), but seconds are rounded. Also, there are some floating point issues, which fortunately showed up for the example in the original issue. There, the time interval was 1.15, which should evaluate to a minutes value of 1656 (as it does in Excel). However, on my system it evaluated to 1655 because of a rounding error in the 13th decimal place. To overcome this, values are rounded to 10 decimal places before truncating.
1 parent c685888 commit 7fe5ee8

File tree

3 files changed

+166
-58
lines changed

3 files changed

+166
-58
lines changed

phpstan-baseline.neon

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4085,36 +4085,6 @@ parameters:
40854085
count: 1
40864086
path: src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalFormattingRuleExtension.php
40874087

4088-
-
4089-
message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\NumberFormat\\\\DateFormatter\\:\\:escapeQuotesCallback\\(\\) has parameter \\$matches with no type specified\\.$#"
4090-
count: 1
4091-
path: src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php
4092-
4093-
-
4094-
message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\NumberFormat\\\\DateFormatter\\:\\:format\\(\\) has parameter \\$value with no type specified\\.$#"
4095-
count: 1
4096-
path: src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php
4097-
4098-
-
4099-
message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\NumberFormat\\\\DateFormatter\\:\\:setLowercaseCallback\\(\\) has parameter \\$matches with no type specified\\.$#"
4100-
count: 1
4101-
path: src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php
4102-
4103-
-
4104-
message: "#^Parameter \\#1 \\$format of method DateTime\\:\\:format\\(\\) expects string, string\\|null given\\.$#"
4105-
count: 1
4106-
path: src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php
4107-
4108-
-
4109-
message: "#^Parameter \\#2 \\$replace of function str_replace expects array\\|string, int given\\.$#"
4110-
count: 1
4111-
path: src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php
4112-
4113-
-
4114-
message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\|string, string\\|null given\\.$#"
4115-
count: 1
4116-
path: src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php
4117-
41184088
-
41194089
message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\NumberFormat\\\\Formatter\\:\\:splitFormat\\(\\) has no return type specified\\.$#"
41204090
count: 1

src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,8 @@ class DateFormatter
88
{
99
/**
1010
* Search/replace values to convert Excel date/time format masks to PHP format masks.
11-
*
12-
* @var array
1311
*/
14-
private static $dateFormatReplacements = [
12+
private const DATE_FORMAT_REPLACEMENTS = [
1513
// first remove escapes related to non-format characters
1614
'\\' => '',
1715
// 12-hour suffix
@@ -32,10 +30,6 @@ class DateFormatter
3230
// It isn't perfect, but the best way I know how
3331
':mm' => ':i',
3432
'mm:' => 'i:',
35-
// month leading zero
36-
'mm' => 'm',
37-
// month no leading zero
38-
'm' => 'n',
3933
// full day of week name
4034
'dddd' => 'l',
4135
// short day of week name
@@ -44,32 +38,85 @@ class DateFormatter
4438
'dd' => 'd',
4539
// days no leading zero
4640
'd' => 'j',
47-
// seconds
48-
'ss' => 's',
4941
// fractional seconds - no php equivalent
5042
'.s' => '',
5143
];
5244

5345
/**
5446
* Search/replace values to convert Excel date/time format masks hours to PHP format masks (24 hr clock).
55-
*
56-
* @var array
5747
*/
58-
private static $dateFormatReplacements24 = [
48+
private const DATE_FORMAT_REPLACEMENTS24 = [
5949
'hh' => 'H',
6050
'h' => 'G',
51+
// month leading zero
52+
'mm' => 'm',
53+
// month no leading zero
54+
'm' => 'n',
55+
// seconds
56+
'ss' => 's',
6157
];
6258

6359
/**
6460
* Search/replace values to convert Excel date/time format masks hours to PHP format masks (12 hr clock).
65-
*
66-
* @var array
6761
*/
68-
private static $dateFormatReplacements12 = [
62+
private const DATE_FORMAT_REPLACEMENTS12 = [
6963
'hh' => 'h',
7064
'h' => 'g',
65+
// month leading zero
66+
'mm' => 'm',
67+
// month no leading zero
68+
'm' => 'n',
69+
// seconds
70+
'ss' => 's',
71+
];
72+
73+
private const HOURS_IN_DAY = 24;
74+
private const MINUTES_IN_DAY = 60 * self::HOURS_IN_DAY;
75+
private const SECONDS_IN_DAY = 60 * self::MINUTES_IN_DAY;
76+
private const INTERVAL_PRECISION = 10;
77+
private const INTERVAL_LEADING_ZERO = [
78+
'[hh]',
79+
'[mm]',
80+
'[ss]',
81+
];
82+
private const INTERVAL_ROUND_PRECISION = [
83+
// hours and minutes truncate
84+
'[h]' => self::INTERVAL_PRECISION,
85+
'[hh]' => self::INTERVAL_PRECISION,
86+
'[m]' => self::INTERVAL_PRECISION,
87+
'[mm]' => self::INTERVAL_PRECISION,
88+
// seconds round
89+
'[s]' => 0,
90+
'[ss]' => 0,
91+
];
92+
private const INTERVAL_MULTIPLIER = [
93+
'[h]' => self::HOURS_IN_DAY,
94+
'[hh]' => self::HOURS_IN_DAY,
95+
'[m]' => self::MINUTES_IN_DAY,
96+
'[mm]' => self::MINUTES_IN_DAY,
97+
'[s]' => self::SECONDS_IN_DAY,
98+
'[ss]' => self::SECONDS_IN_DAY,
7199
];
72100

101+
/** @param mixed $value */
102+
private static function tryInterval(bool &$seekingBracket, string &$block, $value, string $format): void
103+
{
104+
if ($seekingBracket) {
105+
if (false !== strpos($block, $format)) {
106+
$hours = (string) (int) round(
107+
self::INTERVAL_MULTIPLIER[$format] * $value,
108+
self::INTERVAL_ROUND_PRECISION[$format]
109+
);
110+
if (strlen($hours) === 1 && in_array($format, self::INTERVAL_LEADING_ZERO, true)) {
111+
$hours = "0$hours";
112+
}
113+
$block = str_replace($format, $hours, $block);
114+
$seekingBracket = false;
115+
}
116+
}
117+
}
118+
119+
/** @param mixed $value */
73120
public static function format($value, string $format): string
74121
{
75122
// strip off first part containing e.g. [$-F800] or [$USD-409]
@@ -90,20 +137,21 @@ public static function format($value, string $format): string
90137
$blocks = explode('"', $format);
91138
foreach ($blocks as $key => &$block) {
92139
if ($key % 2 == 0) {
93-
$block = strtr($block, self::$dateFormatReplacements);
140+
$block = strtr($block, self::DATE_FORMAT_REPLACEMENTS);
94141
if (!strpos($block, 'A')) {
95142
// 24-hour time format
96143
// when [h]:mm format, the [h] should replace to the hours of the value * 24
97-
if (false !== strpos($block, '[h]')) {
98-
$hours = (int) ($value * 24);
99-
$block = str_replace('[h]', $hours, $block);
100-
101-
continue;
102-
}
103-
$block = strtr($block, self::$dateFormatReplacements24);
144+
$seekingBracket = true;
145+
self::tryInterval($seekingBracket, $block, $value, '[h]');
146+
self::tryInterval($seekingBracket, $block, $value, '[hh]');
147+
self::tryInterval($seekingBracket, $block, $value, '[mm]');
148+
self::tryInterval($seekingBracket, $block, $value, '[m]');
149+
self::tryInterval($seekingBracket, $block, $value, '[s]');
150+
self::tryInterval($seekingBracket, $block, $value, '[ss]');
151+
$block = strtr($block, self::DATE_FORMAT_REPLACEMENTS24);
104152
} else {
105153
// 12-hour time format
106-
$block = strtr($block, self::$dateFormatReplacements12);
154+
$block = strtr($block, self::DATE_FORMAT_REPLACEMENTS12);
107155
}
108156
}
109157
}
@@ -112,23 +160,23 @@ public static function format($value, string $format): string
112160
// escape any quoted characters so that DateTime format() will render them correctly
113161
/** @var callable */
114162
$callback = ['self', 'escapeQuotesCallback'];
115-
$format = preg_replace_callback('/"(.*)"/U', $callback, $format);
163+
$format = preg_replace_callback('/"(.*)"/U', $callback, $format) ?? '';
116164

117165
$dateObj = Date::excelToDateTimeObject($value);
118166
// If the colon preceding minute had been quoted, as happens in
119167
// Excel 2003 XML formats, m will not have been changed to i above.
120168
// Change it now.
121-
$format = \preg_replace('/\\\\:m/', ':i', $format);
169+
$format = \preg_replace('/\\\\:m/', ':i', $format) ?? '';
122170

123171
return $dateObj->format($format);
124172
}
125173

126-
private static function setLowercaseCallback($matches): string
174+
private static function setLowercaseCallback(array $matches): string
127175
{
128176
return mb_strtolower($matches[0]);
129177
}
130178

131-
private static function escapeQuotesCallback($matches): string
179+
private static function escapeQuotesCallback(array $matches): string
132180
{
133181
return '\\' . implode('\\', str_split($matches[1]));
134182
}

tests/data/Style/NumberFormatDates.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,94 @@
7272
12345.6789,
7373
'[DBNum3][$-zh-CN]yyyymmdd;@',
7474
],
75+
'hour with leading 0 and minute' => [
76+
'03:36',
77+
1.15,
78+
'hh:mm',
79+
],
80+
'hour without leading 0 and minute' => [
81+
'3:36',
82+
1.15,
83+
'h:mm',
84+
],
85+
'hour truncated not rounded' => [
86+
'27',
87+
1.15,
88+
'[hh]',
89+
],
90+
'interval hour > 10 so no need for leading 0 and minute' => [
91+
'27:36',
92+
1.15,
93+
'[hh]:mm',
94+
],
95+
'interval hour > 10 no leading 0 and minute' => [
96+
'27:36',
97+
1.15,
98+
'[h]:mm',
99+
],
100+
'interval hour with leading 0 and minute' => [
101+
'03:36',
102+
0.15,
103+
'[hh]:mm',
104+
],
105+
'interval hour no leading 0 and minute' => [
106+
'3:36',
107+
0.15,
108+
'[h]:mm',
109+
],
110+
'interval hours > 100 and minutes no need for leading 0' => [
111+
'123:36',
112+
5.15,
113+
'[hh]:mm',
114+
],
115+
'interval hours > 100 and minutes no leading 0' => [
116+
'123:36',
117+
5.15,
118+
'[h]:mm',
119+
],
120+
'interval minutes > 10 no need for leading 0' => [
121+
'1656',
122+
1.15,
123+
'[mm]',
124+
],
125+
'interval minutes > 10 no leading 0' => [
126+
'1656',
127+
1.15,
128+
'[m]',
129+
],
130+
'interval minutes < 10 leading 0' => [
131+
'07',
132+
0.005,
133+
'[mm]',
134+
],
135+
'interval minutes < 10 no leading 0' => [
136+
'7',
137+
0.005,
138+
'[m]',
139+
],
140+
'interval minutes and seconds' => [
141+
'07:12',
142+
0.005,
143+
'[mm]:ss',
144+
],
145+
'interval seconds' => [
146+
'432',
147+
0.005,
148+
'[ss]',
149+
],
150+
'interval seconds rounded up leading 0' => [
151+
'09',
152+
0.0001,
153+
'[ss]',
154+
],
155+
'interval seconds rounded up no leading 0' => [
156+
'9',
157+
0.0001,
158+
'[s]',
159+
],
160+
'interval seconds rounded down' => [
161+
'6',
162+
0.00007,
163+
'[s]',
164+
],
75165
];

0 commit comments

Comments
 (0)