diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9dc97da8..4cbd9b32 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -4085,36 +4085,6 @@ parameters: count: 1 path: src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalFormattingRuleExtension.php - - - message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\NumberFormat\\\\DateFormatter\\:\\:escapeQuotesCallback\\(\\) has parameter \\$matches with no type specified\\.$#" - count: 1 - path: src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php - - - - message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\NumberFormat\\\\DateFormatter\\:\\:format\\(\\) has parameter \\$value with no type specified\\.$#" - count: 1 - path: src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php - - - - message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\NumberFormat\\\\DateFormatter\\:\\:setLowercaseCallback\\(\\) has parameter \\$matches with no type specified\\.$#" - count: 1 - path: src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php - - - - message: "#^Parameter \\#1 \\$format of method DateTime\\:\\:format\\(\\) expects string, string\\|null given\\.$#" - count: 1 - path: src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php - - - - message: "#^Parameter \\#2 \\$replace of function str_replace expects array\\|string, int given\\.$#" - count: 1 - path: src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php - - - - message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\|string, string\\|null given\\.$#" - count: 1 - path: src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php - - message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\NumberFormat\\\\Formatter\\:\\:splitFormat\\(\\) has no return type specified\\.$#" count: 1 diff --git a/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php b/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php index 32fba53e..5a2009e9 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php +++ b/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php @@ -8,10 +8,8 @@ class DateFormatter { /** * Search/replace values to convert Excel date/time format masks to PHP format masks. - * - * @var array */ - private static $dateFormatReplacements = [ + private const DATE_FORMAT_REPLACEMENTS = [ // first remove escapes related to non-format characters '\\' => '', // 12-hour suffix @@ -32,10 +30,6 @@ class DateFormatter // It isn't perfect, but the best way I know how ':mm' => ':i', 'mm:' => 'i:', - // month leading zero - 'mm' => 'm', - // month no leading zero - 'm' => 'n', // full day of week name 'dddd' => 'l', // short day of week name @@ -44,32 +38,85 @@ class DateFormatter 'dd' => 'd', // days no leading zero 'd' => 'j', - // seconds - 'ss' => 's', // fractional seconds - no php equivalent '.s' => '', ]; /** * Search/replace values to convert Excel date/time format masks hours to PHP format masks (24 hr clock). - * - * @var array */ - private static $dateFormatReplacements24 = [ + private const DATE_FORMAT_REPLACEMENTS24 = [ 'hh' => 'H', 'h' => 'G', + // month leading zero + 'mm' => 'm', + // month no leading zero + 'm' => 'n', + // seconds + 'ss' => 's', ]; /** * Search/replace values to convert Excel date/time format masks hours to PHP format masks (12 hr clock). - * - * @var array */ - private static $dateFormatReplacements12 = [ + private const DATE_FORMAT_REPLACEMENTS12 = [ 'hh' => 'h', 'h' => 'g', + // month leading zero + 'mm' => 'm', + // month no leading zero + 'm' => 'n', + // seconds + 'ss' => 's', ]; + private const HOURS_IN_DAY = 24; + private const MINUTES_IN_DAY = 60 * self::HOURS_IN_DAY; + private const SECONDS_IN_DAY = 60 * self::MINUTES_IN_DAY; + private const INTERVAL_PRECISION = 10; + private const INTERVAL_LEADING_ZERO = [ + '[hh]', + '[mm]', + '[ss]', + ]; + private const INTERVAL_ROUND_PRECISION = [ + // hours and minutes truncate + '[h]' => self::INTERVAL_PRECISION, + '[hh]' => self::INTERVAL_PRECISION, + '[m]' => self::INTERVAL_PRECISION, + '[mm]' => self::INTERVAL_PRECISION, + // seconds round + '[s]' => 0, + '[ss]' => 0, + ]; + private const INTERVAL_MULTIPLIER = [ + '[h]' => self::HOURS_IN_DAY, + '[hh]' => self::HOURS_IN_DAY, + '[m]' => self::MINUTES_IN_DAY, + '[mm]' => self::MINUTES_IN_DAY, + '[s]' => self::SECONDS_IN_DAY, + '[ss]' => self::SECONDS_IN_DAY, + ]; + + /** @param mixed $value */ + private static function tryInterval(bool &$seekingBracket, string &$block, $value, string $format): void + { + if ($seekingBracket) { + if (false !== strpos($block, $format)) { + $hours = (string) (int) round( + self::INTERVAL_MULTIPLIER[$format] * $value, + self::INTERVAL_ROUND_PRECISION[$format] + ); + if (strlen($hours) === 1 && in_array($format, self::INTERVAL_LEADING_ZERO, true)) { + $hours = "0$hours"; + } + $block = str_replace($format, $hours, $block); + $seekingBracket = false; + } + } + } + + /** @param mixed $value */ public static function format($value, string $format): string { // strip off first part containing e.g. [$-F800] or [$USD-409] @@ -90,20 +137,21 @@ class DateFormatter $blocks = explode('"', $format); foreach ($blocks as $key => &$block) { if ($key % 2 == 0) { - $block = strtr($block, self::$dateFormatReplacements); + $block = strtr($block, self::DATE_FORMAT_REPLACEMENTS); if (!strpos($block, 'A')) { // 24-hour time format // when [h]:mm format, the [h] should replace to the hours of the value * 24 - if (false !== strpos($block, '[h]')) { - $hours = (int) ($value * 24); - $block = str_replace('[h]', $hours, $block); - - continue; - } - $block = strtr($block, self::$dateFormatReplacements24); + $seekingBracket = true; + self::tryInterval($seekingBracket, $block, $value, '[h]'); + self::tryInterval($seekingBracket, $block, $value, '[hh]'); + self::tryInterval($seekingBracket, $block, $value, '[mm]'); + self::tryInterval($seekingBracket, $block, $value, '[m]'); + self::tryInterval($seekingBracket, $block, $value, '[s]'); + self::tryInterval($seekingBracket, $block, $value, '[ss]'); + $block = strtr($block, self::DATE_FORMAT_REPLACEMENTS24); } else { // 12-hour time format - $block = strtr($block, self::$dateFormatReplacements12); + $block = strtr($block, self::DATE_FORMAT_REPLACEMENTS12); } } } @@ -112,23 +160,23 @@ class DateFormatter // escape any quoted characters so that DateTime format() will render them correctly /** @var callable */ $callback = ['self', 'escapeQuotesCallback']; - $format = preg_replace_callback('/"(.*)"/U', $callback, $format); + $format = preg_replace_callback('/"(.*)"/U', $callback, $format) ?? ''; $dateObj = Date::excelToDateTimeObject($value); // If the colon preceding minute had been quoted, as happens in // Excel 2003 XML formats, m will not have been changed to i above. // Change it now. - $format = \preg_replace('/\\\\:m/', ':i', $format); + $format = \preg_replace('/\\\\:m/', ':i', $format) ?? ''; return $dateObj->format($format); } - private static function setLowercaseCallback($matches): string + private static function setLowercaseCallback(array $matches): string { return mb_strtolower($matches[0]); } - private static function escapeQuotesCallback($matches): string + private static function escapeQuotesCallback(array $matches): string { return '\\' . implode('\\', str_split($matches[1])); } diff --git a/tests/data/Style/NumberFormatDates.php b/tests/data/Style/NumberFormatDates.php index 331a080c..7b215478 100644 --- a/tests/data/Style/NumberFormatDates.php +++ b/tests/data/Style/NumberFormatDates.php @@ -72,4 +72,94 @@ return [ 12345.6789, '[DBNum3][$-zh-CN]yyyymmdd;@', ], + 'hour with leading 0 and minute' => [ + '03:36', + 1.15, + 'hh:mm', + ], + 'hour without leading 0 and minute' => [ + '3:36', + 1.15, + 'h:mm', + ], + 'hour truncated not rounded' => [ + '27', + 1.15, + '[hh]', + ], + 'interval hour > 10 so no need for leading 0 and minute' => [ + '27:36', + 1.15, + '[hh]:mm', + ], + 'interval hour > 10 no leading 0 and minute' => [ + '27:36', + 1.15, + '[h]:mm', + ], + 'interval hour with leading 0 and minute' => [ + '03:36', + 0.15, + '[hh]:mm', + ], + 'interval hour no leading 0 and minute' => [ + '3:36', + 0.15, + '[h]:mm', + ], + 'interval hours > 100 and minutes no need for leading 0' => [ + '123:36', + 5.15, + '[hh]:mm', + ], + 'interval hours > 100 and minutes no leading 0' => [ + '123:36', + 5.15, + '[h]:mm', + ], + 'interval minutes > 10 no need for leading 0' => [ + '1656', + 1.15, + '[mm]', + ], + 'interval minutes > 10 no leading 0' => [ + '1656', + 1.15, + '[m]', + ], + 'interval minutes < 10 leading 0' => [ + '07', + 0.005, + '[mm]', + ], + 'interval minutes < 10 no leading 0' => [ + '7', + 0.005, + '[m]', + ], + 'interval minutes and seconds' => [ + '07:12', + 0.005, + '[mm]:ss', + ], + 'interval seconds' => [ + '432', + 0.005, + '[ss]', + ], + 'interval seconds rounded up leading 0' => [ + '09', + 0.0001, + '[ss]', + ], + 'interval seconds rounded up no leading 0' => [ + '9', + 0.0001, + '[s]', + ], + 'interval seconds rounded down' => [ + '6', + 0.00007, + '[s]', + ], ];