xref: /webtrees/app/I18N.php (revision 449b311ecf65f677a2595e1e29f712d11ef22f34)
1a25f0a04SGreg Roach<?php
23976b470SGreg Roach
3a25f0a04SGreg Roach/**
4a25f0a04SGreg Roach * webtrees: online genealogy
5d11be702SGreg Roach * Copyright (C) 2023 webtrees development team
6a25f0a04SGreg Roach * This program is free software: you can redistribute it and/or modify
7a25f0a04SGreg Roach * it under the terms of the GNU General Public License as published by
8a25f0a04SGreg Roach * the Free Software Foundation, either version 3 of the License, or
9a25f0a04SGreg Roach * (at your option) any later version.
10a25f0a04SGreg Roach * This program is distributed in the hope that it will be useful,
11a25f0a04SGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of
12a25f0a04SGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13a25f0a04SGreg Roach * GNU General Public License for more details.
14a25f0a04SGreg Roach * You should have received a copy of the GNU General Public License
1589f7189bSGreg Roach * along with this program. If not, see <https://www.gnu.org/licenses/>.
16a25f0a04SGreg Roach */
17fcfa147eSGreg Roach
18e7f56f2aSGreg Roachdeclare(strict_types=1);
19e7f56f2aSGreg Roach
2076692c8bSGreg Roachnamespace Fisharebest\Webtrees;
21a25f0a04SGreg Roach
2237646143SGreg Roachuse Closure;
23991b93ddSGreg Roachuse Collator;
24f1af7e1cSGreg Roachuse Exception;
25c999a340SGreg Roachuse Fisharebest\Localization\Locale;
261e71bdc0SGreg Roachuse Fisharebest\Localization\Locale\LocaleEnUs;
2715834aaeSGreg Roachuse Fisharebest\Localization\Locale\LocaleInterface;
283bdc890bSGreg Roachuse Fisharebest\Localization\Translation;
293bdc890bSGreg Roachuse Fisharebest\Localization\Translator;
30d37db671SGreg Roachuse Fisharebest\Webtrees\Module\ModuleCustomInterface;
3102086832SGreg Roachuse Fisharebest\Webtrees\Module\ModuleLanguageInterface;
32d37db671SGreg Roachuse Fisharebest\Webtrees\Services\ModuleService;
333976b470SGreg Roach
344f194b97SGreg Roachuse function array_merge;
35d68ee7a8SGreg Roachuse function class_exists;
36d68ee7a8SGreg Roachuse function html_entity_decode;
37d68ee7a8SGreg Roachuse function in_array;
38d68ee7a8SGreg Roachuse function mb_strtolower;
39d68ee7a8SGreg Roachuse function mb_strtoupper;
40d68ee7a8SGreg Roachuse function mb_substr;
41d68ee7a8SGreg Roachuse function ord;
42d68ee7a8SGreg Roachuse function sprintf;
43dec352c1SGreg Roachuse function str_contains;
44d68ee7a8SGreg Roachuse function str_replace;
45d68ee7a8SGreg Roachuse function strcmp;
46d68ee7a8SGreg Roachuse function strip_tags;
47d68ee7a8SGreg Roachuse function strlen;
48d68ee7a8SGreg Roachuse function strtr;
49b0fcccb0SGreg Roachuse function var_export;
50a25f0a04SGreg Roach
51a25f0a04SGreg Roach/**
5276692c8bSGreg Roach * Internationalization (i18n) and localization (l10n).
53a25f0a04SGreg Roach */
54c1010edaSGreg Roachclass I18N
55c1010edaSGreg Roach{
56d37db671SGreg Roach    // MO files use special characters for plurals and context.
574f194b97SGreg Roach    public const PLURAL  = "\x00";
584f194b97SGreg Roach    public const CONTEXT = "\x04";
596fcafd02SGreg Roach
606fcafd02SGreg Roach    // Digits are always rendered LTR, even in RTL text.
6116d6367aSGreg Roach    private const DIGITS = '0123456789٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹';
626fcafd02SGreg Roach
636fcafd02SGreg Roach    // These locales need special handling for the dotless letter I.
6416d6367aSGreg Roach    private const DOTLESS_I_LOCALES = [
65c1010edaSGreg Roach        'az',
66c1010edaSGreg Roach        'tr',
67c1010edaSGreg Roach    ];
686fcafd02SGreg Roach
6916d6367aSGreg Roach    private const DOTLESS_I_TOLOWER = [
70c1010edaSGreg Roach        'I' => 'ı',
71c1010edaSGreg Roach        'İ' => 'i',
72c1010edaSGreg Roach    ];
73006094b9SGreg Roach
7416d6367aSGreg Roach    private const DOTLESS_I_TOUPPER = [
75c1010edaSGreg Roach        'ı' => 'I',
76c1010edaSGreg Roach        'i' => 'İ',
77c1010edaSGreg Roach    ];
78a25f0a04SGreg Roach
796fcafd02SGreg Roach    // The ranges of characters used by each script.
8016d6367aSGreg Roach    private const SCRIPT_CHARACTER_RANGES = [
81c1010edaSGreg Roach        [
82c1010edaSGreg Roach            'Latn',
83c1010edaSGreg Roach            0x0041,
84c1010edaSGreg Roach            0x005A,
85c1010edaSGreg Roach        ],
86c1010edaSGreg Roach        [
87c1010edaSGreg Roach            'Latn',
88c1010edaSGreg Roach            0x0061,
89c1010edaSGreg Roach            0x007A,
90c1010edaSGreg Roach        ],
91c1010edaSGreg Roach        [
92c1010edaSGreg Roach            'Latn',
93c1010edaSGreg Roach            0x0100,
94c1010edaSGreg Roach            0x02AF,
95c1010edaSGreg Roach        ],
96c1010edaSGreg Roach        [
97c1010edaSGreg Roach            'Grek',
98c1010edaSGreg Roach            0x0370,
99c1010edaSGreg Roach            0x03FF,
100c1010edaSGreg Roach        ],
101c1010edaSGreg Roach        [
102c1010edaSGreg Roach            'Cyrl',
103c1010edaSGreg Roach            0x0400,
104c1010edaSGreg Roach            0x052F,
105c1010edaSGreg Roach        ],
106c1010edaSGreg Roach        [
107c1010edaSGreg Roach            'Hebr',
108c1010edaSGreg Roach            0x0590,
109c1010edaSGreg Roach            0x05FF,
110c1010edaSGreg Roach        ],
111c1010edaSGreg Roach        [
112c1010edaSGreg Roach            'Arab',
113c1010edaSGreg Roach            0x0600,
114c1010edaSGreg Roach            0x06FF,
115c1010edaSGreg Roach        ],
116c1010edaSGreg Roach        [
117c1010edaSGreg Roach            'Arab',
118c1010edaSGreg Roach            0x0750,
119c1010edaSGreg Roach            0x077F,
120c1010edaSGreg Roach        ],
121c1010edaSGreg Roach        [
122c1010edaSGreg Roach            'Arab',
123c1010edaSGreg Roach            0x08A0,
124c1010edaSGreg Roach            0x08FF,
125c1010edaSGreg Roach        ],
126c1010edaSGreg Roach        [
127c1010edaSGreg Roach            'Deva',
128c1010edaSGreg Roach            0x0900,
129c1010edaSGreg Roach            0x097F,
130c1010edaSGreg Roach        ],
131c1010edaSGreg Roach        [
132c1010edaSGreg Roach            'Taml',
133c1010edaSGreg Roach            0x0B80,
134c1010edaSGreg Roach            0x0BFF,
135c1010edaSGreg Roach        ],
136c1010edaSGreg Roach        [
137c1010edaSGreg Roach            'Sinh',
138c1010edaSGreg Roach            0x0D80,
139c1010edaSGreg Roach            0x0DFF,
140c1010edaSGreg Roach        ],
141c1010edaSGreg Roach        [
142c1010edaSGreg Roach            'Thai',
143c1010edaSGreg Roach            0x0E00,
144c1010edaSGreg Roach            0x0E7F,
145c1010edaSGreg Roach        ],
146c1010edaSGreg Roach        [
147c1010edaSGreg Roach            'Geor',
148c1010edaSGreg Roach            0x10A0,
149c1010edaSGreg Roach            0x10FF,
150c1010edaSGreg Roach        ],
151c1010edaSGreg Roach        [
152c1010edaSGreg Roach            'Grek',
153c1010edaSGreg Roach            0x1F00,
154c1010edaSGreg Roach            0x1FFF,
155c1010edaSGreg Roach        ],
156c1010edaSGreg Roach        [
157c1010edaSGreg Roach            'Deva',
158c1010edaSGreg Roach            0xA8E0,
159c1010edaSGreg Roach            0xA8FF,
160c1010edaSGreg Roach        ],
161c1010edaSGreg Roach        [
162c1010edaSGreg Roach            'Hans',
163c1010edaSGreg Roach            0x3000,
164c1010edaSGreg Roach            0x303F,
165c1010edaSGreg Roach        ],
166c1010edaSGreg Roach        // Mixed CJK, not just Hans
167c1010edaSGreg Roach        [
168c1010edaSGreg Roach            'Hans',
169c1010edaSGreg Roach            0x3400,
170c1010edaSGreg Roach            0xFAFF,
171c1010edaSGreg Roach        ],
172c1010edaSGreg Roach        // Mixed CJK, not just Hans
173c1010edaSGreg Roach        [
174c1010edaSGreg Roach            'Hans',
175c1010edaSGreg Roach            0x20000,
176c1010edaSGreg Roach            0x2FA1F,
177c1010edaSGreg Roach        ],
178c1010edaSGreg Roach        // Mixed CJK, not just Hans
17913abd6f3SGreg Roach    ];
1806fcafd02SGreg Roach
1816fcafd02SGreg Roach    // Characters that are displayed in mirror form in RTL text.
18216d6367aSGreg Roach    private const MIRROR_CHARACTERS = [
183a25f0a04SGreg Roach        '('  => ')',
184a25f0a04SGreg Roach        ')'  => '(',
185a25f0a04SGreg Roach        '['  => ']',
186a25f0a04SGreg Roach        ']'  => '[',
187a25f0a04SGreg Roach        '{'  => '}',
188a25f0a04SGreg Roach        '}'  => '{',
189a25f0a04SGreg Roach        '<'  => '>',
190a25f0a04SGreg Roach        '>'  => '<',
191a25f0a04SGreg Roach        '‹ ' => '›',
192a25f0a04SGreg Roach        '› ' => '‹',
193a25f0a04SGreg Roach        '«'  => '»',
194a25f0a04SGreg Roach        '»'  => '«',
195a25f0a04SGreg Roach        '﴾ ' => '﴿',
196a25f0a04SGreg Roach        '﴿ ' => '﴾',
197a25f0a04SGreg Roach        '“ ' => '”',
198a25f0a04SGreg Roach        '” ' => '“',
199a25f0a04SGreg Roach        '‘ ' => '’',
200a25f0a04SGreg Roach        '’ ' => '‘',
20113abd6f3SGreg Roach    ];
202a25f0a04SGreg Roach
2036fcafd02SGreg Roach    // Punctuation used to separate list items, typically a comma
2046fcafd02SGreg Roach    public static string $list_separator;
205006094b9SGreg Roach
206b458aac1SGreg Roach    private static ModuleLanguageInterface $language;
2076fcafd02SGreg Roach
2086fcafd02SGreg Roach    private static LocaleInterface $locale;
2096fcafd02SGreg Roach
2106fcafd02SGreg Roach    private static Translator $translator;
2116fcafd02SGreg Roach
212*1ff45046SGreg Roach    private static Collator|null $collator = null;
213006094b9SGreg Roach
214a25f0a04SGreg Roach    /**
21502086832SGreg Roach     * The preferred locales for this site, or a default list if no preference.
216dfeee0a8SGreg Roach     *
217ac701fbdSGreg Roach     * @return array<LocaleInterface>
218dfeee0a8SGreg Roach     */
2198f53f488SRico Sonntag    public static function activeLocales(): array
220c1010edaSGreg Roach    {
221d35568b4SGreg Roach        $locales = Registry::container()->get(ModuleService::class)
222d6137952SGreg Roach            ->findByInterface(ModuleLanguageInterface::class, false, true)
223f25fc0f9SGreg Roach            ->map(static fn (ModuleLanguageInterface $module): LocaleInterface => $module->locale());
224dfeee0a8SGreg Roach
22502086832SGreg Roach        if ($locales->isEmpty()) {
22602086832SGreg Roach            return [new LocaleEnUs()];
227dfeee0a8SGreg Roach        }
228dfeee0a8SGreg Roach
22902086832SGreg Roach        return $locales->all();
230dfeee0a8SGreg Roach    }
231dfeee0a8SGreg Roach
232dfeee0a8SGreg Roach    /**
233dfeee0a8SGreg Roach     * What format is used to display dates in the current locale?
234dfeee0a8SGreg Roach     *
235dfeee0a8SGreg Roach     * @return string
236dfeee0a8SGreg Roach     */
2378f53f488SRico Sonntag    public static function dateFormat(): string
238c1010edaSGreg Roach    {
239ad3143ccSGreg Roach        /* I18N: This is the format string for full dates. See https://php.net/date for codes */
240bbb76c12SGreg Roach        return self::$translator->translate('%j %F %Y');
241dfeee0a8SGreg Roach    }
242dfeee0a8SGreg Roach
243dfeee0a8SGreg Roach    /**
244dfeee0a8SGreg Roach     * Convert the digits 0-9 into the local script
245dfeee0a8SGreg Roach     * Used for years, etc., where we do not want thousands-separators, decimals, etc.
246dfeee0a8SGreg Roach     *
24755664801SGreg Roach     * @param string|int $n
248dfeee0a8SGreg Roach     *
249dfeee0a8SGreg Roach     * @return string
250dfeee0a8SGreg Roach     */
251ac71572dSGreg Roach    public static function digits(string|int $n): string
252c1010edaSGreg Roach    {
25355664801SGreg Roach        return self::$locale->digits((string) $n);
254dfeee0a8SGreg Roach    }
255dfeee0a8SGreg Roach
256dfeee0a8SGreg Roach    /**
257dfeee0a8SGreg Roach     * What is the direction of the current locale
258dfeee0a8SGreg Roach     *
259dfeee0a8SGreg Roach     * @return string "ltr" or "rtl"
260dfeee0a8SGreg Roach     */
2618f53f488SRico Sonntag    public static function direction(): string
262c1010edaSGreg Roach    {
263dfeee0a8SGreg Roach        return self::$locale->direction();
264dfeee0a8SGreg Roach    }
265dfeee0a8SGreg Roach
266dfeee0a8SGreg Roach    /**
267a25f0a04SGreg Roach     * Initialise the translation adapter with a locale setting.
268a25f0a04SGreg Roach     *
269150f35adSGreg Roach     * @param string $code
270150f35adSGreg Roach     * @param bool   $setup
271a25f0a04SGreg Roach     *
272150f35adSGreg Roach     * @return void
273a25f0a04SGreg Roach     */
274150f35adSGreg Roach    public static function init(string $code, bool $setup = false): void
275c1010edaSGreg Roach    {
2763bdc890bSGreg Roach        self::$locale = Locale::create($code);
2773bdc890bSGreg Roach
2784f194b97SGreg Roach        // Load the translation file
279150f35adSGreg Roach        $translation_file = __DIR__ . '/../resources/lang/' . self::$locale->languageTag() . '/messages.php';
2804f194b97SGreg Roach
281f1af7e1cSGreg Roach        try {
282006094b9SGreg Roach            $translation  = new Translation($translation_file);
283006094b9SGreg Roach            $translations = $translation->asArray();
28428d026adSGreg Roach        } catch (Exception) {
285006094b9SGreg Roach            // The translations files are created during the build process, and are
286006094b9SGreg Roach            // not included in the source code.
287006094b9SGreg Roach            // Assuming we are using dev code, and build (or rebuild) the files.
288006094b9SGreg Roach            $po_file      = Webtrees::ROOT_DIR . 'resources/lang/' . self::$locale->languageTag() . '/messages.po';
289006094b9SGreg Roach            $translation  = new Translation($po_file);
290006094b9SGreg Roach            $translations = $translation->asArray();
291b0fcccb0SGreg Roach            file_put_contents($translation_file, "<?php\n\nreturn " . var_export($translations, true) . ";\n");
292a25f0a04SGreg Roach        }
293a25f0a04SGreg Roach
2944f194b97SGreg Roach        // Add translations from custom modules (but not during setup, as we have no database/modules)
295c116a5ccSGreg Roach        if (!$setup) {
296d35568b4SGreg Roach            $module_service = Registry::container()->get(ModuleService::class);
2976fcafd02SGreg Roach
2986fcafd02SGreg Roach            $translations = $module_service
2994f194b97SGreg Roach                ->findByInterface(ModuleCustomInterface::class)
300f25fc0f9SGreg Roach                ->reduce(static fn (array $carry, ModuleCustomInterface $item): array => array_merge($carry, $item->customTranslations(self::$locale->languageTag())), $translations);
3016fcafd02SGreg Roach
3026fcafd02SGreg Roach            self::$language = $module_service
303b458aac1SGreg Roach                ->findByInterface(ModuleLanguageInterface::class, true)
3046fcafd02SGreg Roach                ->first(fn (ModuleLanguageInterface $module): bool => $module->locale()->languageTag() === $code);
305d37db671SGreg Roach        }
306d37db671SGreg Roach
3073bdc890bSGreg Roach        // Create a translator
3083bdc890bSGreg Roach        self::$translator = new Translator($translations, self::$locale->pluralRule());
309a25f0a04SGreg Roach
310bbb76c12SGreg Roach        /* I18N: This punctuation is used to separate lists of items */
311bbb76c12SGreg Roach        self::$list_separator = self::translate(', ');
312a25f0a04SGreg Roach
313991b93ddSGreg Roach        // Create a collator
314991b93ddSGreg Roach        try {
315c9ec599fSGreg Roach            // Symfony provides a very incomplete polyfill - which cannot be used.
316dff81305SGreg Roach            if (class_exists('Collator')) {
317dff81305SGreg Roach                // Need phonebook collation rules for German Ä, Ö and Ü.
318dff81305SGreg Roach                if (str_contains(self::$locale->code(), '@')) {
319dff81305SGreg Roach                    self::$collator = new Collator(self::$locale->code() . ';collation=phonebook');
320dff81305SGreg Roach                } else {
321dff81305SGreg Roach                    self::$collator = new Collator(self::$locale->code() . '@collation=phonebook');
322dff81305SGreg Roach                }
323991b93ddSGreg Roach                // Ignore upper/lower case differences
324991b93ddSGreg Roach                self::$collator->setStrength(Collator::SECONDARY);
325444a65ecSGreg Roach            }
32628d026adSGreg Roach        } catch (Exception) {
327991b93ddSGreg Roach            // PHP-INTL is not installed?  We'll use a fallback later.
328991b93ddSGreg Roach        }
329a25f0a04SGreg Roach    }
330a25f0a04SGreg Roach
331a25f0a04SGreg Roach    /**
332006094b9SGreg Roach     * Translate a string, and then substitute placeholders
333006094b9SGreg Roach     * echo I18N::translate('Hello World!');
334006094b9SGreg Roach     * echo I18N::translate('The %s sat on the mat', 'cat');
335006094b9SGreg Roach     *
336006094b9SGreg Roach     * @param string $message
337006094b9SGreg Roach     * @param string ...$args
338006094b9SGreg Roach     *
339006094b9SGreg Roach     * @return string
340006094b9SGreg Roach     */
341006094b9SGreg Roach    public static function translate(string $message, ...$args): string
342006094b9SGreg Roach    {
343006094b9SGreg Roach        $message = self::$translator->translate($message);
344006094b9SGreg Roach
345006094b9SGreg Roach        return sprintf($message, ...$args);
346006094b9SGreg Roach    }
347006094b9SGreg Roach
348006094b9SGreg Roach    /**
34990a2f718SGreg Roach     * @return string
35090a2f718SGreg Roach     */
35190a2f718SGreg Roach    public static function languageTag(): string
35290a2f718SGreg Roach    {
35390a2f718SGreg Roach        return self::$locale->languageTag();
35490a2f718SGreg Roach    }
35590a2f718SGreg Roach
35690a2f718SGreg Roach    /**
35765cf5706SGreg Roach     * @return LocaleInterface
35865cf5706SGreg Roach     */
35965cf5706SGreg Roach    public static function locale(): LocaleInterface
36065cf5706SGreg Roach    {
36165cf5706SGreg Roach        return self::$locale;
36265cf5706SGreg Roach    }
36365cf5706SGreg Roach
36465cf5706SGreg Roach    /**
3656fcafd02SGreg Roach     * @return ModuleLanguageInterface
3666fcafd02SGreg Roach     */
3676fcafd02SGreg Roach    public static function language(): ModuleLanguageInterface
3686fcafd02SGreg Roach    {
3696fcafd02SGreg Roach        return self::$language;
3706fcafd02SGreg Roach    }
3716fcafd02SGreg Roach
3726fcafd02SGreg Roach    /**
373dfeee0a8SGreg Roach     * Translate a number into the local representation.
374dfeee0a8SGreg Roach     * e.g. 12345.67 becomes
375dfeee0a8SGreg Roach     * en: 12,345.67
376dfeee0a8SGreg Roach     * fr: 12 345,67
377dfeee0a8SGreg Roach     * de: 12.345,67
378dfeee0a8SGreg Roach     *
379dfeee0a8SGreg Roach     * @param float $n
380cbc1590aSGreg Roach     * @param int   $precision
381a25f0a04SGreg Roach     *
382a25f0a04SGreg Roach     * @return string
383a25f0a04SGreg Roach     */
38455664801SGreg Roach    public static function number(float $n, int $precision = 0): string
385c1010edaSGreg Roach    {
386dfeee0a8SGreg Roach        return self::$locale->number(round($n, $precision));
387dfeee0a8SGreg Roach    }
388dfeee0a8SGreg Roach
389dfeee0a8SGreg Roach    /**
390dfeee0a8SGreg Roach     * Translate a fraction into a percentage.
391dfeee0a8SGreg Roach     * e.g. 0.123 becomes
392dfeee0a8SGreg Roach     * en: 12.3%
393dfeee0a8SGreg Roach     * fr: 12,3 %
394dfeee0a8SGreg Roach     * de: 12,3%
395dfeee0a8SGreg Roach     *
396dfeee0a8SGreg Roach     * @param float $n
397cbc1590aSGreg Roach     * @param int   $precision
398dfeee0a8SGreg Roach     *
399dfeee0a8SGreg Roach     * @return string
400dfeee0a8SGreg Roach     */
40155664801SGreg Roach    public static function percentage(float $n, int $precision = 0): string
402c1010edaSGreg Roach    {
403dfeee0a8SGreg Roach        return self::$locale->percent(round($n, $precision + 2));
404dfeee0a8SGreg Roach    }
405dfeee0a8SGreg Roach
406dfeee0a8SGreg Roach    /**
407dfeee0a8SGreg Roach     * Translate a plural string
408dfeee0a8SGreg Roach     * echo self::plural('There is an error', 'There are errors', $num_errors);
409dfeee0a8SGreg Roach     * echo self::plural('There is one error', 'There are %s errors', $num_errors);
410dfeee0a8SGreg Roach     * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour);
411dfeee0a8SGreg Roach     *
412924d091bSGreg Roach     * @param string $singular
413924d091bSGreg Roach     * @param string $plural
414924d091bSGreg Roach     * @param int    $count
415a515be7cSGreg Roach     * @param string ...$args
416e93111adSRico Sonntag     *
417dfeee0a8SGreg Roach     * @return string
418dfeee0a8SGreg Roach     */
419924d091bSGreg Roach    public static function plural(string $singular, string $plural, int $count, ...$args): string
420c1010edaSGreg Roach    {
421924d091bSGreg Roach        $message = self::$translator->translatePlural($singular, $plural, $count);
422dfeee0a8SGreg Roach
423924d091bSGreg Roach        return sprintf($message, ...$args);
424dfeee0a8SGreg Roach    }
425dfeee0a8SGreg Roach
426dfeee0a8SGreg Roach    /**
427dfeee0a8SGreg Roach     * UTF8 version of PHP::strrev()
428dfeee0a8SGreg Roach     * Reverse RTL text for third-party libraries such as GD2 and googlechart.
429dfeee0a8SGreg Roach     * These do not support UTF8 text direction, so we must mimic it for them.
430dfeee0a8SGreg Roach     * Numbers are always rendered LTR, even in RTL text.
431dfeee0a8SGreg Roach     * The visual direction of characters such as parentheses should be reversed.
432dfeee0a8SGreg Roach     *
433dfeee0a8SGreg Roach     * @param string $text Text to be reversed
434dfeee0a8SGreg Roach     *
435dfeee0a8SGreg Roach     * @return string
436dfeee0a8SGreg Roach     */
437e0c85f48SGreg Roach    public static function reverseText(string $text): string
438c1010edaSGreg Roach    {
439dfeee0a8SGreg Roach        // Remove HTML markup - we can't display it and it is LTR.
4409524b7b5SGreg Roach        $text = strip_tags($text);
4419524b7b5SGreg Roach        // Remove HTML entities.
4429524b7b5SGreg Roach        $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
443dfeee0a8SGreg Roach
444dfeee0a8SGreg Roach        // LTR text doesn't need reversing
445dfeee0a8SGreg Roach        if (self::scriptDirection(self::textScript($text)) === 'ltr') {
446dfeee0a8SGreg Roach            return $text;
447dfeee0a8SGreg Roach        }
448dfeee0a8SGreg Roach
449dfeee0a8SGreg Roach        // Mirrored characters
450991b93ddSGreg Roach        $text = strtr($text, self::MIRROR_CHARACTERS);
451dfeee0a8SGreg Roach
452dfeee0a8SGreg Roach        $reversed = '';
453dfeee0a8SGreg Roach        $digits   = '';
454e364afe4SGreg Roach        while ($text !== '') {
455dfeee0a8SGreg Roach            $letter = mb_substr($text, 0, 1);
456dfeee0a8SGreg Roach            $text   = mb_substr($text, 1);
457dec352c1SGreg Roach            if (str_contains(self::DIGITS, $letter)) {
458dfeee0a8SGreg Roach                $digits .= $letter;
459a25f0a04SGreg Roach            } else {
460dfeee0a8SGreg Roach                $reversed = $letter . $digits . $reversed;
461dfeee0a8SGreg Roach                $digits   = '';
462dfeee0a8SGreg Roach            }
463a25f0a04SGreg Roach        }
464a25f0a04SGreg Roach
465dfeee0a8SGreg Roach        return $digits . $reversed;
466a25f0a04SGreg Roach    }
467a25f0a04SGreg Roach
468a25f0a04SGreg Roach    /**
469a25f0a04SGreg Roach     * Return the direction (ltr or rtl) for a given script
470a25f0a04SGreg Roach     * The PHP/intl library does not provde this information, so we need
471a25f0a04SGreg Roach     * our own lookup table.
472a25f0a04SGreg Roach     *
473a25f0a04SGreg Roach     * @param string $script
474a25f0a04SGreg Roach     *
475a25f0a04SGreg Roach     * @return string
476a25f0a04SGreg Roach     */
477e0c85f48SGreg Roach    public static function scriptDirection(string $script): string
478c1010edaSGreg Roach    {
479a25f0a04SGreg Roach        switch ($script) {
480a25f0a04SGreg Roach            case 'Arab':
481a25f0a04SGreg Roach            case 'Hebr':
482a25f0a04SGreg Roach            case 'Mong':
483a25f0a04SGreg Roach            case 'Thaa':
484a25f0a04SGreg Roach                return 'rtl';
485a25f0a04SGreg Roach            default:
486a25f0a04SGreg Roach                return 'ltr';
487a25f0a04SGreg Roach        }
488a25f0a04SGreg Roach    }
489a25f0a04SGreg Roach
490a25f0a04SGreg Roach    /**
491dfeee0a8SGreg Roach     * Identify the script used for a piece of text
492dfeee0a8SGreg Roach     *
493d0bfc631SGreg Roach     * @param string $string
494dfeee0a8SGreg Roach     *
495dfeee0a8SGreg Roach     * @return string
496dfeee0a8SGreg Roach     */
497e0c85f48SGreg Roach    public static function textScript(string $string): string
498c1010edaSGreg Roach    {
499dfeee0a8SGreg Roach        $string = strip_tags($string); // otherwise HTML tags show up as latin
500dfeee0a8SGreg Roach        $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin
501c1010edaSGreg Roach        $string = str_replace([
5028fb4e87cSGreg Roach            Individual::NOMEN_NESCIO,
5038fb4e87cSGreg Roach            Individual::PRAENOMEN_NESCIO,
5048fb4e87cSGreg Roach        ], '', $string);
505dfeee0a8SGreg Roach        $pos    = 0;
506dfeee0a8SGreg Roach        $strlen = strlen($string);
507dfeee0a8SGreg Roach        while ($pos < $strlen) {
508dfeee0a8SGreg Roach            // get the Unicode Code Point for the character at position $pos
509dfeee0a8SGreg Roach            $byte1 = ord($string[$pos]);
510dfeee0a8SGreg Roach            if ($byte1 < 0x80) {
511dfeee0a8SGreg Roach                $code_point = $byte1;
512dfeee0a8SGreg Roach                $chrlen     = 1;
513dfeee0a8SGreg Roach            } elseif ($byte1 < 0xC0) {
514dfeee0a8SGreg Roach                // Invalid continuation character
515dfeee0a8SGreg Roach                return 'Latn';
516dfeee0a8SGreg Roach            } elseif ($byte1 < 0xE0) {
517dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F);
518dfeee0a8SGreg Roach                $chrlen     = 2;
519dfeee0a8SGreg Roach            } elseif ($byte1 < 0xF0) {
520dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F);
521dfeee0a8SGreg Roach                $chrlen     = 3;
522dfeee0a8SGreg Roach            } elseif ($byte1 < 0xF8) {
523dfeee0a8SGreg Roach                $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F);
524dfeee0a8SGreg Roach                $chrlen     = 3;
525dfeee0a8SGreg Roach            } else {
526dfeee0a8SGreg Roach                // Invalid UTF
527dfeee0a8SGreg Roach                return 'Latn';
528dfeee0a8SGreg Roach            }
529dfeee0a8SGreg Roach
530991b93ddSGreg Roach            foreach (self::SCRIPT_CHARACTER_RANGES as $range) {
531dfeee0a8SGreg Roach                if ($code_point >= $range[1] && $code_point <= $range[2]) {
532dfeee0a8SGreg Roach                    return $range[0];
533dfeee0a8SGreg Roach                }
534dfeee0a8SGreg Roach            }
535dfeee0a8SGreg Roach            // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking.
536dfeee0a8SGreg Roach            $pos += $chrlen;
537dfeee0a8SGreg Roach        }
538dfeee0a8SGreg Roach
539dfeee0a8SGreg Roach        return 'Latn';
540dfeee0a8SGreg Roach    }
541dfeee0a8SGreg Roach
542dfeee0a8SGreg Roach    /**
54337646143SGreg Roach     * A closure which will compare strings using local collation rules.
544006094b9SGreg Roach     *
545c6921a17SGreg Roach     * @return Closure(string,string):int
546006094b9SGreg Roach     */
54737646143SGreg Roach    public static function comparator(): Closure
548006094b9SGreg Roach    {
54939bfe684SGreg Roach        $collator = self::$collator;
55039bfe684SGreg Roach
55139bfe684SGreg Roach        if ($collator instanceof Collator) {
55239bfe684SGreg Roach            return static fn (string $x, string $y): int => (int) $collator->compare($x, $y);
553006094b9SGreg Roach        }
554006094b9SGreg Roach
5556c3b7df0SGreg Roach        return static fn (string $x, string $y): int => strcmp(self::strtolower($x), self::strtolower($y));
556006094b9SGreg Roach    }
557006094b9SGreg Roach
558006094b9SGreg Roach    /**
559006094b9SGreg Roach     * Convert a string to lower case.
560006094b9SGreg Roach     *
561006094b9SGreg Roach     * @param string $string
562006094b9SGreg Roach     *
563006094b9SGreg Roach     * @return string
564006094b9SGreg Roach     */
565e0c85f48SGreg Roach    public static function strtolower(string $string): string
566006094b9SGreg Roach    {
567006094b9SGreg Roach        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES, true)) {
568006094b9SGreg Roach            $string = strtr($string, self::DOTLESS_I_TOLOWER);
569006094b9SGreg Roach        }
570006094b9SGreg Roach
571006094b9SGreg Roach        return mb_strtolower($string);
572006094b9SGreg Roach    }
573006094b9SGreg Roach
574006094b9SGreg Roach    /**
575006094b9SGreg Roach     * Convert a string to upper case.
576006094b9SGreg Roach     *
577006094b9SGreg Roach     * @param string $string
578006094b9SGreg Roach     *
579006094b9SGreg Roach     * @return string
580006094b9SGreg Roach     */
581e0c85f48SGreg Roach    public static function strtoupper(string $string): string
582006094b9SGreg Roach    {
583006094b9SGreg Roach        if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES, true)) {
584006094b9SGreg Roach            $string = strtr($string, self::DOTLESS_I_TOUPPER);
585006094b9SGreg Roach        }
586006094b9SGreg Roach
587006094b9SGreg Roach        return mb_strtoupper($string);
588006094b9SGreg Roach    }
589006094b9SGreg Roach
590006094b9SGreg Roach    /**
591dfeee0a8SGreg Roach     * What format is used to display dates in the current locale?
592dfeee0a8SGreg Roach     *
593dfeee0a8SGreg Roach     * @return string
594dfeee0a8SGreg Roach     */
5958f53f488SRico Sonntag    public static function timeFormat(): string
596c1010edaSGreg Roach    {
597ad3143ccSGreg Roach        /* I18N: This is the format string for the time-of-day. See https://php.net/date for codes */
598bbb76c12SGreg Roach        return self::$translator->translate('%H:%i:%s');
599dfeee0a8SGreg Roach    }
600dfeee0a8SGreg Roach
601dfeee0a8SGreg Roach    /**
602dfeee0a8SGreg Roach     * Context sensitive version of translate.
603a4956c0eSGreg Roach     * echo I18N::translateContext('NOMINATIVE', 'January');
604a4956c0eSGreg Roach     * echo I18N::translateContext('GENITIVE', 'January');
605dfeee0a8SGreg Roach     *
606924d091bSGreg Roach     * @param string $context
607924d091bSGreg Roach     * @param string $message
608a515be7cSGreg Roach     * @param string ...$args
609c3283ed7SGreg Roach     *
610dfeee0a8SGreg Roach     * @return string
611dfeee0a8SGreg Roach     */
612924d091bSGreg Roach    public static function translateContext(string $context, string $message, ...$args): string
613c1010edaSGreg Roach    {
614924d091bSGreg Roach        $message = self::$translator->translateContext($context, $message);
615dfeee0a8SGreg Roach
616924d091bSGreg Roach        return sprintf($message, ...$args);
617a25f0a04SGreg Roach    }
618a25f0a04SGreg Roach}
619