123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441 |
- <?php
- /**
- * @link http://www.yiiframework.com/
- * @copyright Copyright (c) 2008 Yii Software LLC
- * @license http://www.yiiframework.com/license/
- */
- namespace yii\i18n;
- use Yii;
- use yii\base\Component;
- use yii\base\NotSupportedException;
- /**
- * MessageFormatter allows formatting messages via [ICU message format](http://userguide.icu-project.org/formatparse/messages).
- *
- * This class enhances the message formatter class provided by the PHP intl extension.
- *
- * The following enhancements are provided:
- *
- * - It accepts named arguments and mixed numeric and named arguments.
- * - Issues no error when an insufficient number of arguments have been provided. Instead, the placeholders will not be
- * substituted.
- * - Fixes PHP 5.5 weird placeholder replacement in case no arguments are provided at all (https://bugs.php.net/bug.php?id=65920).
- * - Offers limited support for message formatting in case PHP intl extension is not installed.
- * However it is highly recommended that you install [PHP intl extension](https://secure.php.net/manual/en/book.intl.php) if you want
- * to use MessageFormatter features.
- *
- * The fallback implementation only supports the following message formats:
- * - plural formatting for english ('one' and 'other' selectors)
- * - select format
- * - simple parameters
- * - integer number parameters
- *
- * The fallback implementation does NOT support the ['apostrophe-friendly' syntax](https://secure.php.net/manual/en/messageformatter.formatmessage.php).
- * Also messages that are working with the fallback implementation are not necessarily compatible with the
- * PHP intl MessageFormatter so do not rely on the fallback if you are able to install intl extension somehow.
- *
- * @property string $errorCode Code of the last error. This property is read-only.
- * @property string $errorMessage Description of the last error. This property is read-only.
- *
- * @author Alexander Makarov <sam@rmcreative.ru>
- * @author Carsten Brandt <mail@cebe.cc>
- * @since 2.0
- */
- class MessageFormatter extends Component
- {
- private $_errorCode = 0;
- private $_errorMessage = '';
- /**
- * Get the error code from the last operation.
- * @link https://secure.php.net/manual/en/messageformatter.geterrorcode.php
- * @return string Code of the last error.
- */
- public function getErrorCode()
- {
- return $this->_errorCode;
- }
- /**
- * Get the error text from the last operation.
- * @link https://secure.php.net/manual/en/messageformatter.geterrormessage.php
- * @return string Description of the last error.
- */
- public function getErrorMessage()
- {
- return $this->_errorMessage;
- }
- /**
- * Formats a message via [ICU message format](http://userguide.icu-project.org/formatparse/messages).
- *
- * It uses the PHP intl extension's [MessageFormatter](https://secure.php.net/manual/en/class.messageformatter.php)
- * and works around some issues.
- * If PHP intl is not installed a fallback will be used that supports a subset of the ICU message format.
- *
- * @param string $pattern The pattern string to insert parameters into.
- * @param array $params The array of name value pairs to insert into the format string.
- * @param string $language The locale to use for formatting locale-dependent parts
- * @return string|false The formatted pattern string or `false` if an error occurred
- */
- public function format($pattern, $params, $language)
- {
- $this->_errorCode = 0;
- $this->_errorMessage = '';
- if ($params === []) {
- return $pattern;
- }
- if (!class_exists('MessageFormatter', false)) {
- return $this->fallbackFormat($pattern, $params, $language);
- }
- // replace named arguments (https://github.com/yiisoft/yii2/issues/9678)
- $newParams = [];
- $pattern = $this->replaceNamedArguments($pattern, $params, $newParams);
- $params = $newParams;
- try {
- $formatter = new \MessageFormatter($language, $pattern);
- if ($formatter === null) {
- // formatter may be null in PHP 5.x
- $this->_errorCode = intl_get_error_code();
- $this->_errorMessage = 'Message pattern is invalid: ' . intl_get_error_message();
- return false;
- }
- } catch (\IntlException $e) {
- // IntlException is thrown since PHP 7
- $this->_errorCode = $e->getCode();
- $this->_errorMessage = 'Message pattern is invalid: ' . $e->getMessage();
- return false;
- } catch (\Exception $e) {
- // Exception is thrown by HHVM
- $this->_errorCode = $e->getCode();
- $this->_errorMessage = 'Message pattern is invalid: ' . $e->getMessage();
- return false;
- }
- $result = $formatter->format($params);
- if ($result === false) {
- $this->_errorCode = $formatter->getErrorCode();
- $this->_errorMessage = $formatter->getErrorMessage();
- return false;
- }
- return $result;
- }
- /**
- * Parses an input string according to an [ICU message format](http://userguide.icu-project.org/formatparse/messages) pattern.
- *
- * It uses the PHP intl extension's [MessageFormatter::parse()](https://secure.php.net/manual/en/messageformatter.parsemessage.php)
- * and adds support for named arguments.
- * Usage of this method requires PHP intl extension to be installed.
- *
- * @param string $pattern The pattern to use for parsing the message.
- * @param string $message The message to parse, conforming to the pattern.
- * @param string $language The locale to use for formatting locale-dependent parts
- * @return array|bool An array containing items extracted, or `FALSE` on error.
- * @throws \yii\base\NotSupportedException when PHP intl extension is not installed.
- */
- public function parse($pattern, $message, $language)
- {
- $this->_errorCode = 0;
- $this->_errorMessage = '';
- if (!class_exists('MessageFormatter', false)) {
- throw new NotSupportedException('You have to install PHP intl extension to use this feature.');
- }
- // replace named arguments
- if (($tokens = self::tokenizePattern($pattern)) === false) {
- $this->_errorCode = -1;
- $this->_errorMessage = 'Message pattern is invalid.';
- return false;
- }
- $map = [];
- foreach ($tokens as $i => $token) {
- if (is_array($token)) {
- $param = trim($token[0]);
- if (!isset($map[$param])) {
- $map[$param] = count($map);
- }
- $token[0] = $map[$param];
- $tokens[$i] = '{' . implode(',', $token) . '}';
- }
- }
- $pattern = implode('', $tokens);
- $map = array_flip($map);
- $formatter = new \MessageFormatter($language, $pattern);
- if ($formatter === null) {
- $this->_errorCode = -1;
- $this->_errorMessage = 'Message pattern is invalid.';
- return false;
- }
- $result = $formatter->parse($message);
- if ($result === false) {
- $this->_errorCode = $formatter->getErrorCode();
- $this->_errorMessage = $formatter->getErrorMessage();
- return false;
- }
- $values = [];
- foreach ($result as $key => $value) {
- $values[$map[$key]] = $value;
- }
- return $values;
- }
- /**
- * Replace named placeholders with numeric placeholders and quote unused.
- *
- * @param string $pattern The pattern string to replace things into.
- * @param array $givenParams The array of values to insert into the format string.
- * @param array $resultingParams Modified array of parameters.
- * @param array $map
- * @return string The pattern string with placeholders replaced.
- */
- private function replaceNamedArguments($pattern, $givenParams, &$resultingParams = [], &$map = [])
- {
- if (($tokens = self::tokenizePattern($pattern)) === false) {
- return false;
- }
- foreach ($tokens as $i => $token) {
- if (!is_array($token)) {
- continue;
- }
- $param = trim($token[0]);
- if (array_key_exists($param, $givenParams)) {
- // if param is given, replace it with a number
- if (!isset($map[$param])) {
- $map[$param] = count($map);
- // make sure only used params are passed to format method
- $resultingParams[$map[$param]] = $givenParams[$param];
- }
- $token[0] = $map[$param];
- $quote = '';
- } else {
- // quote unused token
- $quote = "'";
- }
- $type = isset($token[1]) ? trim($token[1]) : 'none';
- // replace plural and select format recursively
- if ($type === 'plural' || $type === 'select') {
- if (!isset($token[2])) {
- return false;
- }
- if (($subtokens = self::tokenizePattern($token[2])) === false) {
- return false;
- }
- $c = count($subtokens);
- for ($k = 0; $k + 1 < $c; $k++) {
- if (is_array($subtokens[$k]) || !is_array($subtokens[++$k])) {
- return false;
- }
- $subpattern = $this->replaceNamedArguments(implode(',', $subtokens[$k]), $givenParams, $resultingParams, $map);
- $subtokens[$k] = $quote . '{' . $quote . $subpattern . $quote . '}' . $quote;
- }
- $token[2] = implode('', $subtokens);
- }
- $tokens[$i] = $quote . '{' . $quote . implode(',', $token) . $quote . '}' . $quote;
- }
- return implode('', $tokens);
- }
- /**
- * Fallback implementation for MessageFormatter::formatMessage.
- * @param string $pattern The pattern string to insert things into.
- * @param array $args The array of values to insert into the format string
- * @param string $locale The locale to use for formatting locale-dependent parts
- * @return false|string The formatted pattern string or `false` if an error occurred
- */
- protected function fallbackFormat($pattern, $args, $locale)
- {
- if (($tokens = self::tokenizePattern($pattern)) === false) {
- $this->_errorCode = -1;
- $this->_errorMessage = 'Message pattern is invalid.';
- return false;
- }
- foreach ($tokens as $i => $token) {
- if (is_array($token)) {
- if (($tokens[$i] = $this->parseToken($token, $args, $locale)) === false) {
- $this->_errorCode = -1;
- $this->_errorMessage = 'Message pattern is invalid.';
- return false;
- }
- }
- }
- return implode('', $tokens);
- }
- /**
- * Tokenizes a pattern by separating normal text from replaceable patterns.
- * @param string $pattern patter to tokenize
- * @return array|bool array of tokens or false on failure
- */
- private static function tokenizePattern($pattern)
- {
- $charset = Yii::$app ? Yii::$app->charset : 'UTF-8';
- $depth = 1;
- if (($start = $pos = mb_strpos($pattern, '{', 0, $charset)) === false) {
- return [$pattern];
- }
- $tokens = [mb_substr($pattern, 0, $pos, $charset)];
- while (true) {
- $open = mb_strpos($pattern, '{', $pos + 1, $charset);
- $close = mb_strpos($pattern, '}', $pos + 1, $charset);
- if ($open === false && $close === false) {
- break;
- }
- if ($open === false) {
- $open = mb_strlen($pattern, $charset);
- }
- if ($close > $open) {
- $depth++;
- $pos = $open;
- } else {
- $depth--;
- $pos = $close;
- }
- if ($depth === 0) {
- $tokens[] = explode(',', mb_substr($pattern, $start + 1, $pos - $start - 1, $charset), 3);
- $start = $pos + 1;
- $tokens[] = mb_substr($pattern, $start, $open - $start, $charset);
- $start = $open;
- }
- if ($depth !== 0 && ($open === false || $close === false)) {
- break;
- }
- }
- if ($depth !== 0) {
- return false;
- }
- return $tokens;
- }
- /**
- * Parses a token.
- * @param array $token the token to parse
- * @param array $args arguments to replace
- * @param string $locale the locale
- * @return bool|string parsed token or false on failure
- * @throws \yii\base\NotSupportedException when unsupported formatting is used.
- */
- private function parseToken($token, $args, $locale)
- {
- // parsing pattern based on ICU grammar:
- // http://icu-project.org/apiref/icu4c/classMessageFormat.html#details
- $charset = Yii::$app ? Yii::$app->charset : 'UTF-8';
- $param = trim($token[0]);
- if (isset($args[$param])) {
- $arg = $args[$param];
- } else {
- return '{' . implode(',', $token) . '}';
- }
- $type = isset($token[1]) ? trim($token[1]) : 'none';
- switch ($type) {
- case 'date':
- case 'time':
- case 'spellout':
- case 'ordinal':
- case 'duration':
- case 'choice':
- case 'selectordinal':
- throw new NotSupportedException("Message format '$type' is not supported. You have to install PHP intl extension to use this feature.");
- case 'number':
- $format = isset($token[2]) ? trim($token[2]) : null;
- if (is_numeric($arg) && ($format === null || $format === 'integer')) {
- $number = number_format($arg);
- if ($format === null && ($pos = strpos($arg, '.')) !== false) {
- // add decimals with unknown length
- $number .= '.' . substr($arg, $pos + 1);
- }
- return $number;
- }
- throw new NotSupportedException("Message format 'number' is only supported for integer values. You have to install PHP intl extension to use this feature.");
- case 'none':
- return $arg;
- case 'select':
- /* http://icu-project.org/apiref/icu4c/classicu_1_1SelectFormat.html
- selectStyle = (selector '{' message '}')+
- */
- if (!isset($token[2])) {
- return false;
- }
- $select = self::tokenizePattern($token[2]);
- $c = count($select);
- $message = false;
- for ($i = 0; $i + 1 < $c; $i++) {
- if (is_array($select[$i]) || !is_array($select[$i + 1])) {
- return false;
- }
- $selector = trim($select[$i++]);
- if ($message === false && $selector === 'other' || $selector == $arg) {
- $message = implode(',', $select[$i]);
- }
- }
- if ($message !== false) {
- return $this->fallbackFormat($message, $args, $locale);
- }
- break;
- case 'plural':
- /* http://icu-project.org/apiref/icu4c/classicu_1_1PluralFormat.html
- pluralStyle = [offsetValue] (selector '{' message '}')+
- offsetValue = "offset:" number
- selector = explicitValue | keyword
- explicitValue = '=' number // adjacent, no white space in between
- keyword = [^[[:Pattern_Syntax:][:Pattern_White_Space:]]]+
- message: see MessageFormat
- */
- if (!isset($token[2])) {
- return false;
- }
- $plural = self::tokenizePattern($token[2]);
- $c = count($plural);
- $message = false;
- $offset = 0;
- for ($i = 0; $i + 1 < $c; $i++) {
- if (is_array($plural[$i]) || !is_array($plural[$i + 1])) {
- return false;
- }
- $selector = trim($plural[$i++]);
- if ($i == 1 && strncmp($selector, 'offset:', 7) === 0) {
- $offset = (int) trim(mb_substr($selector, 7, ($pos = mb_strpos(str_replace(["\n", "\r", "\t"], ' ', $selector), ' ', 7, $charset)) - 7, $charset));
- $selector = trim(mb_substr($selector, $pos + 1, mb_strlen($selector, $charset), $charset));
- }
- if ($message === false && $selector === 'other' ||
- $selector[0] === '=' && (int) mb_substr($selector, 1, mb_strlen($selector, $charset), $charset) === $arg ||
- $selector === 'one' && $arg - $offset == 1
- ) {
- $message = implode(',', str_replace('#', $arg - $offset, $plural[$i]));
- }
- }
- if ($message !== false) {
- return $this->fallbackFormat($message, $args, $locale);
- }
- break;
- }
- return false;
- }
- }
|