diff --git a/CHANGELOG.md b/CHANGELOG.md index d2aa5101eb..5544f84722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com) and this project adheres to [Semantic Versioning](https://semver.org). +# TBD - 2.1.10 + +### Fixed + +- TEXT and TIMEVALUE functions. [Issue #4249](https://github.com/PHPOffice/PhpSpreadsheet/issues/4249) [PR #4353](https://github.com/PHPOffice/PhpSpreadsheet/pull/4353) + # 2025-02-07 - 2.1.9 ### Fixed diff --git a/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeValue.php b/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeValue.php index f744e30694..b00aba7b6a 100644 --- a/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeValue.php +++ b/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeValue.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\DateTimeExcel; +use Composer\Pcre\Preg; use Datetime; use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled; use PhpOffice\PhpSpreadsheet\Calculation\Functions; @@ -12,6 +13,19 @@ class TimeValue { use ArrayEnabled; + private const EXTRACT_TIME = '/\b' + . '(\d+)' // match[1] - hour + . '(:' // start of match[2] (rest of string) - colon + . '(\d+' // start of match[3] - minute + . '(:\d+' // start of match[4] - colon and seconds + . '([.]\d+)?' // match[5] - optional decimal point followed by fractional seconds + . ')?' // end of match[4], which is optional + . ')' // end of match 3 + // Excel does not require 'm' to trail 'a' or 'p'; Php does + . '(\s*(a|p))?' // match[6] optional whitespace followed by optional match[7] a or p + . ')' // end of match[2] + . '/i'; + /** * TIMEVALUE. * @@ -43,17 +57,20 @@ public static function fromString(null|array|string|int|bool|float $timeValue): } // try to parse as time iff there is at least one digit - if (is_string($timeValue) && preg_match('/\\d/', $timeValue) !== 1) { + if (is_string($timeValue) && !Preg::isMatch('/\d/', $timeValue)) { return ExcelError::VALUE(); } $timeValue = trim((string) $timeValue, '"'); - $timeValue = str_replace(['/', '.'], '-', $timeValue); - - $arraySplit = preg_split('/[\/:\-\s]/', $timeValue) ?: []; - if ((count($arraySplit) == 2 || count($arraySplit) == 3) && $arraySplit[0] > 24) { - $arraySplit[0] = ($arraySplit[0] % 24); // @phpstan-ignore-line - $timeValue = implode(':', $arraySplit); + if (Preg::isMatch(self::EXTRACT_TIME, $timeValue, $matches)) { + if (empty($matches[6])) { // am/pm + $hour = (int) $matches[1]; + $timeValue = ($hour % 24) . $matches[2]; + } elseif ($matches[6] === $matches[7]) { // Excel wants space before am/pm + return ExcelError::VALUE(); + } else { + $timeValue = $matches[0] . 'm'; + } } $PHPDateArray = Helpers::dateParse($timeValue); diff --git a/src/PhpSpreadsheet/Calculation/TextData/Format.php b/src/PhpSpreadsheet/Calculation/TextData/Format.php index 40335ced73..0f2d69f5f6 100644 --- a/src/PhpSpreadsheet/Calculation/TextData/Format.php +++ b/src/PhpSpreadsheet/Calculation/TextData/Format.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\TextData; +use Composer\Pcre\Preg; use DateTimeInterface; use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; @@ -127,11 +128,11 @@ public static function TEXTFORMAT(mixed $value, mixed $format): array|string $format = Helpers::extractString($format); $format = (string) NumberFormat::convertSystemFormats($format); - if (!is_numeric($value) && Date::isDateTimeFormatCode($format)) { + if (!is_numeric($value) && Date::isDateTimeFormatCode($format) && !Preg::isMatch('/^\s*\d+(\s+\d+)+\s*$/', $value)) { $value1 = DateTimeExcel\DateValue::fromString($value); $value2 = DateTimeExcel\TimeValue::fromString($value); /** @var float|int|string */ - $value = (is_numeric($value1) && is_numeric($value2)) ? ($value1 + $value2) : (is_numeric($value1) ? $value2 : $value1); + $value = (is_numeric($value1) && is_numeric($value2)) ? ($value1 + $value2) : (is_numeric($value1) ? $value1 : (is_numeric($value2) ? $value2 : $value)); } return (string) NumberFormat::toFormattedString($value, $format); @@ -284,7 +285,7 @@ public static function NUMBERVALUE(mixed $value = '', mixed $decimalSeparator = } if (!is_numeric($value)) { - $decimalPositions = preg_match_all('/' . preg_quote($decimalSeparator, '/') . '/', $value, $matches, PREG_OFFSET_CAPTURE); + $decimalPositions = Preg::matchAllWithOffsets('/' . preg_quote($decimalSeparator, '/') . '/', $value, $matches); if ($decimalPositions > 1) { return ExcelError::VALUE(); } diff --git a/tests/data/Calculation/TextData/TEXT.php b/tests/data/Calculation/TextData/TEXT.php index bf6474a281..1eee828144 100644 --- a/tests/data/Calculation/TextData/TEXT.php +++ b/tests/data/Calculation/TextData/TEXT.php @@ -73,6 +73,56 @@ '2014-02-15 16:17', 'dd-mmm-yyyy HH:MM:SS AM/PM', ], + 'datetime integer' => [ + '1900-01-06 00:00', + 6, + 'yyyy-mm-dd hh:mm', + ], + 'datetime integer as string' => [ + '1900-01-06 00:00', + '6', + 'yyyy-mm-dd hh:mm', + ], + 'datetime 2 integers without date delimiters' => [ + '5 6', + '5 6', + 'yyyy-mm-dd hh:mm', + ], + 'datetime 2 integers separated by hyphen' => [ + (new DateTimeImmutable())->format('Y') . '-05-13 00:00', + '5-13', + 'yyyy-mm-dd hh:mm', + ], + 'datetime string date only' => [ + '1951-01-23 00:00', + 'January 23, 1951', + 'yyyy-mm-dd hh:mm', + ], + 'datetime string time followed by date' => [ + '1952-05-02 03:54', + '3:54 May 2, 1952', + 'yyyy-mm-dd hh:mm', + ], + 'datetime string date followed by time pm' => [ + '1952-05-02 15:54', + 'May 2, 1952 3:54 pm', + 'yyyy-mm-dd hh:mm', + ], + 'datetime string date followed by time p' => [ + '1952-05-02 15:54', + 'May 2, 1952 3:54 p', + 'yyyy-mm-dd hh:mm', + ], + 'datetime decimal string interpreted as time' => [ + '1900-01-02 12:00', + '2.5', + 'yyyy-mm-dd hh:mm', + ], + 'datetime unparseable string' => [ + 'xyz', + 'xyz', + 'yyyy-mm-dd hh:mm', + ], [ '1 3/4', 1.75,