xref: /webtrees/app/I18N.php (revision 69253da94e62dd6504af7726c454c9dfe10fe7b6)
1a25f0a04SGreg Roach<?php
2a25f0a04SGreg Roach/**
3a25f0a04SGreg Roach * webtrees: online genealogy
48fcd0d32SGreg Roach * Copyright (C) 2019 webtrees development team
5a25f0a04SGreg Roach * This program is free software: you can redistribute it and/or modify
6a25f0a04SGreg Roach * it under the terms of the GNU General Public License as published by
7a25f0a04SGreg Roach * the Free Software Foundation, either version 3 of the License, or
8a25f0a04SGreg Roach * (at your option) any later version.
9a25f0a04SGreg Roach * This program is distributed in the hope that it will be useful,
10a25f0a04SGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of
11a25f0a04SGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12a25f0a04SGreg Roach * GNU General Public License for more details.
13a25f0a04SGreg Roach * You should have received a copy of the GNU General Public License
14a25f0a04SGreg Roach * along with this program. If not, see <http://www.gnu.org/licenses/>.
15a25f0a04SGreg Roach */
16e7f56f2aSGreg Roachdeclare(strict_types=1);
17e7f56f2aSGreg Roach
1876692c8bSGreg Roachnamespace Fisharebest\Webtrees;
19a25f0a04SGreg Roach
20991b93ddSGreg Roachuse Collator;
21f1af7e1cSGreg Roachuse Exception;
22c999a340SGreg Roachuse Fisharebest\Localization\Locale;
231e71bdc0SGreg Roachuse Fisharebest\Localization\Locale\LocaleEnUs;
2415834aaeSGreg Roachuse Fisharebest\Localization\Locale\LocaleInterface;
253bdc890bSGreg Roachuse Fisharebest\Localization\Translation;
263bdc890bSGreg Roachuse Fisharebest\Localization\Translator;
27d37db671SGreg Roachuse Fisharebest\Webtrees\Module\ModuleCustomInterface;
2802086832SGreg Roachuse Fisharebest\Webtrees\Module\ModuleLanguageInterface;
29d37db671SGreg Roachuse Fisharebest\Webtrees\Services\ModuleService;
306cd97bf6SGreg Roachuse Illuminate\Support\Collection;
314f194b97SGreg Roachuse function array_merge;
324f194b97SGreg Roachuse function filemtime;
33a25f0a04SGreg Roach
34a25f0a04SGreg Roach/**
3576692c8bSGreg Roach * Internationalization (i18n) and localization (l10n).
36a25f0a04SGreg Roach */
37c1010edaSGreg Roachclass I18N
38c1010edaSGreg Roach{
39d37db671SGreg Roach    // MO files use special characters for plurals and context.
404f194b97SGreg Roach    public const PLURAL  = "\x00";
414f194b97SGreg Roach    public const CONTEXT = "\x04";
42d37db671SGreg Roach
4315834aaeSGreg Roach    /** @var LocaleInterface The current locale (e.g. LocaleEnGb) */
44c999a340SGreg Roach    private static $locale;
45c999a340SGreg Roach
4676692c8bSGreg Roach    /** @var Translator An object that performs translation */
473bdc890bSGreg Roach    private static $translator;
483bdc890bSGreg Roach
49c9ec599fSGreg Roach    /** @var  Collator|null From the php-intl library */
50991b93ddSGreg Roach    private static $collator;
51991b93ddSGreg Roach
52a25f0a04SGreg Roach    // Digits are always rendered LTR, even in RTL text.
5316d6367aSGreg Roach    private const DIGITS = '0123456789٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹';
54a25f0a04SGreg Roach
55991b93ddSGreg Roach    // These locales need special handling for the dotless letter I.
5616d6367aSGreg Roach    private const DOTLESS_I_LOCALES = [
57c1010edaSGreg Roach        'az',
58c1010edaSGreg Roach        'tr',
59c1010edaSGreg Roach    ];
6016d6367aSGreg Roach    private const DOTLESS_I_TOLOWER = [
61c1010edaSGreg Roach        'I' => 'ı',
62c1010edaSGreg Roach        'İ' => 'i',
63c1010edaSGreg Roach    ];
6416d6367aSGreg Roach    private const DOTLESS_I_TOUPPER = [
65c1010edaSGreg Roach        'ı' => 'I',
66c1010edaSGreg Roach        'i' => 'İ',
67c1010edaSGreg Roach    ];
68a25f0a04SGreg Roach
69991b93ddSGreg Roach    // The ranges of characters used by each script.
7016d6367aSGreg Roach    private const SCRIPT_CHARACTER_RANGES = [
71c1010edaSGreg Roach        [
72c1010edaSGreg Roach            'Latn',
73c1010edaSGreg Roach            0x0041,
74c1010edaSGreg Roach            0x005A,
75c1010edaSGreg Roach        ],
76c1010edaSGreg Roach        [
77c1010edaSGreg Roach            'Latn',
78c1010edaSGreg Roach            0x0061,
79c1010edaSGreg Roach            0x007A,
80c1010edaSGreg Roach        ],
81c1010edaSGreg Roach        [
82c1010edaSGreg Roach            'Latn',
83c1010edaSGreg Roach            0x0100,
84c1010edaSGreg Roach            0x02AF,
85c1010edaSGreg Roach        ],
86c1010edaSGreg Roach        [
87c1010edaSGreg Roach            'Grek',
88c1010edaSGreg Roach            0x0370,
89c1010edaSGreg Roach            0x03FF,
90c1010edaSGreg Roach        ],
91c1010edaSGreg Roach        [
92c1010edaSGreg Roach            'Cyrl',
93c1010edaSGreg Roach            0x0400,
94c1010edaSGreg Roach            0x052F,
95c1010edaSGreg Roach        ],
96c1010edaSGreg Roach        [
97c1010edaSGreg Roach            'Hebr',
98c1010edaSGreg Roach            0x0590,
99c1010edaSGreg Roach            0x05FF,
100c1010edaSGreg Roach        ],
101c1010edaSGreg Roach        [
102c1010edaSGreg Roach            'Arab',
103c1010edaSGreg Roach            0x0600,
104c1010edaSGreg Roach            0x06FF,
105c1010edaSGreg Roach        ],
106c1010edaSGreg Roach        [
107c1010edaSGreg Roach            'Arab',
108c1010edaSGreg Roach            0x0750,
109c1010edaSGreg Roach            0x077F,
110c1010edaSGreg Roach        ],
111c1010edaSGreg Roach        [
112c1010edaSGreg Roach            'Arab',
113c1010edaSGreg Roach            0x08A0,
114c1010edaSGreg Roach            0x08FF,
115c1010edaSGreg Roach        ],
116c1010edaSGreg Roach        [
117c1010edaSGreg Roach            'Deva',
118c1010edaSGreg Roach            0x0900,
119c1010edaSGreg Roach            0x097F,
120c1010edaSGreg Roach        ],
121c1010edaSGreg Roach        [
122c1010edaSGreg Roach            'Taml',
123c1010edaSGreg Roach            0x0B80,
124c1010edaSGreg Roach            0x0BFF,
125c1010edaSGreg Roach        ],
126c1010edaSGreg Roach        [
127c1010edaSGreg Roach            'Sinh',
128c1010edaSGreg Roach            0x0D80,
129c1010edaSGreg Roach            0x0DFF,
130c1010edaSGreg Roach        ],
131c1010edaSGreg Roach        [
132c1010edaSGreg Roach            'Thai',
133c1010edaSGreg Roach            0x0E00,
134c1010edaSGreg Roach            0x0E7F,
135c1010edaSGreg Roach        ],
136c1010edaSGreg Roach        [
137c1010edaSGreg Roach            'Geor',
138c1010edaSGreg Roach            0x10A0,
139c1010edaSGreg Roach            0x10FF,
140c1010edaSGreg Roach        ],
141c1010edaSGreg Roach        [
142c1010edaSGreg Roach            'Grek',
143c1010edaSGreg Roach            0x1F00,
144c1010edaSGreg Roach            0x1FFF,
145c1010edaSGreg Roach        ],
146c1010edaSGreg Roach        [
147c1010edaSGreg Roach            'Deva',
148c1010edaSGreg Roach            0xA8E0,
149c1010edaSGreg Roach            0xA8FF,
150c1010edaSGreg Roach        ],
151c1010edaSGreg Roach        [
152c1010edaSGreg Roach            'Hans',
153c1010edaSGreg Roach            0x3000,
154c1010edaSGreg Roach            0x303F,
155c1010edaSGreg Roach        ],
156c1010edaSGreg Roach        // Mixed CJK, not just Hans
157c1010edaSGreg Roach        [
158c1010edaSGreg Roach            'Hans',
159c1010edaSGreg Roach            0x3400,
160c1010edaSGreg Roach            0xFAFF,
161c1010edaSGreg Roach        ],
162c1010edaSGreg Roach        // Mixed CJK, not just Hans
163c1010edaSGreg Roach        [
164c1010edaSGreg Roach            'Hans',
165c1010edaSGreg Roach            0x20000,
166c1010edaSGreg Roach            0x2FA1F,
167c1010edaSGreg Roach        ],
168c1010edaSGreg Roach        // Mixed CJK, not just Hans
16913abd6f3SGreg Roach    ];
170a25f0a04SGreg Roach
171991b93ddSGreg Roach    // Characters that are displayed in mirror form in RTL text.
17216d6367aSGreg Roach    private const MIRROR_CHARACTERS = [
173a25f0a04SGreg Roach        '('  => ')',
174a25f0a04SGreg Roach        ')'  => '(',
175a25f0a04SGreg Roach        '['  => ']',
176a25f0a04SGreg Roach        ']'  => '[',
177a25f0a04SGreg Roach        '{'  => '}',
178a25f0a04SGreg Roach        '}'  => '{',
179a25f0a04SGreg Roach        '<'  => '>',
180a25f0a04SGreg Roach        '>'  => '<',
181a25f0a04SGreg Roach        '‹ ' => '›',
182a25f0a04SGreg Roach        '› ' => '‹',
183a25f0a04SGreg Roach        '«'  => '»',
184a25f0a04SGreg Roach        '»'  => '«',
185a25f0a04SGreg Roach        '﴾ ' => '﴿',
186a25f0a04SGreg Roach        '﴿ ' => '﴾',
187a25f0a04SGreg Roach        '“ ' => '”',
188a25f0a04SGreg Roach        '” ' => '“',
189a25f0a04SGreg Roach        '‘ ' => '’',
190a25f0a04SGreg Roach        '’ ' => '‘',
19113abd6f3SGreg Roach    ];
192a25f0a04SGreg Roach
193a25f0a04SGreg Roach    /** @var string Punctuation used to separate list items, typically a comma */
194a25f0a04SGreg Roach    public static $list_separator;
195a25f0a04SGreg Roach
196a25f0a04SGreg Roach    /**
19702086832SGreg Roach     * The preferred locales for this site, or a default list if no preference.
198dfeee0a8SGreg Roach     *
199dfeee0a8SGreg Roach     * @return LocaleInterface[]
200dfeee0a8SGreg Roach     */
2018f53f488SRico Sonntag    public static function activeLocales(): array
202c1010edaSGreg Roach    {
20302086832SGreg Roach        $locales = app(ModuleService::class)
204d6137952SGreg Roach            ->findByInterface(ModuleLanguageInterface::class, false, true)
2050b5fd0a6SGreg Roach            ->map(static function (ModuleLanguageInterface $module): LocaleInterface {
20602086832SGreg Roach                return $module->locale();
20702086832SGreg Roach            });
208dfeee0a8SGreg Roach
20902086832SGreg Roach        if ($locales->isEmpty()) {
21002086832SGreg Roach            return [new LocaleEnUs()];
211dfeee0a8SGreg Roach        }
212dfeee0a8SGreg Roach
21302086832SGreg Roach        return $locales->all();
214dfeee0a8SGreg Roach    }
215dfeee0a8SGreg Roach
216dfeee0a8SGreg Roach    /**
217dfeee0a8SGreg Roach     * Which MySQL collation should be used for this locale?
218dfeee0a8SGreg Roach     *
219dfeee0a8SGreg Roach     * @return string
220dfeee0a8SGreg Roach     */
221e364afe4SGreg Roach    public static function collation(): string
222c1010edaSGreg Roach    {
223dfeee0a8SGreg Roach        $collation = self::$locale->collation();
224dfeee0a8SGreg Roach        switch ($collation) {
225dfeee0a8SGreg Roach            case 'croatian_ci':
226dfeee0a8SGreg Roach            case 'german2_ci':
227dfeee0a8SGreg Roach            case 'vietnamese_ci':
228dfeee0a8SGreg Roach                // Only available in MySQL 5.6
229dfeee0a8SGreg Roach                return 'utf8_unicode_ci';
230dfeee0a8SGreg Roach            default:
231dfeee0a8SGreg Roach                return 'utf8_' . $collation;
232dfeee0a8SGreg Roach        }
233dfeee0a8SGreg Roach    }
234dfeee0a8SGreg Roach
235dfeee0a8SGreg Roach    /**
236dfeee0a8SGreg Roach     * What format is used to display dates in the current locale?
237dfeee0a8SGreg Roach     *
238dfeee0a8SGreg Roach     * @return string
239dfeee0a8SGreg Roach     */
2408f53f488SRico Sonntag    public static function dateFormat(): string
241c1010edaSGreg Roach    {
242bbb76c12SGreg Roach        /* I18N: This is the format string for full dates. See http://php.net/date for codes */
243bbb76c12SGreg Roach        return self::$translator->translate('%j %F %Y');
244dfeee0a8SGreg Roach    }
245dfeee0a8SGreg Roach
246dfeee0a8SGreg Roach    /**
247dfeee0a8SGreg Roach     * Convert the digits 0-9 into the local script
248dfeee0a8SGreg Roach     * Used for years, etc., where we do not want thousands-separators, decimals, etc.
249dfeee0a8SGreg Roach     *
25055664801SGreg Roach     * @param string|int $n
251dfeee0a8SGreg Roach     *
252dfeee0a8SGreg Roach     * @return string
253dfeee0a8SGreg Roach     */
2548f53f488SRico Sonntag    public static function digits($n): string
255c1010edaSGreg Roach    {
25655664801SGreg Roach        return self::$locale->digits((string) $n);
257dfeee0a8SGreg Roach    }
258dfeee0a8SGreg Roach
259dfeee0a8SGreg Roach    /**
260dfeee0a8SGreg Roach     * What is the direction of the current locale
261dfeee0a8SGreg Roach     *
262dfeee0a8SGreg Roach     * @return string "ltr" or "rtl"
263dfeee0a8SGreg Roach     */
2648f53f488SRico Sonntag    public static function direction(): string
265c1010edaSGreg Roach    {
266dfeee0a8SGreg Roach        return self::$locale->direction();
267dfeee0a8SGreg Roach    }
268dfeee0a8SGreg Roach
269dfeee0a8SGreg Roach    /**
2707231a557SGreg Roach     * What is the first day of the week.
2717231a557SGreg Roach     *
272cbc1590aSGreg Roach     * @return int Sunday=0, Monday=1, etc.
2737231a557SGreg Roach     */
2748f53f488SRico Sonntag    public static function firstDay(): int
275c1010edaSGreg Roach    {
2767231a557SGreg Roach        return self::$locale->territory()->firstDay();
2777231a557SGreg Roach    }
2787231a557SGreg Roach
2797231a557SGreg Roach    /**
280dfeee0a8SGreg Roach     * Generate i18n markup for the <html> tag, e.g. lang="ar" dir="rtl"
281dfeee0a8SGreg Roach     *
282dfeee0a8SGreg Roach     * @return string
283dfeee0a8SGreg Roach     */
2848f53f488SRico Sonntag    public static function htmlAttributes(): string
285c1010edaSGreg Roach    {
286dfeee0a8SGreg Roach        return self::$locale->htmlAttributes();
287dfeee0a8SGreg Roach    }
288dfeee0a8SGreg Roach
289dfeee0a8SGreg Roach    /**
290a25f0a04SGreg Roach     * Initialise the translation adapter with a locale setting.
291a25f0a04SGreg Roach     *
29215d603e7SGreg Roach     * @param string    $code  Use this locale/language code, or choose one automatically
293e58a20ffSGreg Roach     * @param Tree|null $tree
294c116a5ccSGreg Roach     * @param bool      $setup During setup, we cannot access the database.
295a25f0a04SGreg Roach     *
296a25f0a04SGreg Roach     * @return string $string
297a25f0a04SGreg Roach     */
298081ddc56SGreg Roach    public static function init(string $code = '', Tree $tree = null, $setup = false): string
299c1010edaSGreg Roach    {
30015d603e7SGreg Roach        if ($code !== '') {
3013bdc890bSGreg Roach            // Create the specified locale
3023bdc890bSGreg Roach            self::$locale = Locale::create($code);
3034ee95e68SRico Sonntag        } elseif (Session::has('locale') && file_exists(WT_ROOT . 'resources/lang/' . Session::get('locale') . '/messages.mo')) {
304e58a20ffSGreg Roach            // Select a previously used locale
30531bc7874SGreg Roach            self::$locale = Locale::create(Session::get('locale'));
3063bdc890bSGreg Roach        } else {
307e58a20ffSGreg Roach            if ($tree instanceof Tree) {
308e58a20ffSGreg Roach                $default_locale = Locale::create($tree->getPreference('LANGUAGE', 'en-US'));
309e58a20ffSGreg Roach            } else {
31059f2f229SGreg Roach                $default_locale = new LocaleEnUs();
3113bdc890bSGreg Roach            }
312e58a20ffSGreg Roach
313e58a20ffSGreg Roach            // Negotiate with the browser.
314e58a20ffSGreg Roach            // Search engines don't negotiate.  They get the default locale of the tree.
315c116a5ccSGreg Roach            if ($setup) {
316c116a5ccSGreg Roach                $installed_locales = app(ModuleService::class)->setupLanguages()
3170b5fd0a6SGreg Roach                    ->map(static function (ModuleLanguageInterface $module): LocaleInterface {
318c116a5ccSGreg Roach                        return $module->locale();
319c116a5ccSGreg Roach                    });
320c116a5ccSGreg Roach            } else {
321c116a5ccSGreg Roach                $installed_locales = self::installedLocales();
322c116a5ccSGreg Roach            }
323c116a5ccSGreg Roach
324c116a5ccSGreg Roach            self::$locale = Locale::httpAcceptLanguage($_SERVER, $installed_locales->all(), $default_locale);
3253bdc890bSGreg Roach        }
3263bdc890bSGreg Roach
327f1af7e1cSGreg Roach        $cache_dir  = WT_DATA_DIR . 'cache/';
328f1af7e1cSGreg Roach        $cache_file = $cache_dir . 'language-' . self::$locale->languageTag() . '-cache.php';
3293bdc890bSGreg Roach        if (file_exists($cache_file)) {
3303bdc890bSGreg Roach            $filemtime = filemtime($cache_file);
3313bdc890bSGreg Roach        } else {
3323bdc890bSGreg Roach            $filemtime = 0;
3333bdc890bSGreg Roach        }
3343bdc890bSGreg Roach
3354f194b97SGreg Roach        // Load the translation file
3364f194b97SGreg Roach        $translation_file = WT_ROOT . 'resources/lang/' . self::$locale->languageTag() . '/messages.mo';
337362b8464SGreg Roach
3384f194b97SGreg Roach        // Rebuild files if the translation file has been updated
3393bdc890bSGreg Roach        if (filemtime($translation_file) > $filemtime) {
3403bdc890bSGreg Roach            $translation  = new Translation($translation_file);
3414f194b97SGreg Roach            $translations = $translation->asArray();
3424f194b97SGreg Roach
343f1af7e1cSGreg Roach            try {
344f1af7e1cSGreg Roach                File::mkdir($cache_dir);
345f1af7e1cSGreg Roach                file_put_contents($cache_file, '<?php return ' . var_export($translations, true) . ';');
346f1af7e1cSGreg Roach            } catch (Exception $ex) {
3477c2999b4SGreg Roach                // During setup, we may not have been able to create it.
348c85fb0c4SGreg Roach            }
3493bdc890bSGreg Roach        } else {
3503bdc890bSGreg Roach            $translations = include $cache_file;
351a25f0a04SGreg Roach        }
352a25f0a04SGreg Roach
3534f194b97SGreg Roach        // Add translations from custom modules (but not during setup, as we have no database/modules)
354c116a5ccSGreg Roach        if (!$setup) {
3554f194b97SGreg Roach            $translations = app(ModuleService::class)
3564f194b97SGreg Roach                ->findByInterface(ModuleCustomInterface::class)
357*69253da9SGreg Roach                ->reduce(static function (array $carry, ModuleCustomInterface $item): array {
3584f194b97SGreg Roach                    return array_merge($carry, $item->customTranslations(self::$locale->languageTag()));
3594f194b97SGreg Roach                }, $translations);
360d37db671SGreg Roach        }
361d37db671SGreg Roach
3623bdc890bSGreg Roach        // Create a translator
3633bdc890bSGreg Roach        self::$translator = new Translator($translations, self::$locale->pluralRule());
364a25f0a04SGreg Roach
365bbb76c12SGreg Roach        /* I18N: This punctuation is used to separate lists of items */
366bbb76c12SGreg Roach        self::$list_separator = self::translate(', ');
367a25f0a04SGreg Roach
368991b93ddSGreg Roach        // Create a collator
369991b93ddSGreg Roach        try {
370444a65ecSGreg Roach            if (class_exists('Collator')) {
371c9ec599fSGreg Roach                // Symfony provides a very incomplete polyfill - which cannot be used.
372991b93ddSGreg Roach                self::$collator = new Collator(self::$locale->code());
373991b93ddSGreg Roach                // Ignore upper/lower case differences
374991b93ddSGreg Roach                self::$collator->setStrength(Collator::SECONDARY);
375444a65ecSGreg Roach            }
376991b93ddSGreg Roach        } catch (Exception $ex) {
377991b93ddSGreg Roach            // PHP-INTL is not installed?  We'll use a fallback later.
378c9ec599fSGreg Roach            self::$collator = null;
379991b93ddSGreg Roach        }
380991b93ddSGreg Roach
3815331c5eaSGreg Roach        return self::$locale->languageTag();
382a25f0a04SGreg Roach    }
383a25f0a04SGreg Roach
384a25f0a04SGreg Roach    /**
385c999a340SGreg Roach     * All locales for which a translation file exists.
386c999a340SGreg Roach     *
387c116a5ccSGreg Roach     * @return Collection
38815834aaeSGreg Roach     * @return LocaleInterface[]
389c999a340SGreg Roach     */
390c116a5ccSGreg Roach    public static function installedLocales(): Collection
391c1010edaSGreg Roach    {
39202086832SGreg Roach        return app(ModuleService::class)
39302086832SGreg Roach            ->findByInterface(ModuleLanguageInterface::class, true)
3940b5fd0a6SGreg Roach            ->map(static function (ModuleLanguageInterface $module): LocaleInterface {
39502086832SGreg Roach                return $module->locale();
396c116a5ccSGreg Roach            });
397a25f0a04SGreg Roach    }
398a25f0a04SGreg Roach
399a25f0a04SGreg Roach    /**
400a25f0a04SGreg Roach     * Return the endonym for a given language - as per http://cldr.unicode.org/
401a25f0a04SGreg Roach     *
402a25f0a04SGreg Roach     * @param string $locale
403a25f0a04SGreg Roach     *
404a25f0a04SGreg Roach     * @return string
405a25f0a04SGreg Roach     */
40655664801SGreg Roach    public static function languageName(string $locale): string
407c1010edaSGreg Roach    {
408c999a340SGreg Roach        return Locale::create($locale)->endonym();
409a25f0a04SGreg Roach    }
410a25f0a04SGreg Roach
411a25f0a04SGreg Roach    /**
412a25f0a04SGreg Roach     * Return the script used by a given language
413a25f0a04SGreg Roach     *
414a25f0a04SGreg Roach     * @param string $locale
415a25f0a04SGreg Roach     *
416a25f0a04SGreg Roach     * @return string
417a25f0a04SGreg Roach     */
41855664801SGreg Roach    public static function languageScript(string $locale): string
419c1010edaSGreg Roach    {
420c999a340SGreg Roach        return Locale::create($locale)->script()->code();
421a25f0a04SGreg Roach    }
422a25f0a04SGreg Roach
423a25f0a04SGreg Roach    /**
424dfeee0a8SGreg Roach     * Translate a number into the local representation.
425dfeee0a8SGreg Roach     * e.g. 12345.67 becomes
426dfeee0a8SGreg Roach     * en: 12,345.67
427dfeee0a8SGreg Roach     * fr: 12 345,67
428dfeee0a8SGreg Roach     * de: 12.345,67
429dfeee0a8SGreg Roach     *
430dfeee0a8SGreg Roach     * @param float $n
431cbc1590aSGreg Roach     * @param int   $precision
432a25f0a04SGreg Roach     *
433a25f0a04SGreg Roach     * @return string
434a25f0a04SGreg Roach     */
43555664801SGreg Roach    public static function number(float $n, int $precision = 0): string
436c1010edaSGreg Roach    {
437dfeee0a8SGreg Roach        return self::$locale->number(round($n, $precision));
438dfeee0a8SGreg Roach    }
439dfeee0a8SGreg Roach
440dfeee0a8SGreg Roach    /**
441dfeee0a8SGreg Roach     * Translate a fraction into a percentage.
442dfeee0a8SGreg Roach     * e.g. 0.123 becomes
443dfeee0a8SGreg Roach     * en: 12.3%
444dfeee0a8SGreg Roach     * fr: 12,3 %
445dfeee0a8SGreg Roach     * de: 12,3%
446dfeee0a8SGreg Roach     *
447dfeee0a8SGreg Roach     * @param float $n
448cbc1590aSGreg Roach     * @param int   $precision
449dfeee0a8SGreg Roach     *
450dfeee0a8SGreg Roach     * @return string
451dfeee0a8SGreg Roach     */
45255664801SGreg Roach    public static function percentage(float $n, int $precision = 0): string
453c1010edaSGreg Roach    {
454dfeee0a8SGreg Roach        return self::$locale->percent(round($n, $precision + 2));
455dfeee0a8SGreg Roach    }
456dfeee0a8SGreg Roach
457dfeee0a8SGreg Roach    /**
458dfeee0a8SGreg Roach     * Translate a plural string
459dfeee0a8SGreg Roach     * echo self::plural('There is an error', 'There are errors', $num_errors);
460dfeee0a8SGreg Roach     * echo self::plural('There is one error', 'There are %s errors', $num_errors);
461dfeee0a8SGreg Roach     * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour);
462dfeee0a8SGreg Roach     *
463924d091bSGreg Roach     * @param string $singular
464924d091bSGreg Roach     * @param string $plural
465924d091bSGreg Roach     * @param int    $count
466a515be7cSGreg Roach     * @param string ...$args
467e93111adSRico Sonntag     *
468dfeee0a8SGreg Roach     * @return string
469dfeee0a8SGreg Roach     */
470924d091bSGreg Roach    public static function plural(string $singular, string $plural, int $count, ...$args): string
471c1010edaSGreg Roach    {
472924d091bSGreg Roach        $message = self::$translator->translatePlural($singular, $plural, $count);
473dfeee0a8SGreg Roach
474924d091bSGreg Roach        return sprintf($message, ...$args);
475dfeee0a8SGreg Roach    }
476dfeee0a8SGreg Roach
477dfeee0a8SGreg Roach    /**
478dfeee0a8SGreg Roach     * UTF8 version of PHP::strrev()
479dfeee0a8SGreg Roach     * Reverse RTL text for third-party libraries such as GD2 and googlechart.
480dfeee0a8SGreg Roach     * These do not support UTF8 text direction, so we must mimic it for them.
481dfeee0a8SGreg Roach     * Numbers are always rendered LTR, even in RTL text.
482dfeee0a8SGreg Roach     * The visual direction of characters such as parentheses should be reversed.
483dfeee0a8SGreg Roach     *
484dfeee0a8SGreg Roach     * @param string $text Text to be reversed
485dfeee0a8SGreg Roach     *
486dfeee0a8SGreg Roach     * @return string
487dfeee0a8SGreg Roach     */
4888f53f488SRico Sonntag    public static function reverseText($text): string
489c1010edaSGreg Roach    {
490dfeee0a8SGreg Roach        // Remove HTML markup - we can't display it and it is LTR.
4919524b7b5SGreg Roach        $text = strip_tags($text);
4929524b7b5SGreg Roach        // Remove HTML entities.
4939524b7b5SGreg Roach        $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
494dfeee0a8SGreg Roach
495dfeee0a8SGreg Roach        // LTR text doesn't need reversing
496dfeee0a8SGreg Roach        if (self::scriptDirection(self::textScript($text)) === 'ltr') {
497dfeee0a8SGreg Roach            return $text;
498dfeee0a8SGreg Roach        }
499dfeee0a8SGreg Roach
500dfeee0a8SGreg Roach        // Mirrored characters
501991b93ddSGreg Roach        $text = strtr($text, self::MIRROR_CHARACTERS);
502dfeee0a8SGreg Roach
503dfeee0a8SGreg Roach        $reversed = '';
504dfeee0a8SGreg Roach        $digits   = '';
505e364afe4SGreg Roach        while ($text !== '') {
506dfeee0a8SGreg Roach            $letter = mb_substr($text, 0, 1);
507dfeee0a8SGreg Roach            $text   = mb_substr($text, 1);
508dfeee0a8SGreg Roach            if (strpos(self::DIGITS, $letter) !== false) {
509dfeee0a8SGreg Roach                $digits .= $letter;
510a25f0a04SGreg Roach            } else {
511dfeee0a8SGreg Roach                $reversed = $letter . $digits . $reversed;
512dfeee0a8SGreg Roach                $digits   = '';
513dfeee0a8SGreg Roach            }
514a25f0a04SGreg Roach        }
515a25f0a04SGreg Roach
516dfeee0a8SGreg Roach        return $digits . $reversed;
517a25f0a04SGreg Roach    }
518a25f0a04SGreg Roach
519a25f0a04SGreg Roach    /**
520a25f0a04SGreg Roach     * Return the direction (ltr or rtl) for a given script
521a25f0a04SGreg Roach     * The PHP/intl library does not provde this information, so we need
522a25f0a04SGreg Roach     * our own lookup table.
523a25f0a04SGreg Roach     *
524a25f0a04SGreg Roach     * @param string $script
525a25f0a04SGreg Roach     *
526a25f0a04SGreg Roach     * @return string
527a25f0a04SGreg Roach     */
528e364afe4SGreg Roach    public static function scriptDirection($script): string
529c1010edaSGreg Roach    {
530a25f0a04SGreg Roach        switch ($script) {
531a25f0a04SGreg Roach            case 'Arab':
532a25f0a04SGreg Roach            case 'Hebr':
533a25f0a04SGreg Roach            case 'Mong':
534a25f0a04SGreg Roach            case 'Thaa':
535a25f0a04SGreg Roach                return 'rtl';
536a25f0a04SGreg Roach            default:
537a25f0a04SGreg Roach                return 'ltr';
538a25f0a04SGreg Roach        }
539a25f0a04SGreg Roach    }
540a25f0a04SGreg Roach
541a25f0a04SGreg Roach    /**
542991b93ddSGreg Roach     * Perform a case-insensitive comparison of two strings.
543a25f0a04SGreg Roach     *
544a25f0a04SGreg Roach     * @param string $string1
545a25f0a04SGreg Roach     * @param string $string2
546a25f0a04SGreg Roach     *
547cbc1590aSGreg Roach     * @return int
548a25f0a04SGreg Roach     */
549e364afe4SGreg Roach    public static function strcasecmp($string1, $string2): int
550c1010edaSGreg Roach    {
551991b93ddSGreg Roach        if (self::$collator instanceof Collator) {
552991b93ddSGreg Roach            return self::$collator->compare($string1, $string2);
553a25f0a04SGreg Roach        }
554e364afe4SGreg Roach
555e364afe4SGreg Roach        return strcmp(self::strtolower($string1), self::strtolower($string2));
556c9ec599fSGreg Roach    }
557a25f0a04SGreg Roach
558a25f0a04SGreg Roach    /**
559991b93ddSGreg Roach     * Convert a string to lower case.
560a25f0a04SGreg Roach     *
561dfeee0a8SGreg Roach     * @param string $string
562a25f0a04SGreg Roach     *
563a25f0a04SGreg Roach     * @return string
564a25f0a04SGreg Roach     */
5658f53f488SRico Sonntag    public static function strtolower($string): string
566c1010edaSGreg Roach    {
56702086832SGreg Roach        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES, true)) {
568991b93ddSGreg Roach            $string = strtr($string, self::DOTLESS_I_TOLOWER);
569a25f0a04SGreg Roach        }
5705ddad20bSGreg Roach
5715ddad20bSGreg Roach        return mb_strtolower($string);
572a25f0a04SGreg Roach    }
573a25f0a04SGreg Roach
574a25f0a04SGreg Roach    /**
575991b93ddSGreg Roach     * Convert a string to upper case.
576dfeee0a8SGreg Roach     *
577dfeee0a8SGreg Roach     * @param string $string
578a25f0a04SGreg Roach     *
579a25f0a04SGreg Roach     * @return string
580a25f0a04SGreg Roach     */
5818f53f488SRico Sonntag    public static function strtoupper($string): string
582c1010edaSGreg Roach    {
58302086832SGreg Roach        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES, true)) {
584991b93ddSGreg Roach            $string = strtr($string, self::DOTLESS_I_TOUPPER);
585a25f0a04SGreg Roach        }
5865ddad20bSGreg Roach
5875ddad20bSGreg Roach        return mb_strtoupper($string);
588a25f0a04SGreg Roach    }
589a25f0a04SGreg Roach
590dfeee0a8SGreg Roach    /**
591dfeee0a8SGreg Roach     * Identify the script used for a piece of text
592dfeee0a8SGreg Roach     *
593d0bfc631SGreg Roach     * @param string $string
594dfeee0a8SGreg Roach     *
595dfeee0a8SGreg Roach     * @return string
596dfeee0a8SGreg Roach     */
5978f53f488SRico Sonntag    public static function textScript($string): string
598c1010edaSGreg Roach    {
599dfeee0a8SGreg Roach        $string = strip_tags($string); // otherwise HTML tags show up as latin
600dfeee0a8SGreg Roach        $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin
601c1010edaSGreg Roach        $string = str_replace([
602c1010edaSGreg Roach            '@N.N.',
603c1010edaSGreg Roach            '@P.N.',
604c1010edaSGreg Roach        ], '', $string); // otherwise unknown names show up as latin
605dfeee0a8SGreg Roach        $pos    = 0;
606dfeee0a8SGreg Roach        $strlen = strlen($string);
607dfeee0a8SGreg Roach        while ($pos < $strlen) {
608dfeee0a8SGreg Roach            // get the Unicode Code Point for the character at position $pos
609dfeee0a8SGreg Roach            $byte1 = ord($string[$pos]);
610dfeee0a8SGreg Roach            if ($byte1 < 0x80) {
611dfeee0a8SGreg Roach                $code_point = $byte1;
612dfeee0a8SGreg Roach                $chrlen     = 1;
613dfeee0a8SGreg Roach            } elseif ($byte1 < 0xC0) {
614dfeee0a8SGreg Roach                // Invalid continuation character
615dfeee0a8SGreg Roach                return 'Latn';
616dfeee0a8SGreg Roach            } elseif ($byte1 < 0xE0) {
617dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F);
618dfeee0a8SGreg Roach                $chrlen     = 2;
619dfeee0a8SGreg Roach            } elseif ($byte1 < 0xF0) {
620dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F);
621dfeee0a8SGreg Roach                $chrlen     = 3;
622dfeee0a8SGreg Roach            } elseif ($byte1 < 0xF8) {
623dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F);
624dfeee0a8SGreg Roach                $chrlen     = 3;
625dfeee0a8SGreg Roach            } else {
626dfeee0a8SGreg Roach                // Invalid UTF
627dfeee0a8SGreg Roach                return 'Latn';
628dfeee0a8SGreg Roach            }
629dfeee0a8SGreg Roach
630991b93ddSGreg Roach            foreach (self::SCRIPT_CHARACTER_RANGES as $range) {
631dfeee0a8SGreg Roach                if ($code_point >= $range[1] && $code_point <= $range[2]) {
632dfeee0a8SGreg Roach                    return $range[0];
633dfeee0a8SGreg Roach                }
634dfeee0a8SGreg Roach            }
635dfeee0a8SGreg Roach            // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking.
636dfeee0a8SGreg Roach            $pos += $chrlen;
637dfeee0a8SGreg Roach        }
638dfeee0a8SGreg Roach
639dfeee0a8SGreg Roach        return 'Latn';
640dfeee0a8SGreg Roach    }
641dfeee0a8SGreg Roach
642dfeee0a8SGreg Roach    /**
643dfeee0a8SGreg Roach     * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago"
644dfeee0a8SGreg Roach     *
645cbc1590aSGreg Roach     * @param int $seconds
646dfeee0a8SGreg Roach     *
647dfeee0a8SGreg Roach     * @return string
648dfeee0a8SGreg Roach     */
649e364afe4SGreg Roach    public static function timeAgo($seconds): string
650c1010edaSGreg Roach    {
651dfeee0a8SGreg Roach        $minute = 60;
652dfeee0a8SGreg Roach        $hour   = 60 * $minute;
653dfeee0a8SGreg Roach        $day    = 24 * $hour;
654dfeee0a8SGreg Roach        $month  = 30 * $day;
655dfeee0a8SGreg Roach        $year   = 365 * $day;
656dfeee0a8SGreg Roach
657dfeee0a8SGreg Roach        if ($seconds > $year) {
658cdaafeeeSGreg Roach            $years = intdiv($seconds, $year);
659cbc1590aSGreg Roach
660dfeee0a8SGreg Roach            return self::plural('%s year ago', '%s years ago', $years, self::number($years));
661b2ce94c6SRico Sonntag        }
662b2ce94c6SRico Sonntag
663b2ce94c6SRico Sonntag        if ($seconds > $month) {
664cdaafeeeSGreg Roach            $months = intdiv($seconds, $month);
665cbc1590aSGreg Roach
666dfeee0a8SGreg Roach            return self::plural('%s month ago', '%s months ago', $months, self::number($months));
667b2ce94c6SRico Sonntag        }
668b2ce94c6SRico Sonntag
669b2ce94c6SRico Sonntag        if ($seconds > $day) {
670cdaafeeeSGreg Roach            $days = intdiv($seconds, $day);
671cbc1590aSGreg Roach
672dfeee0a8SGreg Roach            return self::plural('%s day ago', '%s days ago', $days, self::number($days));
673b2ce94c6SRico Sonntag        }
674b2ce94c6SRico Sonntag
675b2ce94c6SRico Sonntag        if ($seconds > $hour) {
676cdaafeeeSGreg Roach            $hours = intdiv($seconds, $hour);
677cbc1590aSGreg Roach
678dfeee0a8SGreg Roach            return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours));
679b2ce94c6SRico Sonntag        }
680b2ce94c6SRico Sonntag
681b2ce94c6SRico Sonntag        if ($seconds > $minute) {
682cdaafeeeSGreg Roach            $minutes = intdiv($seconds, $minute);
683cbc1590aSGreg Roach
684dfeee0a8SGreg Roach            return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes));
685dfeee0a8SGreg Roach        }
686b2ce94c6SRico Sonntag
687b2ce94c6SRico Sonntag        return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds));
688dfeee0a8SGreg Roach    }
689dfeee0a8SGreg Roach
690dfeee0a8SGreg Roach    /**
691dfeee0a8SGreg Roach     * What format is used to display dates in the current locale?
692dfeee0a8SGreg Roach     *
693dfeee0a8SGreg Roach     * @return string
694dfeee0a8SGreg Roach     */
6958f53f488SRico Sonntag    public static function timeFormat(): string
696c1010edaSGreg Roach    {
697bbb76c12SGreg Roach        /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */
698bbb76c12SGreg Roach        return self::$translator->translate('%H:%i:%s');
699dfeee0a8SGreg Roach    }
700dfeee0a8SGreg Roach
701dfeee0a8SGreg Roach    /**
702dfeee0a8SGreg Roach     * Translate a string, and then substitute placeholders
703dfeee0a8SGreg Roach     * echo I18N::translate('Hello World!');
704dfeee0a8SGreg Roach     * echo I18N::translate('The %s sat on the mat', 'cat');
705dfeee0a8SGreg Roach     *
706924d091bSGreg Roach     * @param string $message
707a515be7cSGreg Roach     * @param string ...$args
708c3283ed7SGreg Roach     *
709dfeee0a8SGreg Roach     * @return string
710dfeee0a8SGreg Roach     */
711924d091bSGreg Roach    public static function translate(string $message, ...$args): string
712c1010edaSGreg Roach    {
713924d091bSGreg Roach        $message = self::$translator->translate($message);
714dfeee0a8SGreg Roach
715924d091bSGreg Roach        return sprintf($message, ...$args);
716dfeee0a8SGreg Roach    }
717dfeee0a8SGreg Roach
718dfeee0a8SGreg Roach    /**
719dfeee0a8SGreg Roach     * Context sensitive version of translate.
720a4956c0eSGreg Roach     * echo I18N::translateContext('NOMINATIVE', 'January');
721a4956c0eSGreg Roach     * echo I18N::translateContext('GENITIVE', 'January');
722dfeee0a8SGreg Roach     *
723924d091bSGreg Roach     * @param string $context
724924d091bSGreg Roach     * @param string $message
725a515be7cSGreg Roach     * @param string ...$args
726c3283ed7SGreg Roach     *
727dfeee0a8SGreg Roach     * @return string
728dfeee0a8SGreg Roach     */
729924d091bSGreg Roach    public static function translateContext(string $context, string $message, ...$args): string
730c1010edaSGreg Roach    {
731924d091bSGreg Roach        $message = self::$translator->translateContext($context, $message);
732dfeee0a8SGreg Roach
733924d091bSGreg Roach        return sprintf($message, ...$args);
734a25f0a04SGreg Roach    }
735a25f0a04SGreg Roach}
736