Skip to content

Commit 7fe5146

Browse files
committed
Fix TEXT and TIMEVALUE Functions 3.9 Branch
Fix PHPOffice#4249. Technically speaking, only the 1.29 branch needs fixing, and only for TEXT. It was fixed for the other branches by PR PHPOffice#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 e586f9d commit 7fe5146

File tree

4 files changed

+84
-10
lines changed

4 files changed

+84
-10
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com)
66
and this project adheres to [Semantic Versioning](https://semver.org).
77

8+
# TBD - 3.9.2
9+
10+
### Fixed
11+
12+
- TEXT and TIMEVALUE functions. [Issue #4249](https://github.com/PHPOffice/PhpSpreadsheet/issues/4249) [PR #4355](https://github.com/PHPOffice/PhpSpreadsheet/pull/4355)
13+
814
## 2025-02-07 - 3.9.1
915

1016
### Fixed

src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeValue.php

+24-7
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

+4-3
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

+50
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)