Passed
Push — master ( 7a8e6a...cf836d )
by William
02:48
created

Translator::gettext()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 3
dl 0
loc 7
ccs 4
cts 4
cp 1
c 0
b 0
f 0
rs 10
cc 2
nc 2
nop 1
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
    Copyright (c) 2003, 2009 Danilo Segan <[email protected]>.
7
    Copyright (c) 2005 Nico Kaiser <[email protected]>
8
    Copyright (c) 2016 Michal Čihař <[email protected]>
9
10
    This file is part of MoTranslator.
11
12
    This program is free software; you can redistribute it and/or modify
13
    it under the terms of the GNU General Public License as published by
14
    the Free Software Foundation; either version 2 of the License, or
15
    (at your option) any later version.
16
17
    This program is distributed in the hope that it will be useful,
18
    but WITHOUT ANY WARRANTY; without even the implied warranty of
19
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20
    GNU General Public License for more details.
21
22
    You should have received a copy of the GNU General Public License along
23
    with this program; if not, write to the Free Software Foundation, Inc.,
24
    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
25
*/
26
27
namespace PhpMyAdmin\MoTranslator;
28
29
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
30
use Throwable;
31
use function array_key_exists;
32
use function chr;
33
use function count;
34
use function explode;
35
use function implode;
36
use function intval;
37
use function is_readable;
38
use function ltrim;
39
use function preg_replace;
40
use function rtrim;
41
use function strcmp;
42
use function stripos;
43
use function strpos;
44
use function strtolower;
45
use function substr;
46
use function trim;
47
48
/**
49
 * Provides a simple gettext replacement that works independently from
50
 * the system's gettext abilities.
51
 * It can read MO files and use them for translating strings.
52
 *
53
 * It caches ll strings and translations to speed up the string lookup.
54
 */
