Skip to content

Commit 12c2e79

Browse files
authored
Fix TEXT and TIMEVALUE Functions 2.1 Branch (#4353)
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 a3f9ba9 commit 12c2e79

File tree

4 files changed

+84
-10
lines changed

4 files changed

+84
-10
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
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 - 2.1.10
9+
10+
### Fixed
11+
12+
- TEXT and TIMEVALUE functions. [Issue #4249](https://github.com/PHPOffice/PhpSpreadsheet/issues/4249) [PR #4353](https://github.com/PHPOffice/PhpSpreadsheet/pull/4353)
13+
814
# 2025-02-07 - 2.1.9
915

1016
### Fixed

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] = ($arraySplit[0] % 24); // @phpstan-ignore-line
56-
$timeValue = implode(':', $arraySplit);
65+
if (Preg::isMatch(self::EXTRACT_TIME, $timeValue, $matches)) {
66+
if (empty($matches[6])) { // am/pm
67+
$hour = (int) $matches[1];
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;
@@ -127,11 +128,11 @@ public static function TEXTFORMAT(mixed $value, mixed $format): array|string
127128
$format = Helpers::extractString($format);
128129
$format = (string) NumberFormat::convertSystemFormats($format);
129130

130-
if (!is_numeric($value) && Date::isDateTimeFormatCode($format)) {
131+
if (!is_numeric($value) && Date::isDateTimeFormatCode($format) && !Preg::isMatch('/^\s*\d+(\s+\d+)+\s*$/', $value)) {
131132
$value1 = DateTimeExcel\DateValue::fromString($value);
132133
$value2 = DateTimeExcel\TimeValue::fromString($value);
133134
/** @var float|int|string */
134-
$value = (is_numeric($value1) && is_numeric($value2)) ? ($value1 + $value2) : (is_numeric($value1) ? $value2 : $value1);
135+
$value = (is_numeric($value1) && is_numeric($value2)) ? ($value1 + $value2) : (is_numeric($value1) ? $value1 : (is_numeric($value2) ? $value2 : $value));
135136
}
136137

137138
return (string) NumberFormat::toFormattedString($value, $format);
@@ -284,7 +285,7 @@ public static function NUMBERVALUE(mixed $value = '', mixed $decimalSeparator =
284285
}
285286

286287
if (!is_numeric($value)) {
287-
$decimalPositions = preg_match_all('/' . preg_quote($decimalSeparator, '/') . '/', $value, $matches, PREG_OFFSET_CAPTURE);
288+
$decimalPositions = Preg::matchAllWithOffsets('/' . preg_quote($decimalSeparator, '/') . '/', $value, $matches);
288289
if ($decimalPositions > 1) {
289290
return ExcelError::VALUE();
290291
}

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)