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; 31a25f0a04SGreg Roach 32a25f0a04SGreg Roach/** 3376692c8bSGreg Roach * Internationalization (i18n) and localization (l10n). 34a25f0a04SGreg Roach */ 35c1010edaSGreg Roachclass I18N 36c1010edaSGreg Roach{ 37d37db671SGreg Roach // MO files use special characters for plurals and context. 38d37db671SGreg Roach public const PLURAL = '\x00'; 39d37db671SGreg Roach public const CONTEXT = '\x04'; 40d37db671SGreg Roach 4115834aaeSGreg Roach /** @var LocaleInterface The current locale (e.g. LocaleEnGb) */ 42c999a340SGreg Roach private static $locale; 43c999a340SGreg Roach 4476692c8bSGreg Roach /** @var Translator An object that performs translation */ 453bdc890bSGreg Roach private static $translator; 463bdc890bSGreg Roach 47c9ec599fSGreg Roach /** @var Collator|null From the php-intl library */ 48991b93ddSGreg Roach private static $collator; 49991b93ddSGreg Roach 50a25f0a04SGreg Roach // Digits are always rendered LTR, even in RTL text. 5116d6367aSGreg Roach private const DIGITS = '0123456789٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹'; 52a25f0a04SGreg Roach 53991b93ddSGreg Roach // These locales need special handling for the dotless letter I. 5416d6367aSGreg Roach private const DOTLESS_I_LOCALES = [ 55c1010edaSGreg Roach 'az', 56c1010edaSGreg Roach 'tr', 57c1010edaSGreg Roach ]; 5816d6367aSGreg Roach private const DOTLESS_I_TOLOWER = [ 59c1010edaSGreg Roach 'I' => 'ı', 60c1010edaSGreg Roach 'İ' => 'i', 61c1010edaSGreg Roach ]; 6216d6367aSGreg Roach private const DOTLESS_I_TOUPPER = [ 63c1010edaSGreg Roach 'ı' => 'I', 64c1010edaSGreg Roach 'i' => 'İ', 65c1010edaSGreg Roach ]; 66a25f0a04SGreg Roach 67991b93ddSGreg Roach // The ranges of characters used by each script. 6816d6367aSGreg Roach private const SCRIPT_CHARACTER_RANGES = [ 69c1010edaSGreg Roach [ 70c1010edaSGreg Roach 'Latn', 71c1010edaSGreg Roach 0x0041, 72c1010edaSGreg Roach 0x005A, 73c1010edaSGreg Roach ], 74c1010edaSGreg Roach [ 75c1010edaSGreg Roach 'Latn', 76c1010edaSGreg Roach 0x0061, 77c1010edaSGreg Roach 0x007A, 78c1010edaSGreg Roach ], 79c1010edaSGreg Roach [ 80c1010edaSGreg Roach 'Latn', 81c1010edaSGreg Roach 0x0100, 82c1010edaSGreg Roach 0x02AF, 83c1010edaSGreg Roach ], 84c1010edaSGreg Roach [ 85c1010edaSGreg Roach 'Grek', 86c1010edaSGreg Roach 0x0370, 87c1010edaSGreg Roach 0x03FF, 88c1010edaSGreg Roach ], 89c1010edaSGreg Roach [ 90c1010edaSGreg Roach 'Cyrl', 91c1010edaSGreg Roach 0x0400, 92c1010edaSGreg Roach 0x052F, 93c1010edaSGreg Roach ], 94c1010edaSGreg Roach [ 95c1010edaSGreg Roach 'Hebr', 96c1010edaSGreg Roach 0x0590, 97c1010edaSGreg Roach 0x05FF, 98c1010edaSGreg Roach ], 99c1010edaSGreg Roach [ 100c1010edaSGreg Roach 'Arab', 101c1010edaSGreg Roach 0x0600, 102c1010edaSGreg Roach 0x06FF, 103c1010edaSGreg Roach ], 104c1010edaSGreg Roach [ 105c1010edaSGreg Roach 'Arab', 106c1010edaSGreg Roach 0x0750, 107c1010edaSGreg Roach 0x077F, 108c1010edaSGreg Roach ], 109c1010edaSGreg Roach [ 110c1010edaSGreg Roach 'Arab', 111c1010edaSGreg Roach 0x08A0, 112c1010edaSGreg Roach 0x08FF, 113c1010edaSGreg Roach ], 114c1010edaSGreg Roach [ 115c1010edaSGreg Roach 'Deva', 116c1010edaSGreg Roach 0x0900, 117c1010edaSGreg Roach 0x097F, 118c1010edaSGreg Roach ], 119c1010edaSGreg Roach [ 120c1010edaSGreg Roach 'Taml', 121c1010edaSGreg Roach 0x0B80, 122c1010edaSGreg Roach 0x0BFF, 123c1010edaSGreg Roach ], 124c1010edaSGreg Roach [ 125c1010edaSGreg Roach 'Sinh', 126c1010edaSGreg Roach 0x0D80, 127c1010edaSGreg Roach 0x0DFF, 128c1010edaSGreg Roach ], 129c1010edaSGreg Roach [ 130c1010edaSGreg Roach 'Thai', 131c1010edaSGreg Roach 0x0E00, 132c1010edaSGreg Roach 0x0E7F, 133c1010edaSGreg Roach ], 134c1010edaSGreg Roach [ 135c1010edaSGreg Roach 'Geor', 136c1010edaSGreg Roach 0x10A0, 137c1010edaSGreg Roach 0x10FF, 138c1010edaSGreg Roach ], 139c1010edaSGreg Roach [ 140c1010edaSGreg Roach 'Grek', 141c1010edaSGreg Roach 0x1F00, 142c1010edaSGreg Roach 0x1FFF, 143c1010edaSGreg Roach ], 144c1010edaSGreg Roach [ 145c1010edaSGreg Roach 'Deva', 146c1010edaSGreg Roach 0xA8E0, 147c1010edaSGreg Roach 0xA8FF, 148c1010edaSGreg Roach ], 149c1010edaSGreg Roach [ 150c1010edaSGreg Roach 'Hans', 151c1010edaSGreg Roach 0x3000, 152c1010edaSGreg Roach 0x303F, 153c1010edaSGreg Roach ], 154c1010edaSGreg Roach // Mixed CJK, not just Hans 155c1010edaSGreg Roach [ 156c1010edaSGreg Roach 'Hans', 157c1010edaSGreg Roach 0x3400, 158c1010edaSGreg Roach 0xFAFF, 159c1010edaSGreg Roach ], 160c1010edaSGreg Roach // Mixed CJK, not just Hans 161c1010edaSGreg Roach [ 162c1010edaSGreg Roach 'Hans', 163c1010edaSGreg Roach 0x20000, 164c1010edaSGreg Roach 0x2FA1F, 165c1010edaSGreg Roach ], 166c1010edaSGreg Roach // Mixed CJK, not just Hans 16713abd6f3SGreg Roach ]; 168a25f0a04SGreg Roach 169991b93ddSGreg Roach // Characters that are displayed in mirror form in RTL text. 17016d6367aSGreg Roach private const MIRROR_CHARACTERS = [ 171a25f0a04SGreg Roach '(' => ')', 172a25f0a04SGreg Roach ')' => '(', 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 '’ ' => '‘', 18913abd6f3SGreg Roach ]; 190a25f0a04SGreg Roach 191a25f0a04SGreg Roach /** @var string Punctuation used to separate list items, typically a comma */ 192a25f0a04SGreg Roach public static $list_separator; 193a25f0a04SGreg Roach 194a25f0a04SGreg Roach /** 19502086832SGreg Roach * The preferred locales for this site, or a default list if no preference. 196dfeee0a8SGreg Roach * 197dfeee0a8SGreg Roach * @return LocaleInterface[] 198dfeee0a8SGreg Roach */ 1998f53f488SRico Sonntag public static function activeLocales(): array 200c1010edaSGreg Roach { 20102086832SGreg Roach $locales = app(ModuleService::class) 202d6137952SGreg Roach ->findByInterface(ModuleLanguageInterface::class, false, true) 203*0b5fd0a6SGreg Roach ->map(static function (ModuleLanguageInterface $module): LocaleInterface { 20402086832SGreg Roach return $module->locale(); 20502086832SGreg Roach }); 206dfeee0a8SGreg Roach 20702086832SGreg Roach if ($locales->isEmpty()) { 20802086832SGreg Roach return [new LocaleEnUs()]; 209dfeee0a8SGreg Roach } 210dfeee0a8SGreg Roach 21102086832SGreg Roach return $locales->all(); 212dfeee0a8SGreg Roach } 213dfeee0a8SGreg Roach 214dfeee0a8SGreg Roach /** 215dfeee0a8SGreg Roach * Which MySQL collation should be used for this locale? 216dfeee0a8SGreg Roach * 217dfeee0a8SGreg Roach * @return string 218dfeee0a8SGreg Roach */ 219e364afe4SGreg Roach public static function collation(): string 220c1010edaSGreg Roach { 221dfeee0a8SGreg Roach $collation = self::$locale->collation(); 222dfeee0a8SGreg Roach switch ($collation) { 223dfeee0a8SGreg Roach case 'croatian_ci': 224dfeee0a8SGreg Roach case 'german2_ci': 225dfeee0a8SGreg Roach case 'vietnamese_ci': 226dfeee0a8SGreg Roach // Only available in MySQL 5.6 227dfeee0a8SGreg Roach return 'utf8_unicode_ci'; 228dfeee0a8SGreg Roach default: 229dfeee0a8SGreg Roach return 'utf8_' . $collation; 230dfeee0a8SGreg Roach } 231dfeee0a8SGreg Roach } 232dfeee0a8SGreg Roach 233dfeee0a8SGreg Roach /** 234dfeee0a8SGreg Roach * What format is used to display dates in the current locale? 235dfeee0a8SGreg Roach * 236dfeee0a8SGreg Roach * @return string 237dfeee0a8SGreg Roach */ 2388f53f488SRico Sonntag public static function dateFormat(): string 239c1010edaSGreg Roach { 240bbb76c12SGreg Roach /* I18N: This is the format string for full dates. See http://php.net/date for codes */ 241bbb76c12SGreg Roach return self::$translator->translate('%j %F %Y'); 242dfeee0a8SGreg Roach } 243dfeee0a8SGreg Roach 244dfeee0a8SGreg Roach /** 245dfeee0a8SGreg Roach * Convert the digits 0-9 into the local script 246dfeee0a8SGreg Roach * Used for years, etc., where we do not want thousands-separators, decimals, etc. 247dfeee0a8SGreg Roach * 24855664801SGreg Roach * @param string|int $n 249dfeee0a8SGreg Roach * 250dfeee0a8SGreg Roach * @return string 251dfeee0a8SGreg Roach */ 2528f53f488SRico Sonntag public static function digits($n): string 253c1010edaSGreg Roach { 25455664801SGreg Roach return self::$locale->digits((string) $n); 255dfeee0a8SGreg Roach } 256dfeee0a8SGreg Roach 257dfeee0a8SGreg Roach /** 258dfeee0a8SGreg Roach * What is the direction of the current locale 259dfeee0a8SGreg Roach * 260dfeee0a8SGreg Roach * @return string "ltr" or "rtl" 261dfeee0a8SGreg Roach */ 2628f53f488SRico Sonntag public static function direction(): string 263c1010edaSGreg Roach { 264dfeee0a8SGreg Roach return self::$locale->direction(); 265dfeee0a8SGreg Roach } 266dfeee0a8SGreg Roach 267dfeee0a8SGreg Roach /** 2687231a557SGreg Roach * What is the first day of the week. 2697231a557SGreg Roach * 270cbc1590aSGreg Roach * @return int Sunday=0, Monday=1, etc. 2717231a557SGreg Roach */ 2728f53f488SRico Sonntag public static function firstDay(): int 273c1010edaSGreg Roach { 2747231a557SGreg Roach return self::$locale->territory()->firstDay(); 2757231a557SGreg Roach } 2767231a557SGreg Roach 2777231a557SGreg Roach /** 278dfeee0a8SGreg Roach * Generate i18n markup for the <html> tag, e.g. lang="ar" dir="rtl" 279dfeee0a8SGreg Roach * 280dfeee0a8SGreg Roach * @return string 281dfeee0a8SGreg Roach */ 2828f53f488SRico Sonntag public static function htmlAttributes(): string 283c1010edaSGreg Roach { 284dfeee0a8SGreg Roach return self::$locale->htmlAttributes(); 285dfeee0a8SGreg Roach } 286dfeee0a8SGreg Roach 287dfeee0a8SGreg Roach /** 288a25f0a04SGreg Roach * Initialise the translation adapter with a locale setting. 289a25f0a04SGreg Roach * 29015d603e7SGreg Roach * @param string $code Use this locale/language code, or choose one automatically 291e58a20ffSGreg Roach * @param Tree|null $tree 292c116a5ccSGreg Roach * @param bool $setup During setup, we cannot access the database. 293a25f0a04SGreg Roach * 294a25f0a04SGreg Roach * @return string $string 295a25f0a04SGreg Roach */ 296081ddc56SGreg Roach public static function init(string $code = '', Tree $tree = null, $setup = false): string 297c1010edaSGreg Roach { 29815d603e7SGreg Roach if ($code !== '') { 2993bdc890bSGreg Roach // Create the specified locale 3003bdc890bSGreg Roach self::$locale = Locale::create($code); 3014ee95e68SRico Sonntag } elseif (Session::has('locale') && file_exists(WT_ROOT . 'resources/lang/' . Session::get('locale') . '/messages.mo')) { 302e58a20ffSGreg Roach // Select a previously used locale 30331bc7874SGreg Roach self::$locale = Locale::create(Session::get('locale')); 3043bdc890bSGreg Roach } else { 305e58a20ffSGreg Roach if ($tree instanceof Tree) { 306e58a20ffSGreg Roach $default_locale = Locale::create($tree->getPreference('LANGUAGE', 'en-US')); 307e58a20ffSGreg Roach } else { 30859f2f229SGreg Roach $default_locale = new LocaleEnUs(); 3093bdc890bSGreg Roach } 310e58a20ffSGreg Roach 311e58a20ffSGreg Roach // Negotiate with the browser. 312e58a20ffSGreg Roach // Search engines don't negotiate. They get the default locale of the tree. 313c116a5ccSGreg Roach if ($setup) { 314c116a5ccSGreg Roach $installed_locales = app(ModuleService::class)->setupLanguages() 315*0b5fd0a6SGreg Roach ->map(static function (ModuleLanguageInterface $module): LocaleInterface { 316c116a5ccSGreg Roach return $module->locale(); 317c116a5ccSGreg Roach }); 318c116a5ccSGreg Roach } else { 319c116a5ccSGreg Roach $installed_locales = self::installedLocales(); 320c116a5ccSGreg Roach } 321c116a5ccSGreg Roach 322c116a5ccSGreg Roach self::$locale = Locale::httpAcceptLanguage($_SERVER, $installed_locales->all(), $default_locale); 3233bdc890bSGreg Roach } 3243bdc890bSGreg Roach 325f1af7e1cSGreg Roach $cache_dir = WT_DATA_DIR . 'cache/'; 326f1af7e1cSGreg Roach $cache_file = $cache_dir . 'language-' . self::$locale->languageTag() . '-cache.php'; 3273bdc890bSGreg Roach if (file_exists($cache_file)) { 3283bdc890bSGreg Roach $filemtime = filemtime($cache_file); 3293bdc890bSGreg Roach } else { 3303bdc890bSGreg Roach $filemtime = 0; 3313bdc890bSGreg Roach } 3323bdc890bSGreg Roach 3333bdc890bSGreg Roach // Load the translation file(s) 334362b8464SGreg Roach $translation_files = [ 335362b8464SGreg Roach WT_ROOT . 'resources/lang/' . self::$locale->languageTag() . '/messages.mo', 336362b8464SGreg Roach ]; 337362b8464SGreg Roach 3387a7f87d7SGreg Roach // Rebuild files after one hour 3397a7f87d7SGreg Roach $rebuild_cache = time() > $filemtime + 3600; 3401e71bdc0SGreg Roach // Rebuild files if any translation file has been updated 3413bdc890bSGreg Roach foreach ($translation_files as $translation_file) { 3423bdc890bSGreg Roach if (filemtime($translation_file) > $filemtime) { 3433bdc890bSGreg Roach $rebuild_cache = true; 344a25f0a04SGreg Roach break; 345a25f0a04SGreg Roach } 346a25f0a04SGreg Roach } 3473bdc890bSGreg Roach 3483bdc890bSGreg Roach if ($rebuild_cache) { 34913abd6f3SGreg Roach $translations = []; 3503bdc890bSGreg Roach foreach ($translation_files as $translation_file) { 3513bdc890bSGreg Roach $translation = new Translation($translation_file); 3523bdc890bSGreg Roach $translations = array_merge($translations, $translation->asArray()); 353a25f0a04SGreg Roach } 354f1af7e1cSGreg Roach try { 355f1af7e1cSGreg Roach File::mkdir($cache_dir); 356f1af7e1cSGreg Roach file_put_contents($cache_file, '<?php return ' . var_export($translations, true) . ';'); 357f1af7e1cSGreg Roach } catch (Exception $ex) { 3587c2999b4SGreg Roach // During setup, we may not have been able to create it. 359c85fb0c4SGreg Roach } 3603bdc890bSGreg Roach } else { 3613bdc890bSGreg Roach $translations = include $cache_file; 362a25f0a04SGreg Roach } 363a25f0a04SGreg Roach 364d37db671SGreg Roach // Add translations from custom modules (but not during setup) 365c116a5ccSGreg Roach if (!$setup) { 36602086832SGreg Roach $custom_modules = app(ModuleService::class) 36702086832SGreg Roach ->findByInterface(ModuleCustomInterface::class); 368d37db671SGreg Roach 369d37db671SGreg Roach foreach ($custom_modules as $custom_module) { 370d37db671SGreg Roach $custom_translations = $custom_module->customTranslations(self::$locale->languageTag()); 371d37db671SGreg Roach $translations = array_merge($translations, $custom_translations); 372d37db671SGreg Roach } 373d37db671SGreg Roach } 374d37db671SGreg Roach 3753bdc890bSGreg Roach // Create a translator 3763bdc890bSGreg Roach self::$translator = new Translator($translations, self::$locale->pluralRule()); 377a25f0a04SGreg Roach 378bbb76c12SGreg Roach /* I18N: This punctuation is used to separate lists of items */ 379bbb76c12SGreg Roach self::$list_separator = self::translate(', '); 380a25f0a04SGreg Roach 381991b93ddSGreg Roach // Create a collator 382991b93ddSGreg Roach try { 383444a65ecSGreg Roach if (class_exists('Collator')) { 384c9ec599fSGreg Roach // Symfony provides a very incomplete polyfill - which cannot be used. 385991b93ddSGreg Roach self::$collator = new Collator(self::$locale->code()); 386991b93ddSGreg Roach // Ignore upper/lower case differences 387991b93ddSGreg Roach self::$collator->setStrength(Collator::SECONDARY); 388444a65ecSGreg Roach } 389991b93ddSGreg Roach } catch (Exception $ex) { 390991b93ddSGreg Roach // PHP-INTL is not installed? We'll use a fallback later. 391c9ec599fSGreg Roach self::$collator = null; 392991b93ddSGreg Roach } 393991b93ddSGreg Roach 3945331c5eaSGreg Roach return self::$locale->languageTag(); 395a25f0a04SGreg Roach } 396a25f0a04SGreg Roach 397a25f0a04SGreg Roach /** 398c999a340SGreg Roach * All locales for which a translation file exists. 399c999a340SGreg Roach * 400c116a5ccSGreg Roach * @return Collection 40115834aaeSGreg Roach * @return LocaleInterface[] 402c999a340SGreg Roach */ 403c116a5ccSGreg Roach public static function installedLocales(): Collection 404c1010edaSGreg Roach { 40502086832SGreg Roach return app(ModuleService::class) 40602086832SGreg Roach ->findByInterface(ModuleLanguageInterface::class, true) 407*0b5fd0a6SGreg Roach ->map(static function (ModuleLanguageInterface $module): LocaleInterface { 40802086832SGreg Roach return $module->locale(); 409c116a5ccSGreg Roach }); 410a25f0a04SGreg Roach } 411a25f0a04SGreg Roach 412a25f0a04SGreg Roach /** 413a25f0a04SGreg Roach * Return the endonym for a given language - as per http://cldr.unicode.org/ 414a25f0a04SGreg Roach * 415a25f0a04SGreg Roach * @param string $locale 416a25f0a04SGreg Roach * 417a25f0a04SGreg Roach * @return string 418a25f0a04SGreg Roach */ 41955664801SGreg Roach public static function languageName(string $locale): string 420c1010edaSGreg Roach { 421c999a340SGreg Roach return Locale::create($locale)->endonym(); 422a25f0a04SGreg Roach } 423a25f0a04SGreg Roach 424a25f0a04SGreg Roach /** 425a25f0a04SGreg Roach * Return the script used by a given language 426a25f0a04SGreg Roach * 427a25f0a04SGreg Roach * @param string $locale 428a25f0a04SGreg Roach * 429a25f0a04SGreg Roach * @return string 430a25f0a04SGreg Roach */ 43155664801SGreg Roach public static function languageScript(string $locale): string 432c1010edaSGreg Roach { 433c999a340SGreg Roach return Locale::create($locale)->script()->code(); 434a25f0a04SGreg Roach } 435a25f0a04SGreg Roach 436a25f0a04SGreg Roach /** 437dfeee0a8SGreg Roach * Translate a number into the local representation. 438dfeee0a8SGreg Roach * e.g. 12345.67 becomes 439dfeee0a8SGreg Roach * en: 12,345.67 440dfeee0a8SGreg Roach * fr: 12 345,67 441dfeee0a8SGreg Roach * de: 12.345,67 442dfeee0a8SGreg Roach * 443dfeee0a8SGreg Roach * @param float $n 444cbc1590aSGreg Roach * @param int $precision 445a25f0a04SGreg Roach * 446a25f0a04SGreg Roach * @return string 447a25f0a04SGreg Roach */ 44855664801SGreg Roach public static function number(float $n, int $precision = 0): string 449c1010edaSGreg Roach { 450dfeee0a8SGreg Roach return self::$locale->number(round($n, $precision)); 451dfeee0a8SGreg Roach } 452dfeee0a8SGreg Roach 453dfeee0a8SGreg Roach /** 454dfeee0a8SGreg Roach * Translate a fraction into a percentage. 455dfeee0a8SGreg Roach * e.g. 0.123 becomes 456dfeee0a8SGreg Roach * en: 12.3% 457dfeee0a8SGreg Roach * fr: 12,3 % 458dfeee0a8SGreg Roach * de: 12,3% 459dfeee0a8SGreg Roach * 460dfeee0a8SGreg Roach * @param float $n 461cbc1590aSGreg Roach * @param int $precision 462dfeee0a8SGreg Roach * 463dfeee0a8SGreg Roach * @return string 464dfeee0a8SGreg Roach */ 46555664801SGreg Roach public static function percentage(float $n, int $precision = 0): string 466c1010edaSGreg Roach { 467dfeee0a8SGreg Roach return self::$locale->percent(round($n, $precision + 2)); 468dfeee0a8SGreg Roach } 469dfeee0a8SGreg Roach 470dfeee0a8SGreg Roach /** 471dfeee0a8SGreg Roach * Translate a plural string 472dfeee0a8SGreg Roach * echo self::plural('There is an error', 'There are errors', $num_errors); 473dfeee0a8SGreg Roach * echo self::plural('There is one error', 'There are %s errors', $num_errors); 474dfeee0a8SGreg Roach * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour); 475dfeee0a8SGreg Roach * 476924d091bSGreg Roach * @param string $singular 477924d091bSGreg Roach * @param string $plural 478924d091bSGreg Roach * @param int $count 479a515be7cSGreg Roach * @param string ...$args 480e93111adSRico Sonntag * 481dfeee0a8SGreg Roach * @return string 482dfeee0a8SGreg Roach */ 483924d091bSGreg Roach public static function plural(string $singular, string $plural, int $count, ...$args): string 484c1010edaSGreg Roach { 485924d091bSGreg Roach $message = self::$translator->translatePlural($singular, $plural, $count); 486dfeee0a8SGreg Roach 487924d091bSGreg Roach return sprintf($message, ...$args); 488dfeee0a8SGreg Roach } 489dfeee0a8SGreg Roach 490dfeee0a8SGreg Roach /** 491dfeee0a8SGreg Roach * UTF8 version of PHP::strrev() 492dfeee0a8SGreg Roach * Reverse RTL text for third-party libraries such as GD2 and googlechart. 493dfeee0a8SGreg Roach * These do not support UTF8 text direction, so we must mimic it for them. 494dfeee0a8SGreg Roach * Numbers are always rendered LTR, even in RTL text. 495dfeee0a8SGreg Roach * The visual direction of characters such as parentheses should be reversed. 496dfeee0a8SGreg Roach * 497dfeee0a8SGreg Roach * @param string $text Text to be reversed 498dfeee0a8SGreg Roach * 499dfeee0a8SGreg Roach * @return string 500dfeee0a8SGreg Roach */ 5018f53f488SRico Sonntag public static function reverseText($text): string 502c1010edaSGreg Roach { 503dfeee0a8SGreg Roach // Remove HTML markup - we can't display it and it is LTR. 5049524b7b5SGreg Roach $text = strip_tags($text); 5059524b7b5SGreg Roach // Remove HTML entities. 5069524b7b5SGreg Roach $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8'); 507dfeee0a8SGreg Roach 508dfeee0a8SGreg Roach // LTR text doesn't need reversing 509dfeee0a8SGreg Roach if (self::scriptDirection(self::textScript($text)) === 'ltr') { 510dfeee0a8SGreg Roach return $text; 511dfeee0a8SGreg Roach } 512dfeee0a8SGreg Roach 513dfeee0a8SGreg Roach // Mirrored characters 514991b93ddSGreg Roach $text = strtr($text, self::MIRROR_CHARACTERS); 515dfeee0a8SGreg Roach 516dfeee0a8SGreg Roach $reversed = ''; 517dfeee0a8SGreg Roach $digits = ''; 518e364afe4SGreg Roach while ($text !== '') { 519dfeee0a8SGreg Roach $letter = mb_substr($text, 0, 1); 520dfeee0a8SGreg Roach $text = mb_substr($text, 1); 521dfeee0a8SGreg Roach if (strpos(self::DIGITS, $letter) !== false) { 522dfeee0a8SGreg Roach $digits .= $letter; 523a25f0a04SGreg Roach } else { 524dfeee0a8SGreg Roach $reversed = $letter . $digits . $reversed; 525dfeee0a8SGreg Roach $digits = ''; 526dfeee0a8SGreg Roach } 527a25f0a04SGreg Roach } 528a25f0a04SGreg Roach 529dfeee0a8SGreg Roach return $digits . $reversed; 530a25f0a04SGreg Roach } 531a25f0a04SGreg Roach 532a25f0a04SGreg Roach /** 533a25f0a04SGreg Roach * Return the direction (ltr or rtl) for a given script 534a25f0a04SGreg Roach * The PHP/intl library does not provde this information, so we need 535a25f0a04SGreg Roach * our own lookup table. 536a25f0a04SGreg Roach * 537a25f0a04SGreg Roach * @param string $script 538a25f0a04SGreg Roach * 539a25f0a04SGreg Roach * @return string 540a25f0a04SGreg Roach */ 541e364afe4SGreg Roach public static function scriptDirection($script): string 542c1010edaSGreg Roach { 543a25f0a04SGreg Roach switch ($script) { 544a25f0a04SGreg Roach case 'Arab': 545a25f0a04SGreg Roach case 'Hebr': 546a25f0a04SGreg Roach case 'Mong': 547a25f0a04SGreg Roach case 'Thaa': 548a25f0a04SGreg Roach return 'rtl'; 549a25f0a04SGreg Roach default: 550a25f0a04SGreg Roach return 'ltr'; 551a25f0a04SGreg Roach } 552a25f0a04SGreg Roach } 553a25f0a04SGreg Roach 554a25f0a04SGreg Roach /** 555991b93ddSGreg Roach * Perform a case-insensitive comparison of two strings. 556a25f0a04SGreg Roach * 557a25f0a04SGreg Roach * @param string $string1 558a25f0a04SGreg Roach * @param string $string2 559a25f0a04SGreg Roach * 560cbc1590aSGreg Roach * @return int 561a25f0a04SGreg Roach */ 562e364afe4SGreg Roach public static function strcasecmp($string1, $string2): int 563c1010edaSGreg Roach { 564991b93ddSGreg Roach if (self::$collator instanceof Collator) { 565991b93ddSGreg Roach return self::$collator->compare($string1, $string2); 566a25f0a04SGreg Roach } 567e364afe4SGreg Roach 568e364afe4SGreg Roach return strcmp(self::strtolower($string1), self::strtolower($string2)); 569c9ec599fSGreg Roach } 570a25f0a04SGreg Roach 571a25f0a04SGreg Roach /** 572991b93ddSGreg Roach * Convert a string to lower case. 573a25f0a04SGreg Roach * 574dfeee0a8SGreg Roach * @param string $string 575a25f0a04SGreg Roach * 576a25f0a04SGreg Roach * @return string 577a25f0a04SGreg Roach */ 5788f53f488SRico Sonntag public static function strtolower($string): string 579c1010edaSGreg Roach { 58002086832SGreg Roach if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES, true)) { 581991b93ddSGreg Roach $string = strtr($string, self::DOTLESS_I_TOLOWER); 582a25f0a04SGreg Roach } 5835ddad20bSGreg Roach 5845ddad20bSGreg Roach return mb_strtolower($string); 585a25f0a04SGreg Roach } 586a25f0a04SGreg Roach 587a25f0a04SGreg Roach /** 588991b93ddSGreg Roach * Convert a string to upper case. 589dfeee0a8SGreg Roach * 590dfeee0a8SGreg Roach * @param string $string 591a25f0a04SGreg Roach * 592a25f0a04SGreg Roach * @return string 593a25f0a04SGreg Roach */ 5948f53f488SRico Sonntag public static function strtoupper($string): string 595c1010edaSGreg Roach { 59602086832SGreg Roach if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES, true)) { 597991b93ddSGreg Roach $string = strtr($string, self::DOTLESS_I_TOUPPER); 598a25f0a04SGreg Roach } 5995ddad20bSGreg Roach 6005ddad20bSGreg Roach return mb_strtoupper($string); 601a25f0a04SGreg Roach } 602a25f0a04SGreg Roach 603dfeee0a8SGreg Roach /** 604dfeee0a8SGreg Roach * Identify the script used for a piece of text 605dfeee0a8SGreg Roach * 606d0bfc631SGreg Roach * @param string $string 607dfeee0a8SGreg Roach * 608dfeee0a8SGreg Roach * @return string 609dfeee0a8SGreg Roach */ 6108f53f488SRico Sonntag public static function textScript($string): string 611c1010edaSGreg Roach { 612dfeee0a8SGreg Roach $string = strip_tags($string); // otherwise HTML tags show up as latin 613dfeee0a8SGreg Roach $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin 614c1010edaSGreg Roach $string = str_replace([ 615c1010edaSGreg Roach '@N.N.', 616c1010edaSGreg Roach '@P.N.', 617c1010edaSGreg Roach ], '', $string); // otherwise unknown names show up as latin 618dfeee0a8SGreg Roach $pos = 0; 619dfeee0a8SGreg Roach $strlen = strlen($string); 620dfeee0a8SGreg Roach while ($pos < $strlen) { 621dfeee0a8SGreg Roach // get the Unicode Code Point for the character at position $pos 622dfeee0a8SGreg Roach $byte1 = ord($string[$pos]); 623dfeee0a8SGreg Roach if ($byte1 < 0x80) { 624dfeee0a8SGreg Roach $code_point = $byte1; 625dfeee0a8SGreg Roach $chrlen = 1; 626dfeee0a8SGreg Roach } elseif ($byte1 < 0xC0) { 627dfeee0a8SGreg Roach // Invalid continuation character 628dfeee0a8SGreg Roach return 'Latn'; 629dfeee0a8SGreg Roach } elseif ($byte1 < 0xE0) { 630dfeee0a8SGreg Roach $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F); 631dfeee0a8SGreg Roach $chrlen = 2; 632dfeee0a8SGreg Roach } elseif ($byte1 < 0xF0) { 633dfeee0a8SGreg Roach $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F); 634dfeee0a8SGreg Roach $chrlen = 3; 635dfeee0a8SGreg Roach } elseif ($byte1 < 0xF8) { 636dfeee0a8SGreg Roach $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F); 637dfeee0a8SGreg Roach $chrlen = 3; 638dfeee0a8SGreg Roach } else { 639dfeee0a8SGreg Roach // Invalid UTF 640dfeee0a8SGreg Roach return 'Latn'; 641dfeee0a8SGreg Roach } 642dfeee0a8SGreg Roach 643991b93ddSGreg Roach foreach (self::SCRIPT_CHARACTER_RANGES as $range) { 644dfeee0a8SGreg Roach if ($code_point >= $range[1] && $code_point <= $range[2]) { 645dfeee0a8SGreg Roach return $range[0]; 646dfeee0a8SGreg Roach } 647dfeee0a8SGreg Roach } 648dfeee0a8SGreg Roach // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking. 649dfeee0a8SGreg Roach $pos += $chrlen; 650dfeee0a8SGreg Roach } 651dfeee0a8SGreg Roach 652dfeee0a8SGreg Roach return 'Latn'; 653dfeee0a8SGreg Roach } 654dfeee0a8SGreg Roach 655dfeee0a8SGreg Roach /** 656dfeee0a8SGreg Roach * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago" 657dfeee0a8SGreg Roach * 658cbc1590aSGreg Roach * @param int $seconds 659dfeee0a8SGreg Roach * 660dfeee0a8SGreg Roach * @return string 661dfeee0a8SGreg Roach */ 662e364afe4SGreg Roach public static function timeAgo($seconds): string 663c1010edaSGreg Roach { 664dfeee0a8SGreg Roach $minute = 60; 665dfeee0a8SGreg Roach $hour = 60 * $minute; 666dfeee0a8SGreg Roach $day = 24 * $hour; 667dfeee0a8SGreg Roach $month = 30 * $day; 668dfeee0a8SGreg Roach $year = 365 * $day; 669dfeee0a8SGreg Roach 670dfeee0a8SGreg Roach if ($seconds > $year) { 671cdaafeeeSGreg Roach $years = intdiv($seconds, $year); 672cbc1590aSGreg Roach 673dfeee0a8SGreg Roach return self::plural('%s year ago', '%s years ago', $years, self::number($years)); 674b2ce94c6SRico Sonntag } 675b2ce94c6SRico Sonntag 676b2ce94c6SRico Sonntag if ($seconds > $month) { 677cdaafeeeSGreg Roach $months = intdiv($seconds, $month); 678cbc1590aSGreg Roach 679dfeee0a8SGreg Roach return self::plural('%s month ago', '%s months ago', $months, self::number($months)); 680b2ce94c6SRico Sonntag } 681b2ce94c6SRico Sonntag 682b2ce94c6SRico Sonntag if ($seconds > $day) { 683cdaafeeeSGreg Roach $days = intdiv($seconds, $day); 684cbc1590aSGreg Roach 685dfeee0a8SGreg Roach return self::plural('%s day ago', '%s days ago', $days, self::number($days)); 686b2ce94c6SRico Sonntag } 687b2ce94c6SRico Sonntag 688b2ce94c6SRico Sonntag if ($seconds > $hour) { 689cdaafeeeSGreg Roach $hours = intdiv($seconds, $hour); 690cbc1590aSGreg Roach 691dfeee0a8SGreg Roach return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours)); 692b2ce94c6SRico Sonntag } 693b2ce94c6SRico Sonntag 694b2ce94c6SRico Sonntag if ($seconds > $minute) { 695cdaafeeeSGreg Roach $minutes = intdiv($seconds, $minute); 696cbc1590aSGreg Roach 697dfeee0a8SGreg Roach return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes)); 698dfeee0a8SGreg Roach } 699b2ce94c6SRico Sonntag 700b2ce94c6SRico Sonntag return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds)); 701dfeee0a8SGreg Roach } 702dfeee0a8SGreg Roach 703dfeee0a8SGreg Roach /** 704dfeee0a8SGreg Roach * What format is used to display dates in the current locale? 705dfeee0a8SGreg Roach * 706dfeee0a8SGreg Roach * @return string 707dfeee0a8SGreg Roach */ 7088f53f488SRico Sonntag public static function timeFormat(): string 709c1010edaSGreg Roach { 710bbb76c12SGreg Roach /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */ 711bbb76c12SGreg Roach return self::$translator->translate('%H:%i:%s'); 712dfeee0a8SGreg Roach } 713dfeee0a8SGreg Roach 714dfeee0a8SGreg Roach /** 715dfeee0a8SGreg Roach * Translate a string, and then substitute placeholders 716dfeee0a8SGreg Roach * echo I18N::translate('Hello World!'); 717dfeee0a8SGreg Roach * echo I18N::translate('The %s sat on the mat', 'cat'); 718dfeee0a8SGreg Roach * 719924d091bSGreg Roach * @param string $message 720a515be7cSGreg Roach * @param string ...$args 721c3283ed7SGreg Roach * 722dfeee0a8SGreg Roach * @return string 723dfeee0a8SGreg Roach */ 724924d091bSGreg Roach public static function translate(string $message, ...$args): string 725c1010edaSGreg Roach { 726924d091bSGreg Roach $message = self::$translator->translate($message); 727dfeee0a8SGreg Roach 728924d091bSGreg Roach return sprintf($message, ...$args); 729dfeee0a8SGreg Roach } 730dfeee0a8SGreg Roach 731dfeee0a8SGreg Roach /** 732dfeee0a8SGreg Roach * Context sensitive version of translate. 733a4956c0eSGreg Roach * echo I18N::translateContext('NOMINATIVE', 'January'); 734a4956c0eSGreg Roach * echo I18N::translateContext('GENITIVE', 'January'); 735dfeee0a8SGreg Roach * 736924d091bSGreg Roach * @param string $context 737924d091bSGreg Roach * @param string $message 738a515be7cSGreg Roach * @param string ...$args 739c3283ed7SGreg Roach * 740dfeee0a8SGreg Roach * @return string 741dfeee0a8SGreg Roach */ 742924d091bSGreg Roach public static function translateContext(string $context, string $message, ...$args): string 743c1010edaSGreg Roach { 744924d091bSGreg Roach $message = self::$translator->translateContext($context, $message); 745dfeee0a8SGreg Roach 746924d091bSGreg Roach return sprintf($message, ...$args); 747a25f0a04SGreg Roach } 748a25f0a04SGreg Roach} 749