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

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\TwigFilter;
  22. use Twig\TwigFunction;
  23. final class IntlExtension extends AbstractExtension
  24. {
  25. private static function availableDateFormats(): array
  26. {
  27. static $formats = null;
  28. if (null !== $formats) {
  29. return $formats;
  30. }
  31. $formats = [
  32. 'none' => \IntlDateFormatter::NONE,
  33. 'short' => \IntlDateFormatter::SHORT,
  34. 'medium' => \IntlDateFormatter::MEDIUM,
  35. 'long' => \IntlDateFormatter::LONG,
  36. 'full' => \IntlDateFormatter::FULL,
  37. ];
  38. // Assuming that each `RELATIVE_*` constant are defined when one of them is.
  39. if (\defined('IntlDateFormatter::RELATIVE_FULL')) {
  40. $formats = array_merge($formats, [
  41. 'relative_short' => \IntlDateFormatter::RELATIVE_SHORT,
  42. 'relative_medium' => \IntlDateFormatter::RELATIVE_MEDIUM,
  43. 'relative_long' => \IntlDateFormatter::RELATIVE_LONG,
  44. 'relative_full' => \IntlDateFormatter::RELATIVE_FULL,
  45. ]);
  46. }
  47. return $formats;
  48. }
  49. private const TIME_FORMATS = [
  50. 'none' => \IntlDateFormatter::NONE,
  51. 'short' => \IntlDateFormatter::SHORT,
  52. 'medium' => \IntlDateFormatter::MEDIUM,
  53. 'long' => \IntlDateFormatter::LONG,
  54. 'full' => \IntlDateFormatter::FULL,
  55. ];
  56. private const NUMBER_TYPES = [
  57. 'default' => \NumberFormatter::TYPE_DEFAULT,
  58. 'int32' => \NumberFormatter::TYPE_INT32,
  59. 'int64' => \NumberFormatter::TYPE_INT64,
  60. 'double' => \NumberFormatter::TYPE_DOUBLE,
  61. 'currency' => \NumberFormatter::TYPE_CURRENCY,
  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()
  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()
  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 = twig_date_converter($env, $date, $timezone);
  331. $formatter = $this->createDateFormatter($locale, $dateFormat, $timeFormat, $pattern, $date->getTimezone(), $calendar);
  332. if (false === $ret = $formatter->format($date)) {
  333. throw new RuntimeError('Unable to format the given date.');
  334. }
  335. return $ret;
  336. }
  337. /**
  338. * @param \DateTimeInterface|string|null $date A date or null to use the current time
  339. * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
  340. */
  341. public function formatDate(Environment $env, $date, ?string $dateFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', string $locale = null): string
  342. {
  343. return $this->formatDateTime($env, $date, $dateFormat, 'none', $pattern, $timezone, $calendar, $locale);
  344. }
  345. /**
  346. * @param \DateTimeInterface|string|null $date A date or null to use the current time
  347. * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
  348. */
  349. public function formatTime(Environment $env, $date, ?string $timeFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', string $locale = null): string
  350. {
  351. return $this->formatDateTime($env, $date, 'none', $timeFormat, $pattern, $timezone, $calendar, $locale);
  352. }
  353. private function createDateFormatter(?string $locale, ?string $dateFormat, ?string $timeFormat, string $pattern, \DateTimeZone $timezone, string $calendar): \IntlDateFormatter
  354. {
  355. $dateFormats = self::availableDateFormats();
  356. if (null !== $dateFormat && !isset($dateFormats[$dateFormat])) {
  357. throw new RuntimeError(sprintf('The date format "%s" does not exist, known formats are: "%s".', $dateFormat, implode('", "', array_keys($dateFormats))));
  358. }
  359. if (null !== $timeFormat && !isset(self::TIME_FORMATS[$timeFormat])) {
  360. throw new RuntimeError(sprintf('The time format "%s" does not exist, known formats are: "%s".', $timeFormat, implode('", "', array_keys(self::TIME_FORMATS))));
  361. }
  362. if (null === $locale) {
  363. $locale = \Locale::getDefault();
  364. }
  365. $calendar = 'gregorian' === $calendar ? \IntlDateFormatter::GREGORIAN : \IntlDateFormatter::TRADITIONAL;
  366. $dateFormatValue = $dateFormats[$dateFormat] ?? null;
  367. $timeFormatValue = self::TIME_FORMATS[$timeFormat] ?? null;
  368. if ($this->dateFormatterPrototype) {
  369. $dateFormatValue = $dateFormatValue ?: $this->dateFormatterPrototype->getDateType();
  370. $timeFormatValue = $timeFormatValue ?: $this->dateFormatterPrototype->getTimeType();
  371. $timezone = $timezone ?: $this->dateFormatterPrototype->getTimeType();
  372. $calendar = $calendar ?: $this->dateFormatterPrototype->getCalendar();
  373. $pattern = $pattern ?: $this->dateFormatterPrototype->getPattern();
  374. }
  375. $hash = $locale.'|'.$dateFormatValue.'|'.$timeFormatValue.'|'.$timezone->getName().'|'.$calendar.'|'.$pattern;
  376. if (!isset($this->dateFormatters[$hash])) {
  377. $this->dateFormatters[$hash] = new \IntlDateFormatter($locale, $dateFormatValue, $timeFormatValue, $timezone, $calendar, $pattern);
  378. }
  379. return $this->dateFormatters[$hash];
  380. }
  381. private function createNumberFormatter(?string $locale, string $style, array $attrs = []): \NumberFormatter
  382. {
  383. if (!isset(self::NUMBER_STYLES[$style])) {
  384. throw new RuntimeError(sprintf('The style "%s" does not exist, known styles are: "%s".', $style, implode('", "', array_keys(self::NUMBER_STYLES))));
  385. }
  386. if (null === $locale) {
  387. $locale = \Locale::getDefault();
  388. }
  389. // textAttrs and symbols can only be set on the prototype as there is probably no
  390. // use case for setting it on each call.
  391. $textAttrs = [];
  392. $symbols = [];
  393. if ($this->numberFormatterPrototype) {
  394. foreach (self::NUMBER_ATTRIBUTES as $name => $const) {
  395. if (!isset($attrs[$name])) {
  396. $value = $this->numberFormatterPrototype->getAttribute($const);
  397. if ('rounding_mode' === $name) {
  398. $value = array_flip(self::NUMBER_ROUNDING_ATTRIBUTES)[$value];
  399. } elseif ('padding_position' === $name) {
  400. $value = array_flip(self::NUMBER_PADDING_ATTRIBUTES)[$value];
  401. }
  402. $attrs[$name] = $value;
  403. }
  404. }
  405. foreach (self::NUMBER_TEXT_ATTRIBUTES as $name => $const) {
  406. $textAttrs[$name] = $this->numberFormatterPrototype->getTextAttribute($const);
  407. }
  408. foreach (self::NUMBER_SYMBOLS as $name => $const) {
  409. $symbols[$name] = $this->numberFormatterPrototype->getSymbol($const);
  410. }
  411. }
  412. ksort($attrs);
  413. $hash = $locale.'|'.$style.'|'.json_encode($attrs).'|'.json_encode($textAttrs).'|'.json_encode($symbols);
  414. if (!isset($this->numberFormatters[$hash])) {
  415. $this->numberFormatters[$hash] = new \NumberFormatter($locale, self::NUMBER_STYLES[$style]);
  416. }
  417. foreach ($attrs as $name => $value) {
  418. if (!isset(self::NUMBER_ATTRIBUTES[$name])) {
  419. throw new RuntimeError(sprintf('The number formatter attribute "%s" does not exist, known attributes are: "%s".', $name, implode('", "', array_keys(self::NUMBER_ATTRIBUTES))));
  420. }
  421. if ('rounding_mode' === $name) {
  422. if (!isset(self::NUMBER_ROUNDING_ATTRIBUTES[$value])) {
  423. 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))));
  424. }
  425. $value = self::NUMBER_ROUNDING_ATTRIBUTES[$value];
  426. } elseif ('padding_position' === $name) {
  427. if (!isset(self::NUMBER_PADDING_ATTRIBUTES[$value])) {
  428. 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))));
  429. }
  430. $value = self::NUMBER_PADDING_ATTRIBUTES[$value];
  431. }
  432. $this->numberFormatters[$hash]->setAttribute(self::NUMBER_ATTRIBUTES[$name], $value);
  433. }
  434. foreach ($textAttrs as $name => $value) {
  435. $this->numberFormatters[$hash]->setTextAttribute(self::NUMBER_TEXT_ATTRIBUTES[$name], $value);
  436. }
  437. foreach ($symbols as $name => $value) {
  438. $this->numberFormatters[$hash]->setSymbol(self::NUMBER_SYMBOLS[$name], $value);
  439. }
  440. return $this->numberFormatters[$hash];
  441. }
  442. }