| 1441 |
ariadna |
1 |
<?php
|
|
|
2 |
|
|
|
3 |
namespace PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
|
|
4 |
|
|
|
5 |
use PhpOffice\PhpSpreadsheet\Shared\Date;
|
|
|
6 |
|
|
|
7 |
class DateFormatter
|
|
|
8 |
{
|
|
|
9 |
/**
|
|
|
10 |
* Search/replace values to convert Excel date/time format masks to PHP format masks.
|
|
|
11 |
*/
|
|
|
12 |
private const DATE_FORMAT_REPLACEMENTS = [
|
|
|
13 |
// first remove escapes related to non-format characters
|
|
|
14 |
'\\' => '',
|
|
|
15 |
// 12-hour suffix
|
|
|
16 |
'am/pm' => 'A',
|
|
|
17 |
// 4-digit year
|
|
|
18 |
'e' => 'Y',
|
|
|
19 |
'yyyy' => 'Y',
|
|
|
20 |
// 2-digit year
|
|
|
21 |
'yy' => 'y',
|
|
|
22 |
// first letter of month - no php equivalent
|
|
|
23 |
'mmmmm' => 'M',
|
|
|
24 |
// full month name
|
|
|
25 |
'mmmm' => 'F',
|
|
|
26 |
// short month name
|
|
|
27 |
'mmm' => 'M',
|
|
|
28 |
// mm is minutes if time, but can also be month w/leading zero
|
|
|
29 |
// so we try to identify times be the inclusion of a : separator in the mask
|
|
|
30 |
// It isn't perfect, but the best way I know how
|
|
|
31 |
':mm' => ':i',
|
|
|
32 |
'mm:' => 'i:',
|
|
|
33 |
// full day of week name
|
|
|
34 |
'dddd' => 'l',
|
|
|
35 |
// short day of week name
|
|
|
36 |
'ddd' => 'D',
|
|
|
37 |
// days leading zero
|
|
|
38 |
'dd' => 'd',
|
|
|
39 |
// days no leading zero
|
|
|
40 |
'd' => 'j',
|
|
|
41 |
// fractional seconds - no php equivalent
|
|
|
42 |
'.s' => '',
|
|
|
43 |
];
|
|
|
44 |
|
|
|
45 |
/**
|
|
|
46 |
* Search/replace values to convert Excel date/time format masks hours to PHP format masks (24 hr clock).
|
|
|
47 |
*/
|
|
|
48 |
private const DATE_FORMAT_REPLACEMENTS24 = [
|
|
|
49 |
'hh' => 'H',
|
|
|
50 |
'h' => 'G',
|
|
|
51 |
// month leading zero
|
|
|
52 |
'mm' => 'm',
|
|
|
53 |
// month no leading zero
|
|
|
54 |
'm' => 'n',
|
|
|
55 |
// seconds
|
|
|
56 |
'ss' => 's',
|
|
|
57 |
];
|
|
|
58 |
|
|
|
59 |
/**
|
|
|
60 |
* Search/replace values to convert Excel date/time format masks hours to PHP format masks (12 hr clock).
|
|
|
61 |
*/
|
|
|
62 |
private const DATE_FORMAT_REPLACEMENTS12 = [
|
|
|
63 |
'hh' => 'h',
|
|
|
64 |
'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,
|
|
|
99 |
];
|
|
|
100 |
|
|
|
101 |
private static function tryInterval(bool &$seekingBracket, string &$block, mixed $value, string $format): void
|
|
|
102 |
{
|
|
|
103 |
if ($seekingBracket) {
|
|
|
104 |
if (str_contains($block, $format)) {
|
|
|
105 |
$hours = (string) (int) round(
|
|
|
106 |
self::INTERVAL_MULTIPLIER[$format] * $value,
|
|
|
107 |
self::INTERVAL_ROUND_PRECISION[$format]
|
|
|
108 |
);
|
|
|
109 |
if (strlen($hours) === 1 && in_array($format, self::INTERVAL_LEADING_ZERO, true)) {
|
|
|
110 |
$hours = "0$hours";
|
|
|
111 |
}
|
|
|
112 |
$block = str_replace($format, $hours, $block);
|
|
|
113 |
$seekingBracket = false;
|
|
|
114 |
}
|
|
|
115 |
}
|
|
|
116 |
}
|
|
|
117 |
|
|
|
118 |
/** @param float|int $value value to be formatted */
|
|
|
119 |
public static function format(mixed $value, string $format): string
|
|
|
120 |
{
|
|
|
121 |
// strip off first part containing e.g. [$-F800] or [$USD-409]
|
|
|
122 |
// general syntax: [$<Currency string>-<language info>]
|
|
|
123 |
// language info is in hexadecimal
|
|
|
124 |
// strip off chinese part like [DBNum1][$-804]
|
|
|
125 |
$format = (string) preg_replace('/^(\[DBNum\d\])*(\[\$[^\]]*\])/i', '', $format);
|
|
|
126 |
|
|
|
127 |
// OpenOffice.org uses upper-case number formats, e.g. 'YYYY', convert to lower-case;
|
|
|
128 |
// but we don't want to change any quoted strings
|
|
|
129 |
/** @var callable $callable */
|
|
|
130 |
$callable = [self::class, 'setLowercaseCallback'];
|
|
|
131 |
$format = (string) preg_replace_callback('/(?:^|")([^"]*)(?:$|")/', $callable, $format);
|
|
|
132 |
|
|
|
133 |
// Only process the non-quoted blocks for date format characters
|
|
|
134 |
|
|
|
135 |
$blocks = explode('"', $format);
|
|
|
136 |
foreach ($blocks as $key => &$block) {
|
|
|
137 |
if ($key % 2 == 0) {
|
|
|
138 |
$block = strtr($block, self::DATE_FORMAT_REPLACEMENTS);
|
|
|
139 |
if (!strpos($block, 'A')) {
|
|
|
140 |
// 24-hour time format
|
|
|
141 |
// when [h]:mm format, the [h] should replace to the hours of the value * 24
|
|
|
142 |
$seekingBracket = true;
|
|
|
143 |
self::tryInterval($seekingBracket, $block, $value, '[h]');
|
|
|
144 |
self::tryInterval($seekingBracket, $block, $value, '[hh]');
|
|
|
145 |
self::tryInterval($seekingBracket, $block, $value, '[mm]');
|
|
|
146 |
self::tryInterval($seekingBracket, $block, $value, '[m]');
|
|
|
147 |
self::tryInterval($seekingBracket, $block, $value, '[s]');
|
|
|
148 |
self::tryInterval($seekingBracket, $block, $value, '[ss]');
|
|
|
149 |
$block = strtr($block, self::DATE_FORMAT_REPLACEMENTS24);
|
|
|
150 |
} else {
|
|
|
151 |
// 12-hour time format
|
|
|
152 |
$block = strtr($block, self::DATE_FORMAT_REPLACEMENTS12);
|
|
|
153 |
}
|
|
|
154 |
}
|
|
|
155 |
}
|
|
|
156 |
$format = implode('"', $blocks);
|
|
|
157 |
|
|
|
158 |
// escape any quoted characters so that DateTime format() will render them correctly
|
|
|
159 |
/** @var callable $callback */
|
|
|
160 |
$callback = [self::class, 'escapeQuotesCallback'];
|
|
|
161 |
$format = (string) preg_replace_callback('/"(.*)"/U', $callback, $format);
|
|
|
162 |
|
|
|
163 |
$dateObj = Date::excelToDateTimeObject($value);
|
|
|
164 |
// If the colon preceding minute had been quoted, as happens in
|
|
|
165 |
// Excel 2003 XML formats, m will not have been changed to i above.
|
|
|
166 |
// Change it now.
|
|
|
167 |
$format = (string) \preg_replace('/\\\:m/', ':i', $format);
|
|
|
168 |
$microseconds = (int) $dateObj->format('u');
|
|
|
169 |
if (str_contains($format, ':s.000')) {
|
|
|
170 |
$milliseconds = (int) round($microseconds / 1000.0);
|
|
|
171 |
if ($milliseconds === 1000) {
|
|
|
172 |
$milliseconds = 0;
|
|
|
173 |
$dateObj->modify('+1 second');
|
|
|
174 |
}
|
|
|
175 |
$dateObj->modify("-$microseconds microseconds");
|
|
|
176 |
$format = str_replace(':s.000', ':s.' . sprintf('%03d', $milliseconds), $format);
|
|
|
177 |
} elseif (str_contains($format, ':s.00')) {
|
|
|
178 |
$centiseconds = (int) round($microseconds / 10000.0);
|
|
|
179 |
if ($centiseconds === 100) {
|
|
|
180 |
$centiseconds = 0;
|
|
|
181 |
$dateObj->modify('+1 second');
|
|
|
182 |
}
|
|
|
183 |
$dateObj->modify("-$microseconds microseconds");
|
|
|
184 |
$format = str_replace(':s.00', ':s.' . sprintf('%02d', $centiseconds), $format);
|
|
|
185 |
} elseif (str_contains($format, ':s.0')) {
|
|
|
186 |
$deciseconds = (int) round($microseconds / 100000.0);
|
|
|
187 |
if ($deciseconds === 10) {
|
|
|
188 |
$deciseconds = 0;
|
|
|
189 |
$dateObj->modify('+1 second');
|
|
|
190 |
}
|
|
|
191 |
$dateObj->modify("-$microseconds microseconds");
|
|
|
192 |
$format = str_replace(':s.0', ':s.' . sprintf('%1d', $deciseconds), $format);
|
|
|
193 |
} else { // no fractional second
|
|
|
194 |
if ($microseconds >= 500000) {
|
|
|
195 |
$dateObj->modify('+1 second');
|
|
|
196 |
}
|
|
|
197 |
$dateObj->modify("-$microseconds microseconds");
|
|
|
198 |
}
|
|
|
199 |
|
|
|
200 |
return $dateObj->format($format);
|
|
|
201 |
}
|
|
|
202 |
|
|
|
203 |
private static function setLowercaseCallback(array $matches): string
|
|
|
204 |
{
|
|
|
205 |
return mb_strtolower($matches[0]);
|
|
|
206 |
}
|
|
|
207 |
|
|
|
208 |
private static function escapeQuotesCallback(array $matches): string
|
|
|
209 |
{
|
|
|
210 |
return '\\' . implode('\\', mb_str_split($matches[1], 1, 'UTF-8'));
|
|
|
211 |
}
|
|
|
212 |
}
|