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