yii2

Форк
1
/
MessageFormatter.php 
440 строк · 17.3 Кб
1
<?php
2
/**
3
 * @link https://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license https://www.yiiframework.com/license/
6
 */
7

8
namespace yii\i18n;
9

10
use Yii;
11
use yii\base\Component;
12
use yii\base\NotSupportedException;
13

14
/**
15
 * MessageFormatter allows formatting messages via [ICU message format](https://unicode-org.github.io/icu/userguide/format_parse/messages/).
16
 *
17
 * This class enhances the message formatter class provided by the PHP intl extension.
18
 *
19
 * The following enhancements are provided:
20
 *
21
 * - It accepts named arguments and mixed numeric and named arguments.
22
 * - Issues no error when an insufficient number of arguments have been provided. Instead, the placeholders will not be
23
 *   substituted.
24
 * - Fixes PHP 5.5 weird placeholder replacement in case no arguments are provided at all (https://bugs.php.net/bug.php?id=65920).
25
 * - Offers limited support for message formatting in case PHP intl extension is not installed.
26
 *   However it is highly recommended that you install [PHP intl extension](https://www.php.net/manual/en/book.intl.php) if you want
27
 *   to use MessageFormatter features.
28
 *
29
 *   The fallback implementation only supports the following message formats:
30
 *   - plural formatting for english ('one' and 'other' selectors)
31
 *   - select format
32
 *   - simple parameters
33
 *   - integer number parameters
34
 *
35
 *   The fallback implementation does NOT support the ['apostrophe-friendly' syntax](https://www.php.net/manual/en/messageformatter.formatmessage.php).
36
 *   Also messages that are working with the fallback implementation are not necessarily compatible with the
37
 *   PHP intl MessageFormatter so do not rely on the fallback if you are able to install intl extension somehow.
38
 *
39
 * @property-read string $errorCode Code of the last error.
40
 * @property-read string $errorMessage Description of the last error.
41
 *
42
 * @author Alexander Makarov <sam@rmcreative.ru>
43
 * @author Carsten Brandt <mail@cebe.cc>
44
 * @since 2.0
45
 */
