IntlExtension.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  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\Timezones;
  17. use Twig\Environment;
  18. use Twig\Error\RuntimeError;
  19. use Twig\Extension\AbstractExtension;
  20. use Twig\TwigFilter;
  21. use Twig\TwigFunction;
  22. final class IntlExtension extends AbstractExtension
  23. {
  24. private const DATE_FORMATS = [
  25. 'none' => \IntlDateFormatter::NONE,
  26. 'short' => \IntlDateFormatter::SHORT,
  27. 'medium' => \IntlDateFormatter::MEDIUM,
  28. 'long' => \IntlDateFormatter::LONG,
  29. 'full' => \IntlDateFormatter::FULL,
  30. ];
  31. private const NUMBER_TYPES = [
  32. 'default' => \NumberFormatter::TYPE_DEFAULT,
  33. 'int32' => \NumberFormatter::TYPE_INT32,
  34. 'int64' => \NumberFormatter::TYPE_INT64,
  35. 'double' => \NumberFormatter::TYPE_DOUBLE,
  36. 'currency' => \NumberFormatter::TYPE_CURRENCY,
  37. ];
  38. private const NUMBER_STYLES = [
  39. 'decimal' => \NumberFormatter::DECIMAL,
  40. 'currency' => \NumberFormatter::CURRENCY,
  41. 'percent' => \NumberFormatter::PERCENT,
  42. 'scientific' => \NumberFormatter::SCIENTIFIC,
  43. 'spellout' => \NumberFormatter::SPELLOUT,
  44. 'ordinal' => \NumberFormatter::ORDINAL,
  45. 'duration' => \NumberFormatter::DURATION,
  46. ];
  47. private const NUMBER_ATTRIBUTES = [
  48. 'grouping_used' => \NumberFormatter::GROUPING_USED,
  49. 'decimal_always_shown' => \NumberFormatter::DECIMAL_ALWAYS_SHOWN,
  50. 'max_integer_digit' => \NumberFormatter::MAX_INTEGER_DIGITS,
  51. 'min_integer_digit' => \NumberFormatter::MIN_INTEGER_DIGITS,
  52. 'integer_digit' => \NumberFormatter::INTEGER_DIGITS,
  53. 'max_fraction_digit' => \NumberFormatter::MAX_FRACTION_DIGITS,
  54. 'min_fraction_digit' => \NumberFormatter::MIN_FRACTION_DIGITS,
  55. 'fraction_digit' => \NumberFormatter::FRACTION_DIGITS,
  56. 'multiplier' => \NumberFormatter::MULTIPLIER,
  57. 'grouping_size' => \NumberFormatter::GROUPING_SIZE,
  58. 'rounding_mode' => \NumberFormatter::ROUNDING_MODE,
  59. 'rounding_increment' => \NumberFormatter::ROUNDING_INCREMENT,
  60. 'format_width' => \NumberFormatter::FORMAT_WIDTH,
  61. 'padding_position' => \NumberFormatter::PADDING_POSITION,
  62. 'secondary_grouping_size' => \NumberFormatter::SECONDARY_GROUPING_SIZE,
  63. 'significant_digits_used' => \NumberFormatter::SIGNIFICANT_DIGITS_USED,
  64. 'min_significant_digits_used' => \NumberFormatter::MIN_SIGNIFICANT_DIGITS,
  65. 'max_significant_digits_used' => \NumberFormatter::MAX_SIGNIFICANT_DIGITS,
  66. 'lenient_parse' => \NumberFormatter::LENIENT_PARSE,
  67. ];
  68. private const NUMBER_ROUNDING_ATTRIBUTES = [
  69. 'ceiling' => \NumberFormatter::ROUND_CEILING,
  70. 'floor' => \NumberFormatter::ROUND_FLOOR,
  71. 'down' => \NumberFormatter::ROUND_DOWN,
  72. 'up' => \NumberFormatter::ROUND_UP,
  73. 'halfeven' => \NumberFormatter::ROUND_HALFEVEN,
  74. 'halfdown' => \NumberFormatter::ROUND_HALFDOWN,
  75. 'halfup' => \NumberFormatter::ROUND_HALFUP,
  76. ];
  77. private const NUMBER_PADDING_ATTRIBUTES = [
  78. 'before_prefix' => \NumberFormatter::PAD_BEFORE_PREFIX,
  79. 'after_prefix' => \NumberFormatter::PAD_AFTER_PREFIX,
  80. 'before_suffix' => \NumberFormatter::PAD_BEFORE_SUFFIX,
  81. 'after_suffix' => \NumberFormatter::PAD_AFTER_SUFFIX,
  82. ];
  83. private const NUMBER_TEXT_ATTRIBUTES = [
  84. 'positive_prefix' => \NumberFormatter::POSITIVE_PREFIX,
  85. 'positive_suffix' => \NumberFormatter::POSITIVE_SUFFIX,
  86. 'negative_prefix' => \NumberFormatter::NEGATIVE_PREFIX,
  87. 'negative_suffix' => \NumberFormatter::NEGATIVE_SUFFIX,
  88. 'padding_character' => \NumberFormatter::PADDING_CHARACTER,
  89. 'currency_mode' => \NumberFormatter::CURRENCY_CODE,
  90. 'default_ruleset' => \NumberFormatter::DEFAULT_RULESET,
  91. 'public_rulesets' => \NumberFormatter::PUBLIC_RULESETS,
  92. ];
  93. private const NUMBER_SYMBOLS = [
  94. 'decimal_separator' => \NumberFormatter::DECIMAL_SEPARATOR_SYMBOL,
  95. 'grouping_separator' => \NumberFormatter::GROUPING_SEPARATOR_SYMBOL,
  96. 'pattern_separator' => \NumberFormatter::PATTERN_SEPARATOR_SYMBOL,
  97. 'percent' => \NumberFormatter::PERCENT_SYMBOL,
  98. 'zero_digit' => \NumberFormatter::ZERO_DIGIT_SYMBOL,
  99. 'digit' => \NumberFormatter::DIGIT_SYMBOL,
  100. 'minus_sign' => \NumberFormatter::MINUS_SIGN_SYMBOL,
  101. 'plus_sign' => \NumberFormatter::PLUS_SIGN_SYMBOL,
  102. 'currency' => \NumberFormatter::CURRENCY_SYMBOL,
  103. 'intl_currency' => \NumberFormatter::INTL_CURRENCY_SYMBOL,
  104. 'monetary_separator' => \NumberFormatter::MONETARY_SEPARATOR_SYMBOL,
  105. 'exponential' => \NumberFormatter::EXPONENTIAL_SYMBOL,
  106. 'permill' => \NumberFormatter::PERMILL_SYMBOL,
  107. 'pad_escape' => \NumberFormatter::PAD_ESCAPE_SYMBOL,
  108. 'infinity' => \NumberFormatter::INFINITY_SYMBOL,
  109. 'nan' => \NumberFormatter::NAN_SYMBOL,
  110. 'significant_digit' => \NumberFormatter::SIGNIFICANT_DIGIT_SYMBOL,
  111. 'monetary_grouping_separator' => \NumberFormatter::MONETARY_GROUPING_SEPARATOR_SYMBOL,
  112. ];
  113. private $dateFormatters = [];
  114. private $numberFormatters = [];
  115. private $dateFormatterPrototype;
  116. private $numberFormatterPrototype;
  117. public function __construct(\IntlDateFormatter $dateFormatterPrototype = null, \NumberFormatter $numberFormatterPrototype = null)
  118. {
  119. $this->dateFormatterPrototype = $dateFormatterPrototype;
  120. $this->numberFormatterPrototype = $numberFormatterPrototype;
  121. }
  122. public function getFilters()
  123. {
  124. return [
  125. // internationalized names
  126. new TwigFilter('country_name', [$this, 'getCountryName']),
  127. new TwigFilter('currency_name', [$this, 'getCurrencyName']),
  128. new TwigFilter('currency_symbol', [$this, 'getCurrencySymbol']),
  129. new TwigFilter('language_name', [$this, 'getLanguageName']),
  130. new TwigFilter('locale_name', [$this, 'getLocaleName']),
  131. new TwigFilter('timezone_name', [$this, 'getTimezoneName']),
  132. // localized formatters
  133. new TwigFilter('format_currency', [$this, 'formatCurrency']),
  134. new TwigFilter('format_number', [$this, 'formatNumber']),
  135. new TwigFilter('format_*_number', [$this, 'formatNumberStyle']),
  136. new TwigFilter('format_datetime', [$this, 'formatDateTime'], ['needs_environment' => true]),
  137. new TwigFilter('format_date', [$this, 'formatDate'], ['needs_environment' => true]),
  138. new TwigFilter('format_time', [$this, 'formatTime'], ['needs_environment' => true]),
  139. ];
  140. }
  141. public function getFunctions()
  142. {
  143. return [
  144. // internationalized names
  145. new TwigFunction('country_timezones', [$this, 'getCountryTimezones']),
  146. ];
  147. }
  148. public function getCountryName(?string $country, string $locale = null): string
  149. {
  150. if (null === $country) {
  151. return '';
  152. }
  153. try {
  154. return Countries::getName($country, $locale);
  155. } catch (MissingResourceException $exception) {
  156. return $country;
  157. }
  158. }
  159. public function getCurrencyName(?string $currency, string $locale = null): string
  160. {
  161. if (null === $currency) {
  162. return '';
  163. }
  164. try {
  165. return Currencies::getName($currency, $locale);
  166. } catch (MissingResourceException $exception) {
  167. return $currency;
  168. }
  169. }
  170. public function getCurrencySymbol(?string $currency, string $locale = null): string
  171. {
  172. if (null === $currency) {
  173. return '';
  174. }
  175. try {
  176. return Currencies::getSymbol($currency, $locale);
  177. } catch (MissingResourceException $exception) {
  178. return $currency;
  179. }
  180. }
  181. public function getLanguageName(?string $language, string $locale = null): string
  182. {
  183. if (null === $language) {
  184. return '';
  185. }
  186. try {
  187. return Languages::getName($language, $locale);
  188. } catch (MissingResourceException $exception) {
  189. return $language;
  190. }
  191. }
  192. public function getLocaleName(?string $data, string $locale = null): string
  193. {
  194. if (null === $data) {
  195. return '';
  196. }
  197. try {
  198. return Locales::getName($data, $locale);
  199. } catch (MissingResourceException $exception) {
  200. return $data;
  201. }
  202. }
  203. public function getTimezoneName(?string $timezone, string $locale = null): string
  204. {
  205. if (null === $timezone) {
  206. return '';
  207. }
  208. try {
  209. return Timezones::getName($timezone, $locale);
  210. } catch (MissingResourceException $exception) {
  211. return $timezone;
  212. }
  213. }
  214. public function getCountryTimezones(string $country): array
  215. {
  216. try {
  217. return Timezones::forCountryCode($country);
  218. } catch (MissingResourceException $exception) {
  219. return [];
  220. }
  221. }
  222. public function formatCurrency($amount, string $currency, array $attrs = [], string $locale = null): string
  223. {
  224. $formatter = $this->createNumberFormatter($locale, 'currency', $attrs);
  225. if (false === $ret = $formatter->formatCurrency($amount, $currency)) {
  226. throw new RuntimeError('Unable to format the given number as a currency.');
  227. }
  228. return $ret;
  229. }
  230. public function formatNumber($number, array $attrs = [], string $style = 'decimal', string $type = 'default', string $locale = null): string
  231. {
  232. if (!isset(self::NUMBER_TYPES[$type])) {
  233. throw new RuntimeError(sprintf('The type "%s" does not exist, known types are: "%s".', $type, implode('", "', array_keys(self::NUMBER_TYPES))));
  234. }
  235. $formatter = $this->createNumberFormatter($locale, $style, $attrs);
  236. if (false === $ret = $formatter->format($number, self::NUMBER_TYPES[$type])) {
  237. throw new RuntimeError('Unable to format the given number.');
  238. }
  239. return $ret;
  240. }
  241. public function formatNumberStyle(string $style, $number, array $attrs = [], string $type = 'default', string $locale = null): string
  242. {
  243. return $this->formatNumber($number, $attrs, $style, $type, $locale);
  244. }
  245. /**
  246. * @param \DateTimeInterface|string|null $date A date or null to use the current time
  247. * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
  248. */
  249. public function formatDateTime(Environment $env, $date, ?string $dateFormat = 'medium', ?string $timeFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', string $locale = null): string
  250. {
  251. $date = \twig_date_converter($env, $date, $timezone);
  252. $formatter = $this->createDateFormatter($locale, $dateFormat, $timeFormat, $pattern, $date->getTimezone(), $calendar);
  253. if (false === $ret = $formatter->format($date)) {
  254. throw new RuntimeError('Unable to format the given date.');
  255. }
  256. return $ret;
  257. }
  258. /**
  259. * @param \DateTimeInterface|string|null $date A date or null to use the current time
  260. * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
  261. */
  262. public function formatDate(Environment $env, $date, ?string $dateFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', string $locale = null): string
  263. {
  264. return $this->formatDateTime($env, $date, $dateFormat, 'none', $pattern, $timezone, $calendar, $locale);
  265. }
  266. /**
  267. * @param \DateTimeInterface|string|null $date A date or null to use the current time
  268. * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
  269. */
  270. public function formatTime(Environment $env, $date, ?string $timeFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', string $locale = null): string
  271. {
  272. return $this->formatDateTime($env, $date, 'none', $timeFormat, $pattern, $timezone, $calendar, $locale);
  273. }
  274. private function createDateFormatter(?string $locale, ?string $dateFormat, ?string $timeFormat, string $pattern, \DateTimeZone $timezone, string $calendar): \IntlDateFormatter
  275. {
  276. if (null !== $dateFormat && !isset(self::DATE_FORMATS[$dateFormat])) {
  277. throw new RuntimeError(sprintf('The date format "%s" does not exist, known formats are: "%s".', $dateFormat, implode('", "', array_keys(self::DATE_FORMATS))));
  278. }
  279. if (null !== $timeFormat && !isset(self::DATE_FORMATS[$timeFormat])) {
  280. throw new RuntimeError(sprintf('The time format "%s" does not exist, known formats are: "%s".', $timeFormat, implode('", "', array_keys(self::DATE_FORMATS))));
  281. }
  282. if (null === $locale) {
  283. $locale = \Locale::getDefault();
  284. }
  285. $calendar = 'gregorian' === $calendar ? \IntlDateFormatter::GREGORIAN : \IntlDateFormatter::TRADITIONAL;
  286. $dateFormatValue = self::DATE_FORMATS[$dateFormat] ?? null;
  287. $timeFormatValue = self::DATE_FORMATS[$timeFormat] ?? null;
  288. if ($this->dateFormatterPrototype) {
  289. $dateFormatValue = $dateFormatValue ?: $this->dateFormatterPrototype->getDateType();
  290. $timeFormatValue = $timeFormatValue ?: $this->dateFormatterPrototype->getTimeType();
  291. $timezone = $timezone ?: $this->dateFormatterPrototype->getTimeType();
  292. $calendar = $calendar ?: $this->dateFormatterPrototype->getCalendar();
  293. $pattern = $pattern ?: $this->dateFormatterPrototype->getPattern();
  294. }
  295. $hash = $locale.'|'.$dateFormatValue.'|'.$timeFormatValue.'|'.$timezone->getName().'|'.$calendar.'|'.$pattern;
  296. if (!isset($this->dateFormatters[$hash])) {
  297. $this->dateFormatters[$hash] = new \IntlDateFormatter($locale, $dateFormatValue, $timeFormatValue, $timezone, $calendar, $pattern);
  298. }
  299. return $this->dateFormatters[$hash];
  300. }
  301. private function createNumberFormatter(?string $locale, string $style, array $attrs = []): \NumberFormatter
  302. {
  303. if (!isset(self::NUMBER_STYLES[$style])) {
  304. throw new RuntimeError(sprintf('The style "%s" does not exist, known styles are: "%s".', $style, implode('", "', array_keys(self::NUMBER_STYLES))));
  305. }
  306. if (null === $locale) {
  307. $locale = \Locale::getDefault();
  308. }
  309. // textAttrs and symbols can only be set on the prototype as there is probably no
  310. // use case for setting it on each call.
  311. $textAttrs = [];
  312. $symbols = [];
  313. if ($this->numberFormatterPrototype) {
  314. foreach (self::NUMBER_ATTRIBUTES as $name => $const) {
  315. if (!isset($attrs[$name])) {
  316. $value = $this->numberFormatterPrototype->getAttribute($const);
  317. if ('rounding_mode' === $name) {
  318. $value = array_flip(self::NUMBER_ROUNDING_ATTRIBUTES)[$value];
  319. } elseif ('padding_position' === $name) {
  320. $value = array_flip(self::NUMBER_PADDING_ATTRIBUTES)[$value];
  321. }
  322. $attrs[$name] = $value;
  323. }
  324. }
  325. foreach (self::NUMBER_TEXT_ATTRIBUTES as $name => $const) {
  326. $textAttrs[$name] = $this->numberFormatterPrototype->getTextAttribute($const);
  327. }
  328. foreach (self::NUMBER_SYMBOLS as $name => $const) {
  329. $symbols[$name] = $this->numberFormatterPrototype->getSymbol($const);
  330. }
  331. }
  332. ksort($attrs);
  333. $hash = $locale.'|'.$style.'|'.json_encode($attrs).'|'.json_encode($textAttrs).'|'.json_encode($symbols);
  334. if (!isset($this->numberFormatters[$hash])) {
  335. $this->numberFormatters[$hash] = new \NumberFormatter($locale, self::NUMBER_STYLES[$style]);
  336. }
  337. foreach ($attrs as $name => $value) {
  338. if (!isset(self::NUMBER_ATTRIBUTES[$name])) {
  339. throw new RuntimeError(sprintf('The number formatter attribute "%s" does not exist, known attributes are: "%s".', $name, implode('", "', array_keys(self::NUMBER_ATTRIBUTES))));
  340. }
  341. if ('rounding_mode' === $name) {
  342. if (!isset(self::NUMBER_ROUNDING_ATTRIBUTES[$value])) {
  343. 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))));
  344. }
  345. $value = self::NUMBER_ROUNDING_ATTRIBUTES[$value];
  346. } elseif ('padding_position' === $name) {
  347. if (!isset(self::NUMBER_PADDING_ATTRIBUTES[$value])) {
  348. 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))));
  349. }
  350. $value = self::NUMBER_PADDING_ATTRIBUTES[$value];
  351. }
  352. $this->numberFormatters[$hash]->setAttribute(self::NUMBER_ATTRIBUTES[$name], $value);
  353. }
  354. foreach ($textAttrs as $name => $value) {
  355. $this->numberFormatters[$hash]->setTextAttribute(self::NUMBER_TEXT_ATTRIBUTES[$name], $value);
  356. }
  357. foreach ($symbols as $name => $value) {
  358. $this->numberFormatters[$hash]->setSymbol(self::NUMBER_SYMBOLS[$name], $value);
  359. }
  360. return $this->numberFormatters[$hash];
  361. }
  362. }