\IntlDateFormatter::NONE, 'short' => \IntlDateFormatter::SHORT, 'medium' => \IntlDateFormatter::MEDIUM, 'long' => \IntlDateFormatter::LONG, 'full' => \IntlDateFormatter::FULL, ]; // Assuming that each `RELATIVE_*` constant are defined when one of them is. if (\defined('IntlDateFormatter::RELATIVE_FULL')) { $formats = array_merge($formats, [ 'relative_short' => \IntlDateFormatter::RELATIVE_SHORT, 'relative_medium' => \IntlDateFormatter::RELATIVE_MEDIUM, 'relative_long' => \IntlDateFormatter::RELATIVE_LONG, 'relative_full' => \IntlDateFormatter::RELATIVE_FULL, ]); } return $formats; } private static function availableListTypes(): array { static $types = null; if (null !== $types) { return $types; } $types = [ 'and' => \IntlListFormatter::TYPE_AND, 'or' => \IntlListFormatter::TYPE_OR, 'units' => \IntlListFormatter::TYPE_UNITS, ]; return $types; } private static function availableListWidths(): array { static $widths = null; if (null !== $widths) { return $widths; } $widths = [ 'wide' => \IntlListFormatter::WIDTH_WIDE, 'short' => \IntlListFormatter::WIDTH_SHORT, 'narrow' => \IntlListFormatter::WIDTH_NARROW, ]; return $widths; } private const TIME_FORMATS = [ 'none' => \IntlDateFormatter::NONE, 'short' => \IntlDateFormatter::SHORT, 'medium' => \IntlDateFormatter::MEDIUM, 'long' => \IntlDateFormatter::LONG, 'full' => \IntlDateFormatter::FULL, ]; private const NUMBER_TYPES = [ 'default' => \NumberFormatter::TYPE_DEFAULT, 'int32' => \NumberFormatter::TYPE_INT32, 'int64' => \NumberFormatter::TYPE_INT64, 'double' => \NumberFormatter::TYPE_DOUBLE, ]; private const NUMBER_STYLES = [ 'decimal' => \NumberFormatter::DECIMAL, 'currency' => \NumberFormatter::CURRENCY, 'percent' => \NumberFormatter::PERCENT, 'scientific' => \NumberFormatter::SCIENTIFIC, 'spellout' => \NumberFormatter::SPELLOUT, 'ordinal' => \NumberFormatter::ORDINAL, 'duration' => \NumberFormatter::DURATION, ]; private const NUMBER_ATTRIBUTES = [ 'grouping_used' => \NumberFormatter::GROUPING_USED, 'decimal_always_shown' => \NumberFormatter::DECIMAL_ALWAYS_SHOWN, 'max_integer_digit' => \NumberFormatter::MAX_INTEGER_DIGITS, 'min_integer_digit' => \NumberFormatter::MIN_INTEGER_DIGITS, 'integer_digit' => \NumberFormatter::INTEGER_DIGITS, 'max_fraction_digit' => \NumberFormatter::MAX_FRACTION_DIGITS, 'min_fraction_digit' => \NumberFormatter::MIN_FRACTION_DIGITS, 'fraction_digit' => \NumberFormatter::FRACTION_DIGITS, 'multiplier' => \NumberFormatter::MULTIPLIER, 'grouping_size' => \NumberFormatter::GROUPING_SIZE, 'rounding_mode' => \NumberFormatter::ROUNDING_MODE, 'rounding_increment' => \NumberFormatter::ROUNDING_INCREMENT, 'format_width' => \NumberFormatter::FORMAT_WIDTH, 'padding_position' => \NumberFormatter::PADDING_POSITION, 'secondary_grouping_size' => \NumberFormatter::SECONDARY_GROUPING_SIZE, 'significant_digits_used' => \NumberFormatter::SIGNIFICANT_DIGITS_USED, 'min_significant_digits_used' => \NumberFormatter::MIN_SIGNIFICANT_DIGITS, 'max_significant_digits_used' => \NumberFormatter::MAX_SIGNIFICANT_DIGITS, 'lenient_parse' => \NumberFormatter::LENIENT_PARSE, ]; private const NUMBER_ROUNDING_ATTRIBUTES = [ 'ceiling' => \NumberFormatter::ROUND_CEILING, 'floor' => \NumberFormatter::ROUND_FLOOR, 'down' => \NumberFormatter::ROUND_DOWN, 'up' => \NumberFormatter::ROUND_UP, 'halfeven' => \NumberFormatter::ROUND_HALFEVEN, 'halfdown' => \NumberFormatter::ROUND_HALFDOWN, 'halfup' => \NumberFormatter::ROUND_HALFUP, ]; private const NUMBER_PADDING_ATTRIBUTES = [ 'before_prefix' => \NumberFormatter::PAD_BEFORE_PREFIX, 'after_prefix' => \NumberFormatter::PAD_AFTER_PREFIX, 'before_suffix' => \NumberFormatter::PAD_BEFORE_SUFFIX, 'after_suffix' => \NumberFormatter::PAD_AFTER_SUFFIX, ]; private const NUMBER_TEXT_ATTRIBUTES = [ 'positive_prefix' => \NumberFormatter::POSITIVE_PREFIX, 'positive_suffix' => \NumberFormatter::POSITIVE_SUFFIX, 'negative_prefix' => \NumberFormatter::NEGATIVE_PREFIX, 'negative_suffix' => \NumberFormatter::NEGATIVE_SUFFIX, 'padding_character' => \NumberFormatter::PADDING_CHARACTER, 'currency_code' => \NumberFormatter::CURRENCY_CODE, 'default_ruleset' => \NumberFormatter::DEFAULT_RULESET, 'public_rulesets' => \NumberFormatter::PUBLIC_RULESETS, ]; private const NUMBER_SYMBOLS = [ 'decimal_separator' => \NumberFormatter::DECIMAL_SEPARATOR_SYMBOL, 'grouping_separator' => \NumberFormatter::GROUPING_SEPARATOR_SYMBOL, 'pattern_separator' => \NumberFormatter::PATTERN_SEPARATOR_SYMBOL, 'percent' => \NumberFormatter::PERCENT_SYMBOL, 'zero_digit' => \NumberFormatter::ZERO_DIGIT_SYMBOL, 'digit' => \NumberFormatter::DIGIT_SYMBOL, 'minus_sign' => \NumberFormatter::MINUS_SIGN_SYMBOL, 'plus_sign' => \NumberFormatter::PLUS_SIGN_SYMBOL, 'currency' => \NumberFormatter::CURRENCY_SYMBOL, 'intl_currency' => \NumberFormatter::INTL_CURRENCY_SYMBOL, 'monetary_separator' => \NumberFormatter::MONETARY_SEPARATOR_SYMBOL, 'exponential' => \NumberFormatter::EXPONENTIAL_SYMBOL, 'permill' => \NumberFormatter::PERMILL_SYMBOL, 'pad_escape' => \NumberFormatter::PAD_ESCAPE_SYMBOL, 'infinity' => \NumberFormatter::INFINITY_SYMBOL, 'nan' => \NumberFormatter::NAN_SYMBOL, 'significant_digit' => \NumberFormatter::SIGNIFICANT_DIGIT_SYMBOL, 'monetary_grouping_separator' => \NumberFormatter::MONETARY_GROUPING_SEPARATOR_SYMBOL, ]; private const MAX_CACHED_FORMATTERS = 100; private $dateFormatters = []; private $numberFormatters = []; private $listFormatters = []; private $dateFormatterPrototype; private $numberFormatterPrototype; public function __construct(?\IntlDateFormatter $dateFormatterPrototype = null, ?\NumberFormatter $numberFormatterPrototype = null) { $this->dateFormatterPrototype = $dateFormatterPrototype; $this->numberFormatterPrototype = $numberFormatterPrototype; } public function getFilters(): array { return [ // internationalized names new TwigFilter('country_name', [$this, 'getCountryName']), new TwigFilter('currency_name', [$this, 'getCurrencyName']), new TwigFilter('currency_symbol', [$this, 'getCurrencySymbol']), new TwigFilter('language_name', [$this, 'getLanguageName']), new TwigFilter('locale_name', [$this, 'getLocaleName']), new TwigFilter('timezone_name', [$this, 'getTimezoneName']), // localized formatters new TwigFilter('format_currency', [$this, 'formatCurrency']), new TwigFilter('format_number', [$this, 'formatNumber']), new TwigFilter('format_*_number', [$this, 'formatNumberStyle']), new TwigFilter('format_datetime', [$this, 'formatDateTime'], ['needs_environment' => true]), new TwigFilter('format_date', [$this, 'formatDate'], ['needs_environment' => true]), new TwigFilter('format_time', [$this, 'formatTime'], ['needs_environment' => true]), new TwigFilter('format_list', [$this, 'formatList']), ]; } public function getFunctions(): array { return [ // internationalized names new TwigFunction('country_timezones', [$this, 'getCountryTimezones']), new TwigFunction('language_names', [$this, 'getLanguageNames']), new TwigFunction('script_names', [$this, 'getScriptNames']), new TwigFunction('country_names', [$this, 'getCountryNames']), new TwigFunction('locale_names', [$this, 'getLocaleNames']), new TwigFunction('currency_names', [$this, 'getCurrencyNames']), new TwigFunction('timezone_names', [$this, 'getTimezoneNames']), ]; } public function getCountryName(?string $country, ?string $locale = null): string { if (null === $country) { return ''; } try { return Countries::getName($country, $locale); } catch (MissingResourceException $exception) { return $country; } } public function getCurrencyName(?string $currency, ?string $locale = null): string { if (null === $currency) { return ''; } try { return Currencies::getName($currency, $locale); } catch (MissingResourceException $exception) { return $currency; } } public function getCurrencySymbol(?string $currency, ?string $locale = null): string { if (null === $currency) { return ''; } try { return Currencies::getSymbol($currency, $locale); } catch (MissingResourceException $exception) { return $currency; } } public function getLanguageName(?string $language, ?string $locale = null): string { if (null === $language) { return ''; } try { return Languages::getName($language, $locale); } catch (MissingResourceException $exception) { return $language; } } public function getLocaleName(?string $data, ?string $locale = null): string { if (null === $data) { return ''; } try { return Locales::getName($data, $locale); } catch (MissingResourceException $exception) { return $data; } } public function getTimezoneName(?string $timezone, ?string $locale = null): string { if (null === $timezone) { return ''; } try { return Timezones::getName($timezone, $locale); } catch (MissingResourceException $exception) { return $timezone; } } public function getCountryTimezones(string $country): array { try { return Timezones::forCountryCode($country); } catch (MissingResourceException $exception) { return []; } } public function getLanguageNames(?string $locale = null): array { try { return Languages::getNames($locale); } catch (MissingResourceException $exception) { return []; } } public function getScriptNames(?string $locale = null): array { try { return Scripts::getNames($locale); } catch (MissingResourceException $exception) { return []; } } public function getCountryNames(?string $locale = null): array { try { return Countries::getNames($locale); } catch (MissingResourceException $exception) { return []; } } public function getLocaleNames(?string $locale = null): array { try { return Locales::getNames($locale); } catch (MissingResourceException $exception) { return []; } } public function getCurrencyNames(?string $locale = null): array { try { return Currencies::getNames($locale); } catch (MissingResourceException $exception) { return []; } } public function getTimezoneNames(?string $locale = null): array { try { return Timezones::getNames($locale); } catch (MissingResourceException $exception) { return []; } } public function formatCurrency($amount, string $currency, array $attrs = [], ?string $locale = null): string { $formatter = $this->createNumberFormatter($locale, 'currency', $attrs); if (false === $ret = $formatter->formatCurrency($amount, $currency)) { throw new RuntimeError('Unable to format the given number as a currency.'); } return $ret; } public function formatNumber($number, array $attrs = [], string $style = 'decimal', string $type = 'default', ?string $locale = null): string { if (!isset(self::NUMBER_TYPES[$type])) { throw new RuntimeError(\sprintf('The type "%s" does not exist, known types are: "%s".', $type, implode('", "', array_keys(self::NUMBER_TYPES)))); } $formatter = $this->createNumberFormatter($locale, $style, $attrs); if (false === $ret = $formatter->format($number, self::NUMBER_TYPES[$type])) { throw new RuntimeError('Unable to format the given number.'); } return $ret; } public function formatNumberStyle(string $style, $number, array $attrs = [], string $type = 'default', ?string $locale = null): string { return $this->formatNumber($number, $attrs, $style, $type, $locale); } /** * @param \DateTimeInterface|string|null $date A date or null to use the current time * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged */ public function formatDateTime(Environment $env, $date, ?string $dateFormat = null, ?string $timeFormat = null, string $pattern = '', $timezone = null, ?string $calendar = null, ?string $locale = null): string { $date = $env->getExtension(CoreExtension::class)->convertDate($date, $timezone); $formatterTimezone = $timezone; if (null === $formatterTimezone || false === $formatterTimezone) { $formatterTimezone = $date->getTimezone(); } elseif (\is_string($formatterTimezone)) { $formatterTimezone = new \DateTimeZone($timezone); } $formatter = $this->createDateFormatter($locale, $dateFormat, $timeFormat, $pattern, $formatterTimezone, $calendar); if (false === $ret = $formatter->format($date)) { throw new RuntimeError('Unable to format the given date.'); } return $ret; } /** * @param \DateTimeInterface|string|null $date A date or null to use the current time * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged */ public function formatDate(Environment $env, $date, ?string $dateFormat = null, string $pattern = '', $timezone = null, ?string $calendar = null, ?string $locale = null): string { return $this->formatDateTime($env, $date, $dateFormat, 'none', $pattern, $timezone, $calendar, $locale); } /** * @param \DateTimeInterface|string|null $date A date or null to use the current time * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged */ public function formatTime(Environment $env, $date, ?string $timeFormat = null, string $pattern = '', $timezone = null, ?string $calendar = null, ?string $locale = null): string { return $this->formatDateTime($env, $date, 'none', $timeFormat, $pattern, $timezone, $calendar, $locale); } /** * @param array $strings A list of items to be joined into a formatted list */ public function formatList(array $strings, string $type = 'and', string $width = 'wide', ?string $locale = null): string { if (!class_exists('IntlListFormatter')) { throw new RuntimeError('The "format_list" filter requires the "IntlListFormatter" class, which is available since PHP 8.5.'); } $formatter = $this->createListFormatter($locale, $type, $width); if (false === $ret = $formatter->format($strings)) { throw new RuntimeError(\sprintf('Unable to format the given list: %s', $formatter->getErrorMessage())); } return $ret; } private function createDateFormatter(?string $locale, ?string $dateFormat, ?string $timeFormat, string $pattern, ?\DateTimeZone $timezone, ?string $calendar): \IntlDateFormatter { $dateFormats = self::availableDateFormats(); if (null !== $dateFormat && !isset($dateFormats[$dateFormat])) { throw new RuntimeError(\sprintf('The date format "%s" does not exist, known formats are: "%s".', $dateFormat, implode('", "', array_keys($dateFormats)))); } if (null !== $timeFormat && !isset(self::TIME_FORMATS[$timeFormat])) { throw new RuntimeError(\sprintf('The time format "%s" does not exist, known formats are: "%s".', $timeFormat, implode('", "', array_keys(self::TIME_FORMATS)))); } if (null === $locale) { if ($this->dateFormatterPrototype) { $locale = $this->dateFormatterPrototype->getLocale(); } $locale = $locale ?: \Locale::getDefault(); } $calendar = null === $calendar ? null : ('gregorian' === $calendar ? \IntlDateFormatter::GREGORIAN : \IntlDateFormatter::TRADITIONAL); $dateFormatValue = null === $dateFormat ? null : $dateFormats[$dateFormat]; $timeFormatValue = null === $timeFormat ? null : self::TIME_FORMATS[$timeFormat]; if ($this->dateFormatterPrototype) { if (null === $dateFormatValue && false !== $prototypeDateFormat = $this->dateFormatterPrototype->getDateType()) { $dateFormatValue = $prototypeDateFormat; } if (null === $timeFormatValue && false !== $prototypeTimeFormat = $this->dateFormatterPrototype->getTimeType()) { $timeFormatValue = $prototypeTimeFormat; } $timezone = $timezone ?: $this->dateFormatterPrototype->getTimeZone()->toDateTimeZone(); if (null === $calendar) { $calendar = $this->dateFormatterPrototype->getCalendar(); if (false === $calendar) { $calendar = $this->dateFormatterPrototype->getCalendarObject(); } } // fall back to the prototype's pattern only when nothing else was given, else it would override the explicit date/time formats; // a pattern describes a full datetime rendering, so it cannot be honored by format_date/format_time, which pass 'none' for the other part if ('' === $pattern && null === $dateFormat && null === $timeFormat) { $pattern = $this->dateFormatterPrototype->getPattern(); } } $dateFormatValue ??= \IntlDateFormatter::MEDIUM; $timeFormatValue ??= \IntlDateFormatter::MEDIUM; if (null === $calendar || false === $calendar) { $calendar = \IntlDateFormatter::GREGORIAN; } if ($calendar instanceof \IntlCalendar) { return new \IntlDateFormatter($locale, $dateFormatValue, $timeFormatValue, $timezone, $calendar, $pattern); } $timezoneName = $timezone ? $timezone->getName() : '(none)'; $hash = $locale.'|'.$dateFormatValue.'|'.$timeFormatValue.'|'.$timezoneName.'|'.$calendar.'|'.$pattern; if (!isset($this->dateFormatters[$hash])) { if (\count($this->dateFormatters) >= self::MAX_CACHED_FORMATTERS) { array_shift($this->dateFormatters); } $this->dateFormatters[$hash] = new \IntlDateFormatter($locale, $dateFormatValue, $timeFormatValue, $timezone, $calendar, $pattern); } return $this->dateFormatters[$hash]; } private function createNumberFormatter(?string $locale, string $style, array $attrs = []): \NumberFormatter { if (!isset(self::NUMBER_STYLES[$style])) { throw new RuntimeError(\sprintf('The style "%s" does not exist, known styles are: "%s".', $style, implode('", "', array_keys(self::NUMBER_STYLES)))); } if (null === $locale) { $locale = \Locale::getDefault(); } // textAttrs and symbols can only be set on the prototype as there is probably no // use case for setting it on each call. $textAttrs = []; $symbols = []; if ($this->numberFormatterPrototype) { foreach (self::NUMBER_ATTRIBUTES as $name => $const) { if (!isset($attrs[$name])) { $value = $this->numberFormatterPrototype->getAttribute($const); if ('rounding_mode' === $name) { $value = array_flip(self::NUMBER_ROUNDING_ATTRIBUTES)[$value]; } elseif ('padding_position' === $name) { $value = array_flip(self::NUMBER_PADDING_ATTRIBUTES)[$value]; } $attrs[$name] = $value; } } foreach (self::NUMBER_TEXT_ATTRIBUTES as $name => $const) { $textAttrs[$name] = $this->numberFormatterPrototype->getTextAttribute($const); } foreach (self::NUMBER_SYMBOLS as $name => $const) { $symbols[$name] = $this->numberFormatterPrototype->getSymbol($const); } } ksort($attrs); $hash = $locale.'|'.$style.'|'.json_encode($attrs).'|'.json_encode($textAttrs).'|'.json_encode($symbols); if (!isset($this->numberFormatters[$hash])) { if (\count($this->numberFormatters) >= self::MAX_CACHED_FORMATTERS) { array_shift($this->numberFormatters); } $this->numberFormatters[$hash] = new \NumberFormatter($locale, self::NUMBER_STYLES[$style]); } foreach ($attrs as $name => $value) { if (!isset(self::NUMBER_ATTRIBUTES[$name])) { throw new RuntimeError(\sprintf('The number formatter attribute "%s" does not exist, known attributes are: "%s".', $name, implode('", "', array_keys(self::NUMBER_ATTRIBUTES)))); } if ('rounding_mode' === $name) { if (!isset(self::NUMBER_ROUNDING_ATTRIBUTES[$value])) { throw new RuntimeError(\sprintf('The number formatter rounding mode "%s" does not exist, known modes are: "%s".', $value, implode('", "', array_keys(self::NUMBER_ROUNDING_ATTRIBUTES)))); } $value = self::NUMBER_ROUNDING_ATTRIBUTES[$value]; } elseif ('padding_position' === $name) { if (!isset(self::NUMBER_PADDING_ATTRIBUTES[$value])) { throw new RuntimeError(\sprintf('The number formatter padding position "%s" does not exist, known positions are: "%s".', $value, implode('", "', array_keys(self::NUMBER_PADDING_ATTRIBUTES)))); } $value = self::NUMBER_PADDING_ATTRIBUTES[$value]; } $this->numberFormatters[$hash]->setAttribute(self::NUMBER_ATTRIBUTES[$name], $value); } foreach ($textAttrs as $name => $value) { $this->numberFormatters[$hash]->setTextAttribute(self::NUMBER_TEXT_ATTRIBUTES[$name], $value); } foreach ($symbols as $name => $value) { $this->numberFormatters[$hash]->setSymbol(self::NUMBER_SYMBOLS[$name], $value); } return $this->numberFormatters[$hash]; } private function createListFormatter(?string $locale, string $type, string $width): \IntlListFormatter { $listTypes = self::availableListTypes(); if (!isset($listTypes[$type])) { throw new RuntimeError(\sprintf('The list type "%s" does not exist, known types are: "%s".', $type, implode('", "', array_keys($listTypes)))); } $listWidths = self::availableListWidths(); if (!isset($listWidths[$width])) { throw new RuntimeError(\sprintf('The list width "%s" does not exist, known widths are: "%s".', $width, implode('", "', array_keys($listWidths)))); } if (null === $locale) { $locale = \Locale::getDefault(); } $listTypeValue = $listTypes[$type]; $listWidthValue = $listWidths[$width]; $hash = $locale.'|'.$listTypeValue.'|'.$listWidthValue; if (!isset($this->listFormatters[$hash])) { if (\count($this->listFormatters) >= self::MAX_CACHED_FORMATTERS) { array_shift($this->listFormatters); } $this->listFormatters[$hash] = new \IntlListFormatter($locale, $listTypeValue, $listWidthValue); } return $this->listFormatters[$hash]; } }