vendor/twig/intl-extra/IntlExtension.php line 335

Open in your IDE?
  1. <?php
  2. /*
  3. * This file is part of Twig.
  4. *
  5. * (c) Fabien Potencier
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Twig\Extra\Intl;
  11. use Symfony\Component\Intl\Countries;
  12. use Symfony\Component\Intl\Currencies;
  13. use Symfony\Component\Intl\Exception\MissingResourceException;
  14. use Symfony\Component\Intl\Languages;
  15. use Symfony\Component\Intl\Locales;
  16. use Symfony\Component\Intl\Scripts;
  17. use Symfony\Component\Intl\Timezones;
  18. use Twig\Environment;
  19. use Twig\Error\RuntimeError;
  20. use Twig\Extension\AbstractExtension;
  21. use Twig\Extension\CoreExtension;
  22. use Twig\TwigFilter;
  23. use Twig\TwigFunction;
  24. final class IntlExtension extends AbstractExtension
  25. {
  26. private static function availableDateFormats(): array
  27. {
  28. static $formats = null;
  29. if (null !== $formats) {
  30. return $formats;
  31. }
  32. $formats = [
  33. 'none' => \IntlDateFormatter::NONE,
  34. 'short' => \IntlDateFormatter::SHORT,
  35. 'medium' => \IntlDateFormatter::MEDIUM,
  36. 'long' => \IntlDateFormatter::LONG,
  37. 'full' => \IntlDateFormatter::FULL,
  38. ];
  39. // Assuming that each `RELATIVE_*` constant are defined when one of them is.
  40. if (\defined('IntlDateFormatter::RELATIVE_FULL')) {
  41. $formats = array_merge($formats, [
  42. 'relative_short' => \IntlDateFormatter::RELATIVE_SHORT,
  43. 'relative_medium' => \IntlDateFormatter::RELATIVE_MEDIUM,
  44. 'relative_long' => \IntlDateFormatter::RELATIVE_LONG,
  45. 'relative_full' => \IntlDateFormatter::RELATIVE_FULL,
  46. ]);
  47. }
  48. return $formats;
  49. }
  50. private const TIME_FORMATS = [
  51. 'none' => \IntlDateFormatter::NONE,
  52. 'short' => \IntlDateFormatter::SHORT,
  53. 'medium' => \IntlDateFormatter::MEDIUM,
  54. 'long' => \IntlDateFormatter::LONG,
  55. 'full' => \IntlDateFormatter::FULL,
  56. ];
  57. private const NUMBER_TYPES = [
  58. 'default' => \NumberFormatter::TYPE_DEFAULT,
  59. 'int32' => \NumberFormatter::TYPE_INT32,
  60. 'int64' => \NumberFormatter::TYPE_INT64,
  61. 'double' => \NumberFormatter::TYPE_DOUBLE,
  62. ];
  63. private const NUMBER_STYLES = [
  64. 'decimal' => \NumberFormatter::DECIMAL,
  65. 'currency' => \NumberFormatter::CURRENCY,
  66. 'percent' => \NumberFormatter::PERCENT,
  67. 'scientific' => \NumberFormatter::SCIENTIFIC,
  68. 'spellout' => \NumberFormatter::SPELLOUT,
  69. 'ordinal' => \NumberFormatter::ORDINAL,
  70. 'duration' => \NumberFormatter::DURATION,
  71. ];
  72. private const NUMBER_ATTRIBUTES = [
  73. 'grouping_used' => \NumberFormatter::GROUPING_USED,
  74. 'decimal_always_shown' => \NumberFormatter::DECIMAL_ALWAYS_SHOWN,
  75. 'max_integer_digit' => \NumberFormatter::MAX_INTEGER_DIGITS,
  76. 'min_integer_digit' => \NumberFormatter::MIN_INTEGER_DIGITS,
  77. 'integer_digit' => \NumberFormatter::INTEGER_DIGITS,
  78. 'max_fraction_digit' => \NumberFormatter::MAX_FRACTION_DIGITS,
  79. 'min_fraction_digit' => \NumberFormatter::MIN_FRACTION_DIGITS,
  80. 'fraction_digit' => \NumberFormatter::FRACTION_DIGITS,
  81. 'multiplier' => \NumberFormatter::MULTIPLIER,
  82. 'grouping_size' => \NumberFormatter::GROUPING_SIZE,
  83. 'rounding_mode' => \NumberFormatter::ROUNDING_MODE,
  84. 'rounding_increment' => \NumberFormatter::ROUNDING_INCREMENT,
  85. 'format_width' => \NumberFormatter::FORMAT_WIDTH,
  86. 'padding_position' => \NumberFormatter::PADDING_POSITION,
  87. 'secondary_grouping_size' => \NumberFormatter::SECONDARY_GROUPING_SIZE,
  88. 'significant_digits_used' => \NumberFormatter::SIGNIFICANT_DIGITS_USED,
  89. 'min_significant_digits_used' => \NumberFormatter::MIN_SIGNIFICANT_DIGITS,
  90. 'max_significant_digits_used' => \NumberFormatter::MAX_SIGNIFICANT_DIGITS,
  91. 'lenient_parse' => \NumberFormatter::LENIENT_PARSE,
  92. ];
  93. private const NUMBER_ROUNDING_ATTRIBUTES = [
  94. 'ceiling' => \NumberFormatter::ROUND_CEILING,
  95. 'floor' => \NumberFormatter::ROUND_FLOOR,
  96. 'down' => \NumberFormatter::ROUND_DOWN,
  97. 'up' => \NumberFormatter::ROUND_UP,
  98. 'halfeven' => \NumberFormatter::ROUND_HALFEVEN,
  99. 'halfdown' => \NumberFormatter::ROUND_HALFDOWN,
  100. 'halfup' => \NumberFormatter::ROUND_HALFUP,
  101. ];
  102. private const NUMBER_PADDING_ATTRIBUTES = [
  103. 'before_prefix' => \NumberFormatter::PAD_BEFORE_PREFIX,
  104. 'after_prefix' => \NumberFormatter::PAD_AFTER_PREFIX,
  105. 'before_suffix' => \NumberFormatter::PAD_BEFORE_SUFFIX,
  106. 'after_suffix' => \NumberFormatter::PAD_AFTER_SUFFIX,
  107. ];
  108. private const NUMBER_TEXT_ATTRIBUTES = [
  109. 'positive_prefix' => \NumberFormatter::POSITIVE_PREFIX,
  110. 'positive_suffix' => \NumberFormatter::POSITIVE_SUFFIX,
  111. 'negative_prefix' => \NumberFormatter::NEGATIVE_PREFIX,
  112. 'negative_suffix' => \NumberFormatter::NEGATIVE_SUFFIX,
  113. 'padding_character' => \NumberFormatter::PADDING_CHARACTER,
  114. 'currency_code' => \NumberFormatter::CURRENCY_CODE,
  115. 'default_ruleset' => \NumberFormatter::DEFAULT_RULESET,
  116. 'public_rulesets' => \NumberFormatter::PUBLIC_RULESETS,
  117. ];
  118. private const NUMBER_SYMBOLS = [
  119. 'decimal_separator' => \NumberFormatter::DECIMAL_SEPARATOR_SYMBOL,
  120. 'grouping_separator' => \NumberFormatter::GROUPING_SEPARATOR_SYMBOL,
  121. 'pattern_separator' => \NumberFormatter::PATTERN_SEPARATOR_SYMBOL,
  122. 'percent' => \NumberFormatter::PERCENT_SYMBOL,
  123. 'zero_digit' => \NumberFormatter::ZERO_DIGIT_SYMBOL,
  124. 'digit' => \NumberFormatter::DIGIT_SYMBOL,
  125. 'minus_sign' => \NumberFormatter::MINUS_SIGN_SYMBOL,
  126. 'plus_sign' => \NumberFormatter::PLUS_SIGN_SYMBOL,
  127. 'currency' => \NumberFormatter::CURRENCY_SYMBOL,
  128. 'intl_currency' => \NumberFormatter::INTL_CURRENCY_SYMBOL,
  129. 'monetary_separator' => \NumberFormatter::MONETARY_SEPARATOR_SYMBOL,
  130. 'exponential' => \NumberFormatter::EXPONENTIAL_SYMBOL,
  131. 'permill' => \NumberFormatter::PERMILL_SYMBOL,
  132. 'pad_escape' => \NumberFormatter::PAD_ESCAPE_SYMBOL,
  133. 'infinity' => \NumberFormatter::INFINITY_SYMBOL,
  134. 'nan' => \NumberFormatter::NAN_SYMBOL,
  135. 'significant_digit' => \NumberFormatter::SIGNIFICANT_DIGIT_SYMBOL,
  136. 'monetary_grouping_separator' => \NumberFormatter::MONETARY_GROUPING_SEPARATOR_SYMBOL,
  137. ];
  138. private $dateFormatters = [];
  139. private $numberFormatters = [];
  140. private $dateFormatterPrototype;
  141. private $numberFormatterPrototype;
  142. public function __construct(?\IntlDateFormatter $dateFormatterPrototype = null, ?\NumberFormatter $numberFormatterPrototype = null)
  143. {
  144. $this->dateFormatterPrototype = $dateFormatterPrototype;
  145. $this->numberFormatterPrototype = $numberFormatterPrototype;
  146. }
  147. public function getFilters(): array
  148. {
  149. return [
  150. // internationalized names
  151. new TwigFilter('country_name', [$this, 'getCountryName']),
  152. new TwigFilter('currency_name', [$this, 'getCurrencyName']),
  153. new TwigFilter('currency_symbol', [$this, 'getCurrencySymbol']),
  154. new TwigFilter('language_name', [$this, 'getLanguageName']),
  155. new TwigFilter('locale_name', [$this, 'getLocaleName']),
  156. new TwigFilter('timezone_name', [$this, 'getTimezoneName']),
  157. // localized formatters
  158. new TwigFilter('format_currency', [$this, 'formatCurrency']),
  159. new TwigFilter('format_number', [$this, 'formatNumber']),
  160. new TwigFilter('format_*_number', [$this, 'formatNumberStyle']),
  161. new TwigFilter('format_datetime', [$this, 'formatDateTime'], ['needs_environment' => true]),
  162. new TwigFilter('format_date', [$this, 'formatDate'], ['needs_environment' => true]),
  163. new TwigFilter('format_time', [$this, 'formatTime'], ['needs_environment' => true]),
  164. ];
  165. }
  166. public function getFunctions(): array
  167. {
  168. return [
  169. // internationalized names
  170. new TwigFunction('country_timezones', [$this, 'getCountryTimezones']),
  171. new TwigFunction('language_names', [$this, 'getLanguageNames']),
  172. new TwigFunction('script_names', [$this, 'getScriptNames']),
  173. new TwigFunction('country_names', [$this, 'getCountryNames']),
  174. new TwigFunction('locale_names', [$this, 'getLocaleNames']),
  175. new TwigFunction('currency_names', [$this, 'getCurrencyNames']),
  176. new TwigFunction('timezone_names', [$this, 'getTimezoneNames']),
  177. ];
  178. }
  179. public function getCountryName(?string $country, ?string $locale = null): string
  180. {
  181. if (null === $country) {
  182. return '';
  183. }
  184. try {
  185. return Countries::getName($country, $locale);
  186. } catch (MissingResourceException $exception) {
  187. return $country;
  188. }
  189. }
  190. public function getCurrencyName(?string $currency, ?string $locale = null): string
  191. {
  192. if (null === $currency) {
  193. return '';
  194. }
  195. try {
  196. return Currencies::getName($currency, $locale);
  197. } catch (MissingResourceException $exception) {
  198. return $currency;
  199. }
  200. }
  201. public function getCurrencySymbol(?string $currency, ?string $locale = null): string
  202. {
  203. if (null === $currency) {
  204. return '';
  205. }
  206. try {
  207. return Currencies::getSymbol($currency, $locale);
  208. } catch (MissingResourceException $exception) {
  209. return $currency;
  210. }
  211. }
  212. public function getLanguageName(?string $language, ?string $locale = null): string
  213. {
  214. if (null === $language) {
  215. return '';
  216. }
  217. try {
  218. return Languages::getName($language, $locale);
  219. } catch (MissingResourceException $exception) {
  220. return $language;
  221. }
  222. }
  223. public function getLocaleName(?string $data, ?string $locale = null): string
  224. {
  225. if (null === $data) {
  226. return '';
  227. }
  228. try {
  229. return Locales::getName($data, $locale);
  230. } catch (MissingResourceException $exception) {
  231. return $data;
  232. }
  233. }
  234. public function getTimezoneName(?string $timezone, ?string $locale = null): string
  235. {
  236. if (null === $timezone) {
  237. return '';
  238. }
  239. try {
  240. return Timezones::getName($timezone, $locale);
  241. } catch (MissingResourceException $exception) {
  242. return $timezone;
  243. }
  244. }
  245. public function getCountryTimezones(string $country): array
  246. {
  247. try {
  248. return Timezones::forCountryCode($country);
  249. } catch (MissingResourceException $exception) {
  250. return [];
  251. }
  252. }
  253. public function getLanguageNames(?string $locale = null): array
  254. {
  255. try {
  256. return Languages::getNames($locale);
  257. } catch (MissingResourceException $exception) {
  258. return [];
  259. }
  260. }
  261. public function getScriptNames(?string $locale = null): array
  262. {
  263. try {
  264. return Scripts::getNames($locale);
  265. } catch (MissingResourceException $exception) {
  266. return [];
  267. }
  268. }
  269. public function getCountryNames(?string $locale = null): array
  270. {
  271. try {
  272. return Countries::getNames($locale);
  273. } catch (MissingResourceException $exception) {
  274. return [];
  275. }
  276. }
  277. public function getLocaleNames(?string $locale = null): array
  278. {
  279. try {
  280. return Locales::getNames($locale);
  281. } catch (MissingResourceException $exception) {
  282. return [];
  283. }
  284. }
  285. public function getCurrencyNames(?string $locale = null): array
  286. {
  287. try {
  288. return Currencies::getNames($locale);
  289. } catch (MissingResourceException $exception) {
  290. return [];
  291. }
  292. }
  293. public function getTimezoneNames(?string $locale = null): array
  294. {
  295. try {
  296. return Timezones::getNames($locale);
  297. } catch (MissingResourceException $exception) {
  298. return [];
  299. }
  300. }
  301. public function formatCurrency($amount, string $currency, array $attrs = [], ?string $locale = null): string
  302. {
  303. $formatter = $this->createNumberFormatter($locale, 'currency', $attrs);
  304. if (false === $ret = $formatter->formatCurrency($amount, $currency)) {
  305. throw new RuntimeError('Unable to format the given number as a currency.');
  306. }
  307. return $ret;
  308. }
  309. public function formatNumber($number, array $attrs = [], string $style = 'decimal', string $type = 'default', ?string $locale = null): string
  310. {
  311. if (!isset(self::NUMBER_TYPES[$type])) {
  312. throw new RuntimeError(\sprintf('The type "%s" does not exist, known types are: "%s".', $type, implode('", "', array_keys(self::NUMBER_TYPES))));
  313. }
  314. $formatter = $this->createNumberFormatter($locale, $style, $attrs);
  315. if (false === $ret = $formatter->format($number, self::NUMBER_TYPES[$type])) {
  316. throw new RuntimeError('Unable to format the given number.');
  317. }
  318. return $ret;
  319. }
  320. public function formatNumberStyle(string $style, $number, array $attrs = [], string $type = 'default', ?string $locale = null): string
  321. {
  322. return $this->formatNumber($number, $attrs, $style, $type, $locale);
  323. }
  324. /**
  325. * @param \DateTimeInterface|string|null $date A date or null to use the current time
  326. * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
  327. */
  328. public function formatDateTime(Environment $env, $date, ?string $dateFormat = 'medium', ?string $timeFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', ?string $locale = null): string
  329. {
  330. $date = $env->getExtension(CoreExtension::class)->convertDate($date, $timezone);
  331. $formatterTimezone = $timezone;
  332. if (null === $formatterTimezone || false === $formatterTimezone) {
  333. $formatterTimezone = $date->getTimezone();
  334. } elseif (\is_string($formatterTimezone)) {
  335. $formatterTimezone = new \DateTimeZone($timezone);
  336. }
  337. $formatter = $this->createDateFormatter($locale, $dateFormat, $timeFormat, $pattern, $formatterTimezone, $calendar);
  338. if (false === $ret = $formatter->format($date)) {
  339. throw new RuntimeError('Unable to format the given date.');
  340. }
  341. return $ret;
  342. }
  343. /**
  344. * @param \DateTimeInterface|string|null $date A date or null to use the current time
  345. * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
  346. */
  347. public function formatDate(Environment $env, $date, ?string $dateFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', ?string $locale = null): string
  348. {
  349. return $this->formatDateTime($env, $date, $dateFormat, 'none', $pattern, $timezone, $calendar, $locale);
  350. }
  351. /**
  352. * @param \DateTimeInterface|string|null $date A date or null to use the current time
  353. * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
  354. */
  355. public function formatTime(Environment $env, $date, ?string $timeFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', ?string $locale = null): string
  356. {
  357. return $this->formatDateTime($env, $date, 'none', $timeFormat, $pattern, $timezone, $calendar, $locale);
  358. }
  359. private function createDateFormatter(?string $locale, ?string $dateFormat, ?string $timeFormat, string $pattern, ?\DateTimeZone $timezone, string $calendar): \IntlDateFormatter
  360. {
  361. $dateFormats = self::availableDateFormats();
  362. if (null !== $dateFormat && !isset($dateFormats[$dateFormat])) {
  363. throw new RuntimeError(\sprintf('The date format "%s" does not exist, known formats are: "%s".', $dateFormat, implode('", "', array_keys($dateFormats))));
  364. }
  365. if (null !== $timeFormat && !isset(self::TIME_FORMATS[$timeFormat])) {
  366. throw new RuntimeError(\sprintf('The time format "%s" does not exist, known formats are: "%s".', $timeFormat, implode('", "', array_keys(self::TIME_FORMATS))));
  367. }
  368. if (null === $locale) {
  369. if ($this->dateFormatterPrototype) {
  370. $locale = $this->dateFormatterPrototype->getLocale();
  371. }
  372. $locale = $locale ?: \Locale::getDefault();
  373. }
  374. $calendar = 'gregorian' === $calendar ? \IntlDateFormatter::GREGORIAN : \IntlDateFormatter::TRADITIONAL;
  375. $dateFormatValue = $dateFormats[$dateFormat] ?? null;
  376. $timeFormatValue = self::TIME_FORMATS[$timeFormat] ?? null;
  377. if ($this->dateFormatterPrototype) {
  378. $dateFormatValue = $dateFormatValue ?: $this->dateFormatterPrototype->getDateType();
  379. $timeFormatValue = $timeFormatValue ?: $this->dateFormatterPrototype->getTimeType();
  380. $timezone = $timezone ?: $this->dateFormatterPrototype->getTimeZone()->toDateTimeZone();
  381. $calendar = $calendar ?: $this->dateFormatterPrototype->getCalendar();
  382. $pattern = $pattern ?: $this->dateFormatterPrototype->getPattern();
  383. }
  384. $timezoneName = $timezone ? $timezone->getName() : '(none)';
  385. $hash = $locale.'|'.$dateFormatValue.'|'.$timeFormatValue.'|'.$timezoneName.'|'.$calendar.'|'.$pattern;
  386. if (!isset($this->dateFormatters[$hash])) {
  387. $this->dateFormatters[$hash] = new \IntlDateFormatter($locale, $dateFormatValue, $timeFormatValue, $timezone, $calendar, $pattern);
  388. }
  389. return $this->dateFormatters[$hash];
  390. }
  391. private function createNumberFormatter(?string $locale, string $style, array $attrs = []): \NumberFormatter
  392. {
  393. if (!isset(self::NUMBER_STYLES[$style])) {
  394. throw new RuntimeError(\sprintf('The style "%s" does not exist, known styles are: "%s".', $style, implode('", "', array_keys(self::NUMBER_STYLES))));
  395. }
  396. if (null === $locale) {
  397. $locale = \Locale::getDefault();
  398. }
  399. // textAttrs and symbols can only be set on the prototype as there is probably no
  400. // use case for setting it on each call.
  401. $textAttrs = [];
  402. $symbols = [];
  403. if ($this->numberFormatterPrototype) {
  404. foreach (self::NUMBER_ATTRIBUTES as $name => $const) {
  405. if (!isset($attrs[$name])) {
  406. $value = $this->numberFormatterPrototype->getAttribute($const);
  407. if ('rounding_mode' === $name) {
  408. $value = array_flip(self::NUMBER_ROUNDING_ATTRIBUTES)[$value];
  409. } elseif ('padding_position' === $name) {
  410. $value = array_flip(self::NUMBER_PADDING_ATTRIBUTES)[$value];
  411. }
  412. $attrs[$name] = $value;
  413. }
  414. }
  415. foreach (self::NUMBER_TEXT_ATTRIBUTES as $name => $const) {
  416. $textAttrs[$name] = $this->numberFormatterPrototype->getTextAttribute($const);
  417. }
  418. foreach (self::NUMBER_SYMBOLS as $name => $const) {
  419. $symbols[$name] = $this->numberFormatterPrototype->getSymbol($const);
  420. }
  421. }
  422. ksort($attrs);
  423. $hash = $locale.'|'.$style.'|'.json_encode($attrs).'|'.json_encode($textAttrs).'|'.json_encode($symbols);
  424. if (!isset($this->numberFormatters[$hash])) {
  425. $this->numberFormatters[$hash] = new \NumberFormatter($locale, self::NUMBER_STYLES[$style]);
  426. }
  427. foreach ($attrs as $name => $value) {
  428. if (!isset(self::NUMBER_ATTRIBUTES[$name])) {
  429. throw new RuntimeError(\sprintf('The number formatter attribute "%s" does not exist, known attributes are: "%s".', $name, implode('", "', array_keys(self::NUMBER_ATTRIBUTES))));
  430. }
  431. if ('rounding_mode' === $name) {
  432. if (!isset(self::NUMBER_ROUNDING_ATTRIBUTES[$value])) {
  433. 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))));
  434. }
  435. $value = self::NUMBER_ROUNDING_ATTRIBUTES[$value];
  436. } elseif ('padding_position' === $name) {
  437. if (!isset(self::NUMBER_PADDING_ATTRIBUTES[$value])) {
  438. 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))));
  439. }
  440. $value = self::NUMBER_PADDING_ATTRIBUTES[$value];
  441. }
  442. $this->numberFormatters[$hash]->setAttribute(self::NUMBER_ATTRIBUTES[$name], $value);
  443. }
  444. foreach ($textAttrs as $name => $value) {
  445. $this->numberFormatters[$hash]->setTextAttribute(self::NUMBER_TEXT_ATTRIBUTES[$name], $value);
  446. }
  447. foreach ($symbols as $name => $value) {
  448. $this->numberFormatters[$hash]->setSymbol(self::NUMBER_SYMBOLS[$name], $value);
  449. }
  450. return $this->numberFormatters[$hash];
  451. }
  452. }