46
class MessageFormatter extends Component
47
{
48
    private $_errorCode = 0;
49
    private $_errorMessage = '';
50

51

52
    /**
53
     * Get the error code from the last operation.
54
     * @link https://www.php.net/manual/en/messageformatter.geterrorcode.php
55
     * @return string Code of the last error.
56
     */
57
    public function getErrorCode()
58
    {
59
        return $this->_errorCode;
60
    }
61

62
    /**
63
     * Get the error text from the last operation.
64
     * @link https://www.php.net/manual/en/messageformatter.geterrormessage.php
65
     * @return string Description of the last error.
66
     */
67
    public function getErrorMessage()
68
    {
69
        return $this->_errorMessage;
70
    }
71

72
    /**
73
     * Formats a message via [ICU message format](https://unicode-org.github.io/icu/userguide/format_parse/messages/).
74
     *
75
     * It uses the PHP intl extension's [MessageFormatter](https://www.php.net/manual/en/class.messageformatter.php)
76
     * and works around some issues.
77
     * If PHP intl is not installed a fallback will be used that supports a subset of the ICU message format.
78
     *
79
     * @param string $pattern The pattern string to insert parameters into.
80
     * @param array $params The array of name value pairs to insert into the format string.
81
     * @param string $language The locale to use for formatting locale-dependent parts
82
     * @return string|false The formatted pattern string or `false` if an error occurred
83
     */
84
    public function format($pattern, $params, $language)
85
    {
86
        $this->_errorCode = 0;
87
        $this->_errorMessage = '';
88

89
        if ($params === []) {
90
            return $pattern;
91
        }
92

93
        if (!class_exists('MessageFormatter', false)) {
94
            return $this->fallbackFormat($pattern, $params, $language);
95
        }
96

97
        // replace named arguments (https://github.com/yiisoft/yii2/issues/9678)
98
        $newParams = [];
99
        $pattern = $this->replaceNamedArguments($pattern, $params, $newParams);
100
        $params = $newParams;
101

102
        try {
103
            $formatter = new \MessageFormatter($language, $pattern);
104

105
            if ($formatter === null) {
106
                // formatter may be null in PHP 5.x
107
                $this->_errorCode = intl_get_error_code();
108
                $this->_errorMessage = 'Message pattern is invalid: ' . intl_get_error_message();
109
                return false;
110
            }
111
        } catch (\IntlException $e) {
112
            // IntlException is thrown since PHP 7
113
            $this->_errorCode = $e->getCode();
114
            $this->_errorMessage = 'Message pattern is invalid: ' . $e->getMessage();
115
            return false;
116
        } catch (\Exception $e) {
117
            // Exception is thrown by HHVM
118
            $this->_errorCode = $e->getCode();
119
            $this->_errorMessage = 'Message pattern is invalid: ' . $e->getMessage();
120
            return false;
121
        }
122

123
        $result = $formatter->format($params);
124

125
        if ($result === false) {
126
            $this->_errorCode = $formatter->getErrorCode();
127
            $this->_errorMessage = $formatter->getErrorMessage();
128
            return false;
129
        }
130

131
        return $result;
132
    }
133

134
    /**
135
     * Parses an input string according to an [ICU message format](https://unicode-org.github.io/icu/userguide/format_parse/messages/) pattern.
136
     *
137
     * It uses the PHP intl extension's [MessageFormatter::parse()](https://www.php.net/manual/en/messageformatter.parsemessage.php)
138
     * and adds support for named arguments.
139
     * Usage of this method requires PHP intl extension to be installed.
140
     *
141
     * @param string $pattern The pattern to use for parsing the message.
142
     * @param string $message The message to parse, conforming to the pattern.
143
     * @param string $language The locale to use for formatting locale-dependent parts
144
     * @return array|bool An array containing items extracted, or `FALSE` on error.
145
     * @throws \yii\base\NotSupportedException when PHP intl extension is not installed.
146
     */
147
    public function parse($pattern, $message, $language)
148
    {
149
        $this->_errorCode = 0;
150
        $this->_errorMessage = '';
151

152
        if (!class_exists('MessageFormatter', false)) {
153
            throw new NotSupportedException('You have to install PHP intl extension to use this feature.');
154
        }
155

156
        // replace named arguments
157
        if (($tokens = self::tokenizePattern($pattern)) === false) {
158
            $this->_errorCode = -1;
159
            $this->_errorMessage = 'Message pattern is invalid.';
160

161
            return false;
162
        }
163
        $map = [];
164
        foreach ($tokens as $i => $token) {
165
            if (is_array($token)) {
166
                $param = trim($token[0]);
167
                if (!isset($map[$param])) {
168
                    $map[$param] = count($map);
169
                }
170
                $token[0] = $map[$param];
171
                $tokens[$i] = '{' . implode(',', $token) . '}';
172
            }
173
        }
174
        $pattern = implode('', $tokens);
175
        $map = array_flip($map);
176

177
        $formatter = new \MessageFormatter($language, $pattern);
178
        if ($formatter === null) {
179
            $this->_errorCode = -1;
180
            $this->_errorMessage = 'Message pattern is invalid.';
181

182
            return false;
183
        }
184
        $result = $formatter->parse($message);
185
        if ($result === false) {
186
            $this->_errorCode = $formatter->getErrorCode();
187
            $this->_errorMessage = $formatter->getErrorMessage();
188

189
            return false;
190
        }
191

192
        $values = [];
193
        foreach ($result as $key => $value) {
194
            $values[$map[$key]] = $value;
195
        }
196

197
        return $values;
198
    }
199

200
    /**
201
     * Replace named placeholders with numeric placeholders and quote unused.
202
     *
203
     * @param string $pattern The pattern string to replace things into.
204
     * @param array $givenParams The array of values to insert into the format string.
205
     * @param array $resultingParams Modified array of parameters.
206
     * @param array $map
207
     * @return string The pattern string with placeholders replaced.
208
     */
209
    private function replaceNamedArguments($pattern, $givenParams, &$resultingParams = [], &$map = [])
210
    {
211
        if (($tokens = self::tokenizePattern($pattern)) === false) {
212
            return false;
213
        }
214
        foreach ($tokens as $i => $token) {
215
            if (!is_array($token)) {
216
                continue;
217
            }
218
            $param = trim($token[0]);
219
            if (array_key_exists($param, $givenParams)) {
220
                // if param is given, replace it with a number
221
                if (!isset($map[$param])) {
222
                    $map[$param] = count($map);
223
                    // make sure only used params are passed to format method
224
                    $resultingParams[$map[$param]] = $givenParams[$param];
225
                }
226
                $token[0] = $map[$param];
227
                $quote = '';
228
            } else {
229
                // quote unused token
230
                $quote = "'";
231
            }
232
            $type = isset($token[1]) ? trim($token[1]) : 'none';
233
            // replace plural and select format recursively
234
            if ($type === 'plural' || $type === 'select') {
235
                if (!isset($token[2])) {
236
                    return false;
237
                }
238
                if (($subtokens = self::tokenizePattern($token[2])) === false) {
239
                    return false;
240
                }
241
                $c = count($subtokens);
242
                for ($k = 0; $k + 1 < $c; $k++) {
243
                    if (is_array($subtokens[$k]) || !is_array($subtokens[++$k])) {
244
                        return false;
245
                    }
246
                    $subpattern = $this->replaceNamedArguments(implode(',', $subtokens[$k]), $givenParams, $resultingParams, $map);
247
                    $subtokens[$k] = $quote . '{' . $quote . $subpattern . $quote . '}' . $quote;
248
                }
249
                $token[2] = implode('', $subtokens);
250
            }
251
            $tokens[$i] = $quote . '{' . $quote . implode(',', $token) . $quote . '}' . $quote;
252
        }
253

254
        return implode('', $tokens);
255
    }
256

257
    /**
258
     * Fallback implementation for MessageFormatter::formatMessage.
259
     * @param string $pattern The pattern string to insert things into.
260
     * @param array $args The array of values to insert into the format string
261
     * @param string $locale The locale to use for formatting locale-dependent parts
262
     * @return false|string The formatted pattern string or `false` if an error occurred
263
     */
264
    protected function fallbackFormat($pattern, $args, $locale)
265
    {
266
        if (($tokens = self::tokenizePattern($pattern)) === false) {
267
            $this->_errorCode = -1;
268
            $this->_errorMessage = 'Message pattern is invalid.';
269

270
            return false;
271
        }
272
        foreach ($tokens as $i => $token) {
273
            if (is_array($token)) {
274
                if (($tokens[$i] = $this->parseToken($token, $args, $locale)) === false) {
275
                    $this->_errorCode = -1;
276
                    $this->_errorMessage = 'Message pattern is invalid.';
277

278
                    return false;
279
                }
280
            }
281
        }
282

283
        return implode('', $tokens);
284
    }
285

286
    /**
287
     * Tokenizes a pattern by separating normal text from replaceable patterns.
288
     * @param string $pattern patter to tokenize
289
     * @return array|bool array of tokens or false on failure
290
     */
291
    private static function tokenizePattern($pattern)
292
    {
293
        $charset = Yii::$app ? Yii::$app->charset : 'UTF-8';
294
        $depth = 1;
295
        if (($start = $pos = mb_strpos($pattern, '{', 0, $charset)) === false) {
296
            return [$pattern];
297
        }
298
        $tokens = [mb_substr($pattern, 0, $pos, $charset)];
299
        while (true) {
300
            $open = mb_strpos($pattern, '{', $pos + 1, $charset);
301
            $close = mb_strpos($pattern, '}', $pos + 1, $charset);
302
            if ($open === false && $close === false) {
303
                break;
304
            }
305
            if ($open === false) {
306
                $open = mb_strlen($pattern, $charset);
307
            }
308
            if ($close > $open) {
309
                $depth++;
310
                $pos = $open;
311
            } else {
312
                $depth--;
313
                $pos = $close;
314
            }
315
            if ($depth === 0) {
316
                $tokens[] = explode(',', mb_substr($pattern, $start + 1, $pos - $start - 1, $charset), 3);
317
                $start = $pos + 1;
318
                $tokens[] = mb_substr($pattern, $start, $open - $start, $charset);
319
                $start = $open;
320
            }
321

322
            if ($depth !== 0 && ($open === false || $close === false)) {
323
                break;
324
            }
325
        }
326
        if ($depth !== 0) {
327
            return false;
328
        }
329

330
        return $tokens;
331
    }
332

333
    /**
334
     * Parses a token.
335
     * @param array $token the token to parse
336
     * @param array $args arguments to replace
337
     * @param string $locale the locale
338
     * @return bool|string parsed token or false on failure
339
     * @throws \yii\base\NotSupportedException when unsupported formatting is used.
340
     */
341
    private function parseToken($token, $args, $locale)
342
    {
343
        // parsing pattern based on ICU grammar:
344
        // https://unicode-org.github.io/icu-docs/#/icu4c/classMessageFormat.html
345
        $charset = Yii::$app ? Yii::$app->charset : 'UTF-8';
346
        $param = trim($token[0]);
347
        if (isset($args[$param])) {
348
            $arg = $args[$param];
349
        } else {
350
            return '{' . implode(',', $token) . '}';
351
        }
352
        $type = isset($token[1]) ? trim($token[1]) : 'none';
353
        switch ($type) {
354
            case 'date':
355
            case 'time':
356
            case 'spellout':
357
            case 'ordinal':
358
            case 'duration':
359
            case 'choice':
360
            case 'selectordinal':
361
                throw new NotSupportedException("Message format '$type' is not supported. You have to install PHP intl extension to use this feature.");
362
            case 'number':
363
                $format = isset($token[2]) ? trim($token[2]) : null;
364
                if (is_numeric($arg) && ($format === null || $format === 'integer')) {
365
                    $number = number_format($arg);
366
                    if ($format === null && ($pos = strpos($arg, '.')) !== false) {
367
                        // add decimals with unknown length
368
                        $number .= '.' . substr($arg, $pos + 1);
369
                    }
370

371
                    return $number;
372
                }
373
                throw new NotSupportedException("Message format 'number' is only supported for integer values. You have to install PHP intl extension to use this feature.");
374
            case 'none':
375
                return $arg;
376
            case 'select':
377
                /* https://unicode-org.github.io/icu-docs/#/icu4c/classicu_1_1SelectFormat.html
378
                selectStyle = (selector '{' message '}')+
379
                */
380
                if (!isset($token[2])) {
381
                    return false;
382
                }
383
                $select = self::tokenizePattern($token[2]);
384
                $c = count($select);
385
                $message = false;
386
                for ($i = 0; $i + 1 < $c; $i++) {
387
                    if (is_array($select[$i]) || !is_array($select[$i + 1])) {
388
                        return false;
389
                    }
390
                    $selector = trim($select[$i++]);
391
                    if ($message === false && $selector === 'other' || $selector == $arg) {
392
                        $message = implode(',', $select[$i]);
393
                    }
394
                }
395
                if ($message !== false) {
396
                    return $this->fallbackFormat($message, $args, $locale);
397
                }
398
                break;
399
            case 'plural':
400
                /* https://unicode-org.github.io/icu-docs/#/icu4c/classicu_1_1PluralFormat.html
401
                pluralStyle = [offsetValue] (selector '{' message '}')+
402
                offsetValue = "offset:" number
403
                selector = explicitValue | keyword
404
                explicitValue = '=' number  // adjacent, no white space in between
405
                keyword = [^[[:Pattern_Syntax:][:Pattern_White_Space:]]]+
406
                message: see MessageFormat
407
                */
408
                if (!isset($token[2])) {
409
                    return false;
410
                }
411
                $plural = self::tokenizePattern($token[2]);
412
                $c = count($plural);
413
                $message = false;
414
                $offset = 0;
415
                for ($i = 0; $i + 1 < $c; $i++) {
416
                    if (is_array($plural[$i]) || !is_array($plural[$i + 1])) {
417
                        return false;
418
                    }
419
                    $selector = trim($plural[$i++]);
420

421
                    if ($i == 1 && strncmp($selector, 'offset:', 7) === 0) {
422
                        $offset = (int) trim(mb_substr($selector, 7, ($pos = mb_strpos(str_replace(["\n", "\r", "\t"], ' ', $selector), ' ', 7, $charset)) - 7, $charset));
423
                        $selector = trim(mb_substr($selector, $pos + 1, mb_strlen($selector, $charset), $charset));
424
                    }
425
                    if ($message === false && $selector === 'other' ||
426
                        strncmp($selector, '=', 1) === 0 && (int) mb_substr($selector, 1, mb_strlen($selector, $charset), $charset) === $arg ||
427
                        $selector === 'one' && $arg - $offset == 1
428
                    ) {
429
                        $message = implode(',', str_replace('#', $arg - $offset, $plural[$i]));
430
                    }
431
                }
432
                if ($message !== false) {
433
                    return $this->fallbackFormat($message, $args, $locale);
434
                }
435
                break;
436
        }
437

438
        return false;
439
    }
440
}
441

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.