Skip to content

Commit 04c0a79

Browse files
committed
Fix TEXT and TIMEVALUE Functions Master Branch
Fix #4249. Technically speaking, only the 1.29 branch needs fixing, and only for TEXT. It was fixed for the other branches by PR #3898. However, in adding test cases for the fix, it became apparent that PhpSpreadsheet's parsing in TIMEVALUE (which is called from TEXT in the original issue) did not really match Excel's. There are probably still edge cases where it doesn't, but, in the absence of a spec for how it operates, this will do for now. We do not usually backport fixes from the master branch. Because this is more of a forward port from the earlier branch, there is an equivalent PR for each active branch.
1 parent 9d1ad14 commit 04c0a79

File tree

3 files changed

+78
-10
lines changed

3 files changed

+78
-10
lines changed

src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeValue.php

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PhpOffice\PhpSpreadsheet\Calculation\DateTimeExcel;
44

5+
use Composer\Pcre\Preg;
56
use Datetime;
67
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
78
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
@@ -12,6 +13,19 @@ class TimeValue
1213
{
1314
use ArrayEnabled;
1415

16+
private const EXTRACT_TIME = '/\b'
17+
. '(\d+)' // match[1] - hour
18+
. '(:' // start of match[2] (rest of string) - colon
19+
. '(\d+' // start of match[3] - minute
20+
. '(:\d+' // start of match[4] - colon and seconds
21+
. '([.]\d+)?' // match[5] - optional decimal point followed by fractional seconds
22+
. ')?' // end of match[4], which is optional
23+
. ')' // end of match 3
24+
// Excel does not require 'm' to trail 'a' or 'p'; Php does
25+
. '(\s*(a|p))?' // match[6] optional whitespace followed by optional match[7] a or p
26+
. ')' // end of match[2]
27+
. '/i';
28+
1529
/**
1630
* TIMEVALUE.
1731
*
@@ -43,17 +57,20 @@ public static function fromString(null|array|string|int|bool|float $timeValue):
4357
}
4458

4559
// try to parse as time iff there is at least one digit
46-
if (is_string($timeValue) && preg_match('/\\d/', $timeValue) !== 1) {
60+
if (is_string($timeValue) && !Preg::isMatch('/\d/', $timeValue)) {
4761
return ExcelError::VALUE();
4862
}
4963

5064
$timeValue = trim((string) $timeValue, '"');
51-
$timeValue = str_replace(['/', '.'], '-', $timeValue);
52-
53-
$arraySplit = preg_split('/[\/:\-\s]/', $timeValue) ?: [];
54-
if ((count($arraySplit) == 2 || count($arraySplit) == 3) && $arraySplit[0] > 24) {
55-
$arraySplit[0] = ((int) $arraySplit[0] % 24);
56-
$timeValue = implode(':', $arraySplit);
65+
if (Preg::isMatch(self::EXTRACT_TIME, $timeValue, $matches)) {
66+
if (empty($matches[6])) { // am/pm
67+
$hour = (int) $matches[0];
68+
$timeValue = ($hour % 24) . $matches[2];
69+
} elseif ($matches[6] === $matches[7]) { // Excel wants space before am/pm
70+
return ExcelError::VALUE();
71+
} else {
72+
$timeValue = $matches[0] . 'm';
73+
}
5774
}
5875

5976
$PHPDateArray = Helpers::dateParse($timeValue);

src/PhpSpreadsheet/Calculation/TextData/Format.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PhpOffice\PhpSpreadsheet\Calculation\TextData;
44

5+
use Composer\Pcre\Preg;
56
use DateTimeInterface;
67
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
78
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
@@ -133,11 +134,11 @@ public static function TEXTFORMAT(mixed $value, mixed $format): array|string
133134

134135
$format = (string) NumberFormat::convertSystemFormats($format);
135136

136-
if (!is_numeric($value) && Date::isDateTimeFormatCode($format)) {
137+
if (!is_numeric($value) && Date::isDateTimeFormatCode($format) && !Preg::isMatch('/^\s*\d+(\s+\d+)+\s*$/', $value)) {
137138
$value1 = DateTimeExcel\DateValue::fromString($value);
138139
$value2 = DateTimeExcel\TimeValue::fromString($value);
139140
/** @var float|int|string */
140-
$value = (is_numeric($value1) && is_numeric($value2)) ? ($value1 + $value2) : (is_numeric($value1) ? $value2 : $value1);
141+
$value = (is_numeric($value1) && is_numeric($value2)) ? ($value1 + $value2) : (is_numeric($value1) ? $value1 : (is_numeric($value2) ? $value2 : $value));
141142
}
142143

143144
return (string) NumberFormat::toFormattedString($value, $format);
@@ -293,7 +294,7 @@ public static function NUMBERVALUE(mixed $value = '', mixed $decimalSeparator =
293294
}
294295

295296
if (!is_numeric($value)) {
296-
$decimalPositions = preg_match_all('/' . preg_quote($decimalSeparator, '/') . '/', $value, $matches, PREG_OFFSET_CAPTURE);
297+
$decimalPositions = Preg::matchAllWithOffsets('/' . preg_quote($decimalSeparator, '/') . '/', $value, $matches);
297298
if ($decimalPositions > 1) {
298299
return ExcelError::VALUE();
299300
}

tests/data/Calculation/TextData/TEXT.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,56 @@
7373
'2014-02-15 16:17',
7474
'dd-mmm-yyyy HH:MM:SS AM/PM',
7575
],
76+
'datetime integer' => [
77+
'1900-01-06 00:00',
78+
6,
79+
'yyyy-mm-dd hh:mm',
80+
],
81+
'datetime integer as string' => [
82+
'1900-01-06 00:00',
83+
'6',
84+
'yyyy-mm-dd hh:mm',
85+
],
86+
'datetime 2 integers without date delimiters' => [
87+
'5 6',
88+
'5 6',
89+
'yyyy-mm-dd hh:mm',
90+
],
91+
'datetime 2 integers separated by hyphen' => [
92+
(new DateTimeImmutable())->format('Y') . '-05-13 00:00',
93+
'5-13',
94+
'yyyy-mm-dd hh:mm',
95+
],
96+
'datetime string date only' => [
97+
'1951-01-23 00:00',
98+
'January 23, 1951',
99+
'yyyy-mm-dd hh:mm',
100+
],
101+
'datetime string time followed by date' => [
102+
'1952-05-02 03:54',
103+
'3:54 May 2, 1952',
104+
'yyyy-mm-dd hh:mm',
105+
],
106+
'datetime string date followed by time pm' => [
107+
'1952-05-02 15:54',
108+
'May 2, 1952 3:54 pm',
109+
'yyyy-mm-dd hh:mm',
110+
],
111+
'datetime string date followed by time p' => [
112+
'1952-05-02 15:54',
113+
'May 2, 1952 3:54 p',
114+
'yyyy-mm-dd hh:mm',
115+
],
116+
'datetime decimal string interpreted as time' => [
117+
'1900-01-02 12:00',
118+
'2.5',
119+
'yyyy-mm-dd hh:mm',
120+
],
121+
'datetime unparseable string' => [
122+
'xyz',
123+
'xyz',
124+
'yyyy-mm-dd hh:mm',
125+
],
76126
[
77127
'1 3/4',
78128
1.75,

0 commit comments

Comments
 (0)