55
class Translator
56
{
57
    /**
58
     * None error.
59
     */
60
    public const ERROR_NONE = 0;
61
    /**
62
     * File does not exist.
63
     */
64
    public const ERROR_DOES_NOT_EXIST = 1;
65
    /**
66
     * File has bad magic number.
67
     */
68
    public const ERROR_BAD_MAGIC = 2;
69
    /**
70
     * Error while reading file, probably too short.
71
     */
72
    public const ERROR_READING = 3;
73
74
    /**
75
     * Big endian mo file magic bytes.
76
     */
77
    public const MAGIC_BE = "\x95\x04\x12\xde";
78
    /**
79
     * Little endian mo file magic bytes.
80
     */
81
    public const MAGIC_LE = "\xde\x12\x04\x95";
82
83
    /**
84
     * Parse error code (0 if no error).
85
     *
86
     * @var int
87
     */
88
    public $error = self::ERROR_NONE;
89
90
    /**
91
     * Cache header field for plural forms.
92
     *
93
     * @var string|null
94
     */
95
    private $pluralEquation = null;
96
97
    /** @var ExpressionLanguage|null Evaluator for plurals */
98
    private $pluralExpression = null;
99
100
    /** @var int|null number of plurals */
101
    private $pluralCount = null;
102
103
    /**
104
     * Array with original -> translation mapping.
105
     *
106
     * @var array<string,string>
107
     */
108
    private $cacheTranslations = [];
109
110
    /**
111
     * @param string|null $filename Name of mo file to load (null to not load a file)
112
     */
113 220
    public function __construct(?string $filename)
114
    {
115
        // The user can load the translations manually
116 220
        if ($filename === null) {
117 4
            return;
118
        }
119
120 220
        $this->loadTranslationsFromFile($filename);
121 220
    }
122
123
    /**
124
     * Load a Mo file translations
125
     *
126
     * @param string $filename Name of mo file to load
127
     */
128 220
    private function loadTranslationsFromFile(string $filename): void
129
    {
130 220
        if (! is_readable($filename)) {
131 64
            $this->error = self::ERROR_DOES_NOT_EXIST;
132
133 64
            return;
134
        }
135
136 156
        $stream = new StringReader($filename);
137
138
        try {
139 156
            $magic = $stream->read(0, 4);
140 152
            if (strcmp($magic, self::MAGIC_LE) === 0) {
141 120
                $unpack = 'V';
142 32
            } elseif (strcmp($magic, self::MAGIC_BE) === 0) {
143 28
                $unpack = 'N';
144
            } else {
145 4
                $this->error = self::ERROR_BAD_MAGIC;
146
147 4
                return;
148
            }
149
150
            /* Parse header */
151 148
            $total = $stream->readint($unpack, 8);
152 148
            $originals = $stream->readint($unpack, 12);
153 136
            $translations = $stream->readint($unpack, 16);
154
155
            /* get original and translations tables */
156 136
            $totalTimesTwo = (int) ($total * 2);// Fix for issue #36 on ARM
157 136
            $tableOriginals = $stream->readintarray($unpack, $originals, $totalTimesTwo);
158 132
            $tableTranslations = $stream->readintarray($unpack, $translations, $totalTimesTwo);
159
160
            /* read all strings to the cache */
161 132
            for ($i = 0; $i < $total; ++$i) {
162 132
                $iTimesTwo = $i * 2;
163 132
                $iPlusOne = $iTimesTwo + 1;
164 132
                $iPlusTwo = $iTimesTwo + 2;
165 132
                $original = $stream->read($tableOriginals[$iPlusTwo], $tableOriginals[$iPlusOne]);
166 132
                $translation = $stream->read($tableTranslations[$iPlusTwo], $tableTranslations[$iPlusOne]);
167 132
                $this->cacheTranslations[$original] = $translation;
168
            }
169 20
        } catch (ReaderException $e) {
170 20
            $this->error = self::ERROR_READING;
171
172 20
            return;
173
        }
174 132
    }
175
176
    /**
177
     * Translates a string.
178
     *
179
     * @param string $msgid String to be translated
180
     *
181
     * @return string translated string (or original, if not found)
182
     */
183 124
    public function gettext(string $msgid): string
184
    {
185 124
        if (array_key_exists($msgid, $this->cacheTranslations)) {
186 88
            return $this->cacheTranslations[$msgid];
187
        }
188
189 60
        return $msgid;
190
    }
191
192
    /**
193
     * Check if a string is translated.
194
     *
195
     * @param string $msgid String to be checked
196
     */
197 24
    public function exists(string $msgid): bool
198
    {
199 24
        return array_key_exists($msgid, $this->cacheTranslations);
200
    }
201
202
    /**
203
     * Sanitize plural form expression for use in ExpressionLanguage.
204
     *
205
     * @param string $expr Expression to sanitize
206
     *
207
     * @return string sanitized plural form expression
208
     */
209 92
    public static function sanitizePluralExpression(string $expr): string
210
    {
211
        // Parse equation
212 92
        $expr = explode(';', $expr);
213 92
        if (count($expr) >= 2) {
214 80
            $expr = $expr[1];
215
        } else {
216 12
            $expr = $expr[0];
217
        }
218
219 92
        $expr = trim(strtolower($expr));
220
        // Strip plural prefix
221 92
        if (substr($expr, 0, 6) === 'plural') {
222 84
            $expr = ltrim(substr($expr, 6));
223
        }
224
225
        // Strip equals
226 92
        if (substr($expr, 0, 1) === '=') {
227 84
            $expr = ltrim(substr($expr, 1));
228
        }
229
230
        // Cleanup from unwanted chars
231 92
        $expr = preg_replace('@[^n0-9:\(\)\?=!<>/%&| ]@', '', $expr);
232
233 92
        return (string) $expr;
234
    }
235
236
    /**
237
     * Extracts number of plurals from plurals form expression.
238
     *
239
     * @param string $expr Expression to process
240
     *
241
     * @return int Total number of plurals
242
     */
243 88
    public static function extractPluralCount(string $expr): int
244
    {
245 88
        $parts = explode(';', $expr, 2);
246 88
        $nplurals = explode('=', trim($parts[0]), 2);
247 88
        if (strtolower(rtrim($nplurals[0])) !== 'nplurals') {
248 8
            return 1;
249
        }
250
251 80
        if (count($nplurals) === 1) {
252 4
            return 1;
253
        }
254
255 76
        return intval($nplurals[1]);
256
    }
257
258
    /**
259
     * Parse full PO header and extract only plural forms line.
260
     *
261
     * @param string $header Gettext header
262
     *
263
     * @return string verbatim plural form header field
264
     */
265 80
    public static function extractPluralsForms(string $header): string
266
    {
267 80
        $headers = explode("\n", $header);
268 80
        $expr = 'nplurals=2; plural=n == 1 ? 0 : 1;';
269 80
        foreach ($headers as $header) {
270 80
            if (stripos($header, 'Plural-Forms:') !== 0) {
271 80
                continue;
272
            }
273
274 60
            $expr = substr($header, 13);
275
        }
276
277 80
        return $expr;
278
    }
279
280
    /**
281
     * Get possible plural forms from MO header.
282
     *
283
     * @return string plural form header
284
     */
285 68
    private function getPluralForms(): string
286
    {
287
        // lets assume message number 0 is header
288
        // this is true, right?
289
290
        // cache header field for plural forms
291 68
        if ($this->pluralEquation === null) {
292 64
            if (isset($this->cacheTranslations[''])) {
293 52
                $header = $this->cacheTranslations[''];
294
            } else {
295 12
                $header = '';
296
            }
297
298 64
            $expr = $this->extractPluralsForms($header);
299 64
            $this->pluralEquation = $this->sanitizePluralExpression($expr);
300 64
            $this->pluralCount = $this->extractPluralCount($expr);
301
        }
302
303 68
        return $this->pluralEquation;
304
    }
305
306
    /**
307
     * Detects which plural form to take.
308
     *
309
     * @param int $n count of objects
310
     *
311
     * @return int array index of the right plural form
312
     */
313 68
    private function selectString(int $n): int
314
    {
315 68
        if ($this->pluralExpression === null) {
316 64
            $this->pluralExpression = new ExpressionLanguage();
317
        }
318
319
        try {
320 68
            $plural = (int) $this->pluralExpression->evaluate(
321 68
                $this->getPluralForms(),
322 68
                ['n' => $n]
323
            );
324 4
        } catch (Throwable $e) {
325 4
            $plural = 0;
326
        }
327
328 68
        if ($plural >= $this->pluralCount) {
329 4
            $plural = $this->pluralCount - 1;
330
        }
331
332 68
        return $plural;
333
    }
334
335
    /**
336
     * Plural version of gettext.
337
     *
338
     * @param string $msgid       Single form
339
     * @param string $msgidPlural Plural form
340
     * @param int    $number      Number of objects
341
     *
342
     * @return string translated plural form
343
     */
344 108
    public function ngettext(string $msgid, string $msgidPlural, int $number): string
345
    {
346
        // this should contains all strings separated by NULLs
347 108
        $key = implode(chr(0), [$msgid, $msgidPlural]);
348 108
        if (! array_key_exists($key, $this->cacheTranslations)) {
349 72
            return $number !== 1 ? $msgidPlural : $msgid;
350
        }
351
352
        // find out the appropriate form
353 68
        $select = $this->selectString($number);
354
355 68
        $result = $this->cacheTranslations[$key];
356 68
        $list = explode(chr(0), $result);
357
        // @codeCoverageIgnoreStart
358
        if ($list === false) {
359
            // This was added in 3ff2c63bcf85f81b3a205ce7222de11b33e2bf56 for phpstan
360
            // But according to the php manual it should never happen
361
            return '';
362
        }
363
        // @codeCoverageIgnoreEnd
364
365 68
        if (! isset($list[$select])) {
366 4
            return $list[0];
367
        }
368
369 68
        return $list[$select];
370
    }
371
372
    /**
373
     * Translate with context.
374
     *
375
     * @param string $msgctxt Context
376
     * @param string $msgid   String to be translated
377
     *
378
     * @return string translated plural form
379
     */
380 56
    public function pgettext(string $msgctxt, string $msgid): string
381
    {
382 56
        $key = implode(chr(4), [$msgctxt, $msgid]);
383 56
        $ret = $this->gettext($key);
384 56
        if (strpos($ret, chr(4)) !== false) {
385 24
            return $msgid;
386
        }
387
388 32
        return $ret;
389
    }
390
391
    /**
392
     * Plural version of pgettext.
393
     *
394
     * @param string $msgctxt     Context
395
     * @param string $msgid       Single form
396
     * @param string $msgidPlural Plural form
397
     * @param int    $number      Number of objects
398
     *
399
     * @return string translated plural form
400
     */
401 16
    public function npgettext(string $msgctxt, string $msgid, string $msgidPlural, int $number): string
402
    {
403 16
        $key = implode(chr(4), [$msgctxt, $msgid]);
404 16
        $ret = $this->ngettext($key, $msgidPlural, $number);
405 16
        if (strpos($ret, chr(4)) !== false) {
406 4
            return $msgid;
407
        }
408
409 12
        return $ret;
410
    }
411
412
    /**
413
     * Set translation in place
414
     *
415
     * @param string $msgid  String to be set
416
     * @param string $msgstr Translation
417
     */
418 40
    public function setTranslation(string $msgid, string $msgstr): void
419
    {
420 40
        $this->cacheTranslations[$msgid] = $msgstr;
421 40
    }
422
423
    /**
424
     * Set the translations
425
     *
426
     * @param array<string,string> $translations The translations "key => value" array
427
     */
428 4
    public function setTranslations(array $translations): void
429
    {
430 4
        $this->cacheTranslations = $translations;
431 4
    }
432
433
    /**
434
     * Get the translations
435
     *
436
     * @return array<string,string> The translations "key => value" array
437
     */
438 4
    public function getTranslations(): array
439
    {
440 4
        return $this->cacheTranslations;
441
    }
442
}
443