1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2018 webtrees development team 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * You should have received a copy of the GNU General Public License 14 * along with this program. If not, see <http://www.gnu.org/licenses/>. 15 */ 16namespace Fisharebest\Webtrees; 17 18use Collator; 19use Exception; 20use Fisharebest\ExtCalendar\ArabicCalendar; 21use Fisharebest\ExtCalendar\CalendarInterface; 22use Fisharebest\ExtCalendar\GregorianCalendar; 23use Fisharebest\ExtCalendar\JewishCalendar; 24use Fisharebest\ExtCalendar\PersianCalendar; 25use Fisharebest\Localization\Locale; 26use Fisharebest\Localization\Locale\LocaleEnUs; 27use Fisharebest\Localization\Locale\LocaleInterface; 28use Fisharebest\Localization\Translation; 29use Fisharebest\Localization\Translator; 30use Fisharebest\Webtrees\Functions\FunctionsEdit; 31 32/** 33 * Internationalization (i18n) and localization (l10n). 34 */ 35class I18N 36{ 37 /** @var LocaleInterface The current locale (e.g. LocaleEnGb) */ 38 private static $locale; 39 40 /** @var Translator An object that performs translation */ 41 private static $translator; 42 43 /** @var Collator From the php-intl library */ 44 private static $collator; 45 46 // Digits are always rendered LTR, even in RTL text. 47 const DIGITS = '0123456789٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹'; 48 49 // These locales need special handling for the dotless letter I. 50 const DOTLESS_I_LOCALES = [ 51 'az', 52 'tr', 53 ]; 54 const DOTLESS_I_TOLOWER = [ 55 'I' => 'ı', 56 'İ' => 'i', 57 ]; 58 const DOTLESS_I_TOUPPER = [ 59 'ı' => 'I', 60 'i' => 'İ', 61 ]; 62 63 // The ranges of characters used by each script. 64 const SCRIPT_CHARACTER_RANGES = [ 65 [ 66 'Latn', 67 0x0041, 68 0x005A, 69 ], 70 [ 71 'Latn', 72 0x0061, 73 0x007A, 74 ], 75 [ 76 'Latn', 77 0x0100, 78 0x02AF, 79 ], 80 [ 81 'Grek', 82 0x0370, 83 0x03FF, 84 ], 85 [ 86 'Cyrl', 87 0x0400, 88 0x052F, 89 ], 90 [ 91 'Hebr', 92 0x0590, 93 0x05FF, 94 ], 95 [ 96 'Arab', 97 0x0600, 98 0x06FF, 99 ], 100 [ 101 'Arab', 102 0x0750, 103 0x077F, 104 ], 105 [ 106 'Arab', 107 0x08A0, 108 0x08FF, 109 ], 110 [ 111 'Deva', 112 0x0900, 113 0x097F, 114 ], 115 [ 116 'Taml', 117 0x0B80, 118 0x0BFF, 119 ], 120 [ 121 'Sinh', 122 0x0D80, 123 0x0DFF, 124 ], 125 [ 126 'Thai', 127 0x0E00, 128 0x0E7F, 129 ], 130 [ 131 'Geor', 132 0x10A0, 133 0x10FF, 134 ], 135 [ 136 'Grek', 137 0x1F00, 138 0x1FFF, 139 ], 140 [ 141 'Deva', 142 0xA8E0, 143 0xA8FF, 144 ], 145 [ 146 'Hans', 147 0x3000, 148 0x303F, 149 ], 150 // Mixed CJK, not just Hans 151 [ 152 'Hans', 153 0x3400, 154 0xFAFF, 155 ], 156 // Mixed CJK, not just Hans 157 [ 158 'Hans', 159 0x20000, 160 0x2FA1F, 161 ], 162 // Mixed CJK, not just Hans 163 ]; 164 165 // Characters that are displayed in mirror form in RTL text. 166 const MIRROR_CHARACTERS = [ 167 '(' => ')', 168 ')' => '(', 169 '[' => ']', 170 ']' => '[', 171 '{' => '}', 172 '}' => '{', 173 '<' => '>', 174 '>' => '<', 175 '‹ ' => '›', 176 '› ' => '‹', 177 '«' => '»', 178 '»' => '«', 179 '﴾ ' => '﴿', 180 '﴿ ' => '﴾', 181 '“ ' => '”', 182 '” ' => '“', 183 '‘ ' => '’', 184 '’ ' => '‘', 185 ]; 186 187 // Default list of locales to show in the menu. 188 const DEFAULT_LOCALES = [ 189 'ar', 190 'bg', 191 'bs', 192 'ca', 193 'cs', 194 'da', 195 'de', 196 'el', 197 'en-GB', 198 'en-US', 199 'es', 200 'et', 201 'fi', 202 'fr', 203 'he', 204 'hr', 205 'hu', 206 'is', 207 'it', 208 'ka', 209 'kk', 210 'lt', 211 'mr', 212 'nb', 213 'nl', 214 'nn', 215 'pl', 216 'pt', 217 'ru', 218 'sk', 219 'sv', 220 'tr', 221 'uk', 222 'vi', 223 'zh-Hans', 224 ]; 225 226 /** @var string Punctuation used to separate list items, typically a comma */ 227 public static $list_separator; 228 229 /** 230 * The prefered locales for this site, or a default list if no preference. 231 * 232 * @return LocaleInterface[] 233 */ 234 public static function activeLocales() 235 { 236 $code_list = Site::getPreference('LANGUAGES'); 237 238 if ($code_list === '') { 239 $codes = self::DEFAULT_LOCALES; 240 } else { 241 $codes = explode(',', $code_list); 242 } 243 244 $locales = []; 245 foreach ($codes as $code) { 246 if (file_exists(WT_ROOT . 'language/' . $code . '.mo')) { 247 try { 248 $locales[] = Locale::create($code); 249 } catch (\Exception $ex) { 250 DebugBar::addThrowable($ex); 251 252 // No such locale exists? 253 } 254 } 255 } 256 usort($locales, '\Fisharebest\Localization\Locale::compare'); 257 258 return $locales; 259 } 260 261 /** 262 * Which MySQL collation should be used for this locale? 263 * 264 * @return string 265 */ 266 public static function collation() 267 { 268 $collation = self::$locale->collation(); 269 switch ($collation) { 270 case 'croatian_ci': 271 case 'german2_ci': 272 case 'vietnamese_ci': 273 // Only available in MySQL 5.6 274 return 'utf8_unicode_ci'; 275 default: 276 return 'utf8_' . $collation; 277 } 278 } 279 280 /** 281 * What format is used to display dates in the current locale? 282 * 283 * @return string 284 */ 285 public static function dateFormat() 286 { 287 return /* I18N: This is the format string for full dates. See http://php.net/date for codes */ 288 self::$translator->translate('%j %F %Y'); 289 } 290 291 /** 292 * Generate consistent I18N for datatables.js 293 * 294 * @param array|null $lengths An optional array of page lengths 295 * 296 * @return string 297 */ 298 public static function datatablesI18N(array $lengths = [ 299 10, 300 20, 301 30, 302 50, 303 100, 304 -1, 305 ]) 306 { 307 $length_options = Bootstrap4::select(FunctionsEdit::numericOptions($lengths), 10); 308 309 return 310 '"formatNumber": function(n) { return String(n).replace(/[0-9]/g, function(w) { return ("' . self::$locale->digits('0123456789') . '")[+w]; }); },' . 311 '"language": {' . 312 ' "paginate": {' . 313 ' "first": "' . /* I18N: A button label, first page */ 314 self::translate('first') . '",' . 315 ' "last": "' . /* I18N: A button label, last page */ 316 self::translate('last') . '",' . 317 ' "next": "' . /* I18N: A button label, next page */ 318 self::translate('next') . '",' . 319 ' "previous": "' . /* I18N: A button label, previous page */ 320 self::translate('previous') . '"' . 321 ' },' . 322 ' "emptyTable": "' . self::translate('No records to display') . '",' . 323 ' "info": "' . /* I18N: %s are placeholders for numbers */ 324 self::translate('Showing %1$s to %2$s of %3$s', '_START_', '_END_', '_TOTAL_') . '",' . 325 ' "infoEmpty": "' . self::translate('Showing %1$s to %2$s of %3$s', self::$locale->digits('0'), self::$locale->digits('0'), self::$locale->digits('0')) . '",' . 326 ' "infoFiltered": "' . /* I18N: %s is a placeholder for a number */ 327 self::translate('(filtered from %s total entries)', '_MAX_') . '",' . 328 ' "lengthMenu": "' . /* I18N: %s is a number of records per page */ 329 self::translate('Display %s', addslashes($length_options)) . '",' . 330 ' "loadingRecords": "' . self::translate('Loading…') . '",' . 331 ' "processing": "' . self::translate('Loading…') . '",' . 332 ' "search": "' . self::translate('Filter') . '",' . 333 ' "zeroRecords": "' . self::translate('No records to display') . '"' . 334 '}'; 335 } 336 337 /** 338 * Convert the digits 0-9 into the local script 339 * 340 * Used for years, etc., where we do not want thousands-separators, decimals, etc. 341 * 342 * @param int $n 343 * 344 * @return string 345 */ 346 public static function digits($n) 347 { 348 return self::$locale->digits($n); 349 } 350 351 /** 352 * What is the direction of the current locale 353 * 354 * @return string "ltr" or "rtl" 355 */ 356 public static function direction() 357 { 358 return self::$locale->direction(); 359 } 360 361 /** 362 * What is the first day of the week. 363 * 364 * @return int Sunday=0, Monday=1, etc. 365 */ 366 public static function firstDay() 367 { 368 return self::$locale->territory()->firstDay(); 369 } 370 371 /** 372 * Convert a GEDCOM age string into translated_text 373 * 374 * NB: The import function will have normalised this, so we don't need 375 * to worry about badly formatted strings 376 * NOTE: this function is not yet complete - eventually it will replace FunctionsDate::get_age_at_event() 377 * 378 * @param $string 379 * 380 * @return string 381 */ 382 public static function gedcomAge($string) 383 { 384 switch ($string) { 385 case 'STILLBORN': 386 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (stillborn) 387 return self::translate('(stillborn)'); 388 case 'INFANT': 389 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in infancy) 390 return self::translate('(in infancy)'); 391 case 'CHILD': 392 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (in childhood) 393 return self::translate('(in childhood)'); 394 } 395 $age = []; 396 if (preg_match('/(\d+)y/', $string, $match)) { 397 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 398 $years = $match[1]; 399 $age[] = self::plural('%s year', '%s years', $years, self::number($years)); 400 } else { 401 $years = -1; 402 } 403 if (preg_match('/(\d+)m/', $string, $match)) { 404 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 405 $age[] = self::plural('%s month', '%s months', $match[1], self::number($match[1])); 406 } 407 if (preg_match('/(\d+)w/', $string, $match)) { 408 // I18N: Part of an age string. e.g. 7 weeks and 3 days 409 $age[] = self::plural('%s week', '%s weeks', $match[1], self::number($match[1])); 410 } 411 if (preg_match('/(\d+)d/', $string, $match)) { 412 // I18N: Part of an age string. e.g. 5 years, 4 months and 3 days 413 $age[] = self::plural('%s day', '%s days', $match[1], self::number($match[1])); 414 } 415 // If an age is just a number of years, only show the number 416 if (count($age) === 1 && $years >= 0) { 417 $age = $years; 418 } 419 if ($age) { 420 if (!substr_compare($string, '<', 0, 1)) { 421 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged less than 21 years) 422 return self::translate('(aged less than %s)', $age); 423 } elseif (!substr_compare($string, '>', 0, 1)) { 424 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged more than 21 years) 425 return self::translate('(aged more than %s)', $age); 426 } else { 427 // I18N: Description of an individual’s age at an event. For example, Died 14 Jan 1900 (aged 43 years) 428 return self::translate('(aged %s)', $age); 429 } 430 } else { 431 // Not a valid string? 432 return self::translate('(aged %s)', $string); 433 } 434 } 435 436 /** 437 * Generate i18n markup for the <html> tag, e.g. lang="ar" dir="rtl" 438 * 439 * @return string 440 */ 441 public static function htmlAttributes() 442 { 443 return self::$locale->htmlAttributes(); 444 } 445 446 /** 447 * Initialise the translation adapter with a locale setting. 448 * 449 * @param string $code Use this locale/language code, or choose one automatically 450 * 451 * @return string $string 452 */ 453 public static function init($code = '') 454 { 455 mb_internal_encoding('UTF-8'); 456 457 if ($code !== '') { 458 // Create the specified locale 459 self::$locale = Locale::create($code); 460 } else { 461 // Negotiate a locale, but if we can't then use a failsafe 462 self::$locale = new LocaleEnUs; 463 if (Session::has('locale') && file_exists(WT_ROOT . 'language/' . Session::get('locale') . '.mo')) { 464 // Previously used 465 self::$locale = Locale::create(Session::get('locale')); 466 } else { 467 // Browser negotiation 468 $default_locale = new LocaleEnUs; 469 try { 470 // @TODO, when no language is requested by the user (e.g. search engines), we should use 471 // the tree's default language. However, we currently initialise languages before trees, 472 // so there is no tree available for us to use. 473 } catch (\Exception $ex) { 474 DebugBar::addThrowable($ex); 475 } 476 self::$locale = Locale::httpAcceptLanguage($_SERVER, self::installedLocales(), $default_locale); 477 } 478 } 479 480 $cache_dir = WT_DATA_DIR . 'cache/'; 481 $cache_file = $cache_dir . 'language-' . self::$locale->languageTag() . '-cache.php'; 482 if (file_exists($cache_file)) { 483 $filemtime = filemtime($cache_file); 484 } else { 485 $filemtime = 0; 486 } 487 488 // Load the translation file(s) 489 // Note that glob() returns false instead of an empty array when open_basedir_restriction 490 // is in force and no files are found. See PHP bug #47358. 491 if (defined('GLOB_BRACE')) { 492 $translation_files = array_merge( 493 [WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'], 494 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: [], 495 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.{csv,php,mo}', GLOB_BRACE) ?: [] 496 ); 497 } else { 498 // Some servers do not have GLOB_BRACE - see http://php.net/manual/en/function.glob.php 499 $translation_files = array_merge( 500 [WT_ROOT . 'language/' . self::$locale->languageTag() . '.mo'], 501 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.csv') ?: [], 502 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.php') ?: [], 503 glob(WT_MODULES_DIR . '*/language/' . self::$locale->languageTag() . '.mo') ?: [], 504 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.csv') ?: [], 505 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.php') ?: [], 506 glob(WT_DATA_DIR . 'language/' . self::$locale->languageTag() . '.mo') ?: [] 507 ); 508 } 509 // Rebuild files after one hour 510 $rebuild_cache = time() > $filemtime + 3600; 511 // Rebuild files if any translation file has been updated 512 foreach ($translation_files as $translation_file) { 513 if (filemtime($translation_file) > $filemtime) { 514 $rebuild_cache = true; 515 break; 516 } 517 } 518 519 if ($rebuild_cache) { 520 $translations = []; 521 foreach ($translation_files as $translation_file) { 522 $translation = new Translation($translation_file); 523 $translations = array_merge($translations, $translation->asArray()); 524 } 525 try { 526 File::mkdir($cache_dir); 527 file_put_contents($cache_file, '<?php return ' . var_export($translations, true) . ';'); 528 } catch (Exception $ex) { 529 DebugBar::addThrowable($ex); 530 531 // During setup, we may not have been able to create it. 532 } 533 } else { 534 $translations = include $cache_file; 535 } 536 537 // Create a translator 538 self::$translator = new Translator($translations, self::$locale->pluralRule()); 539 540 self::$list_separator = /* I18N: This punctuation is used to separate lists of items */ 541 self::translate(', '); 542 543 // Create a collator 544 try { 545 // PHP 5.6 cannot catch errors, so test first 546 if (class_exists('Collator')) { 547 self::$collator = new Collator(self::$locale->code()); 548 // Ignore upper/lower case differences 549 self::$collator->setStrength(Collator::SECONDARY); 550 } 551 } catch (Exception $ex) { 552 DebugBar::addThrowable($ex); 553 554 // PHP-INTL is not installed? We'll use a fallback later. 555 } 556 557 return self::$locale->languageTag(); 558 } 559 560 /** 561 * All locales for which a translation file exists. 562 * 563 * @return LocaleInterface[] 564 */ 565 public static function installedLocales() 566 { 567 $locales = []; 568 foreach (glob(WT_ROOT . 'language/*.mo') as $file) { 569 try { 570 $locales[] = Locale::create(basename($file, '.mo')); 571 } catch (\Exception $ex) { 572 DebugBar::addThrowable($ex); 573 574 // Not a recognised locale 575 } 576 } 577 usort($locales, '\Fisharebest\Localization\Locale::compare'); 578 579 return $locales; 580 } 581 582 /** 583 * Return the endonym for a given language - as per http://cldr.unicode.org/ 584 * 585 * @param string $locale 586 * 587 * @return string 588 */ 589 public static function languageName($locale) 590 { 591 return Locale::create($locale)->endonym(); 592 } 593 594 /** 595 * Return the script used by a given language 596 * 597 * @param string $locale 598 * 599 * @return string 600 */ 601 public static function languageScript($locale) 602 { 603 return Locale::create($locale)->script()->code(); 604 } 605 606 /** 607 * Translate a number into the local representation. 608 * 609 * e.g. 12345.67 becomes 610 * en: 12,345.67 611 * fr: 12 345,67 612 * de: 12.345,67 613 * 614 * @param float $n 615 * @param int $precision 616 * 617 * @return string 618 */ 619 public static function number($n, $precision = 0) 620 { 621 return self::$locale->number(round($n, $precision)); 622 } 623 624 /** 625 * Translate a fraction into a percentage. 626 * 627 * e.g. 0.123 becomes 628 * en: 12.3% 629 * fr: 12,3 % 630 * de: 12,3% 631 * 632 * @param float $n 633 * @param int $precision 634 * 635 * @return string 636 */ 637 public static function percentage($n, $precision = 0) 638 { 639 return self::$locale->percent(round($n, $precision + 2)); 640 } 641 642 /** 643 * Translate a plural string 644 * 645 * echo self::plural('There is an error', 'There are errors', $num_errors); 646 * echo self::plural('There is one error', 'There are %s errors', $num_errors); 647 * echo self::plural('There is %1$s %2$s cat', 'There are %1$s %2$s cats', $num, $num, $colour); 648 * 649 * @return string 650 */ 651 public static function plural(/* var_args */) 652 { 653 $args = func_get_args(); 654 $args[0] = self::$translator->translatePlural($args[0], $args[1], (int)$args[2]); 655 unset($args[1], $args[2]); 656 657 return self::substitutePlaceholders($args); 658 } 659 660 /** 661 * UTF8 version of PHP::strrev() 662 * 663 * Reverse RTL text for third-party libraries such as GD2 and googlechart. 664 * 665 * These do not support UTF8 text direction, so we must mimic it for them. 666 * 667 * Numbers are always rendered LTR, even in RTL text. 668 * The visual direction of characters such as parentheses should be reversed. 669 * 670 * @param string $text Text to be reversed 671 * 672 * @return string 673 */ 674 public static function reverseText($text) 675 { 676 // Remove HTML markup - we can't display it and it is LTR. 677 $text = strip_tags($text); 678 // Remove HTML entities. 679 $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8'); 680 681 // LTR text doesn't need reversing 682 if (self::scriptDirection(self::textScript($text)) === 'ltr') { 683 return $text; 684 } 685 686 // Mirrored characters 687 $text = strtr($text, self::MIRROR_CHARACTERS); 688 689 $reversed = ''; 690 $digits = ''; 691 while ($text != '') { 692 $letter = mb_substr($text, 0, 1); 693 $text = mb_substr($text, 1); 694 if (strpos(self::DIGITS, $letter) !== false) { 695 $digits .= $letter; 696 } else { 697 $reversed = $letter . $digits . $reversed; 698 $digits = ''; 699 } 700 } 701 702 return $digits . $reversed; 703 } 704 705 /** 706 * Return the direction (ltr or rtl) for a given script 707 * 708 * The PHP/intl library does not provde this information, so we need 709 * our own lookup table. 710 * 711 * @param string $script 712 * 713 * @return string 714 */ 715 public static function scriptDirection($script) 716 { 717 switch ($script) { 718 case 'Arab': 719 case 'Hebr': 720 case 'Mong': 721 case 'Thaa': 722 return 'rtl'; 723 default: 724 return 'ltr'; 725 } 726 } 727 728 /** 729 * Perform a case-insensitive comparison of two strings. 730 * 731 * @param string $string1 732 * @param string $string2 733 * 734 * @return int 735 */ 736 public static function strcasecmp($string1, $string2) 737 { 738 if (self::$collator instanceof Collator) { 739 return self::$collator->compare($string1, $string2); 740 } else { 741 return strcmp(self::strtolower($string1), self::strtolower($string2)); 742 } 743 } 744 745 /** 746 * Convert a string to lower case. 747 * 748 * @param string $string 749 * 750 * @return string 751 */ 752 public static function strtolower($string) 753 { 754 if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) { 755 $string = strtr($string, self::DOTLESS_I_TOLOWER); 756 } 757 758 return mb_strtolower($string); 759 } 760 761 /** 762 * Convert a string to upper case. 763 * 764 * @param string $string 765 * 766 * @return string 767 */ 768 public static function strtoupper($string) 769 { 770 if (in_array(self::$locale->language()->code(), self::DOTLESS_I_LOCALES)) { 771 $string = strtr($string, self::DOTLESS_I_TOUPPER); 772 } 773 774 return mb_strtoupper($string); 775 } 776 777 /** 778 * Substitute any "%s" placeholders in a translated string. 779 * This also allows us to have translated strings that contain 780 * "%" characters, which can't be passed to sprintf. 781 * 782 * @param string[] $args translated string plus optional parameters 783 * 784 * @return string 785 */ 786 private static function substitutePlaceholders(array $args) 787 { 788 if (count($args) > 1) { 789 return call_user_func_array('sprintf', $args); 790 } else { 791 return $args[0]; 792 } 793 } 794 795 /** 796 * Identify the script used for a piece of text 797 * 798 * @param $string 799 * 800 * @return string 801 */ 802 public static function textScript($string) 803 { 804 $string = strip_tags($string); // otherwise HTML tags show up as latin 805 $string = html_entity_decode($string, ENT_QUOTES, 'UTF-8'); // otherwise HTML entities show up as latin 806 $string = str_replace([ 807 '@N.N.', 808 '@P.N.', 809 ], '', $string); // otherwise unknown names show up as latin 810 $pos = 0; 811 $strlen = strlen($string); 812 while ($pos < $strlen) { 813 // get the Unicode Code Point for the character at position $pos 814 $byte1 = ord($string[$pos]); 815 if ($byte1 < 0x80) { 816 $code_point = $byte1; 817 $chrlen = 1; 818 } elseif ($byte1 < 0xC0) { 819 // Invalid continuation character 820 return 'Latn'; 821 } elseif ($byte1 < 0xE0) { 822 $code_point = (($byte1 & 0x1F) << 6) + (ord($string[$pos + 1]) & 0x3F); 823 $chrlen = 2; 824 } elseif ($byte1 < 0xF0) { 825 $code_point = (($byte1 & 0x0F) << 12) + ((ord($string[$pos + 1]) & 0x3F) << 6) + (ord($string[$pos + 2]) & 0x3F); 826 $chrlen = 3; 827 } elseif ($byte1 < 0xF8) { 828 $code_point = (($byte1 & 0x07) << 24) + ((ord($string[$pos + 1]) & 0x3F) << 12) + ((ord($string[$pos + 2]) & 0x3F) << 6) + (ord($string[$pos + 3]) & 0x3F); 829 $chrlen = 3; 830 } else { 831 // Invalid UTF 832 return 'Latn'; 833 } 834 835 foreach (self::SCRIPT_CHARACTER_RANGES as $range) { 836 if ($code_point >= $range[1] && $code_point <= $range[2]) { 837 return $range[0]; 838 } 839 } 840 // Not a recognised script. Maybe punctuation, spacing, etc. Keep looking. 841 $pos += $chrlen; 842 } 843 844 return 'Latn'; 845 } 846 847 /** 848 * Convert a number of seconds into a relative time. For example, 630 => "10 hours, 30 minutes ago" 849 * 850 * @param int $seconds 851 * 852 * @return string 853 */ 854 public static function timeAgo($seconds) 855 { 856 $minute = 60; 857 $hour = 60 * $minute; 858 $day = 24 * $hour; 859 $month = 30 * $day; 860 $year = 365 * $day; 861 862 if ($seconds > $year) { 863 $years = (int)($seconds / $year); 864 865 return self::plural('%s year ago', '%s years ago', $years, self::number($years)); 866 } elseif ($seconds > $month) { 867 $months = (int)($seconds / $month); 868 869 return self::plural('%s month ago', '%s months ago', $months, self::number($months)); 870 } elseif ($seconds > $day) { 871 $days = (int)($seconds / $day); 872 873 return self::plural('%s day ago', '%s days ago', $days, self::number($days)); 874 } elseif ($seconds > $hour) { 875 $hours = (int)($seconds / $hour); 876 877 return self::plural('%s hour ago', '%s hours ago', $hours, self::number($hours)); 878 } elseif ($seconds > $minute) { 879 $minutes = (int)($seconds / $minute); 880 881 return self::plural('%s minute ago', '%s minutes ago', $minutes, self::number($minutes)); 882 } else { 883 return self::plural('%s second ago', '%s seconds ago', $seconds, self::number($seconds)); 884 } 885 } 886 887 /** 888 * What format is used to display dates in the current locale? 889 * 890 * @return string 891 */ 892 public static function timeFormat() 893 { 894 return /* I18N: This is the format string for the time-of-day. See http://php.net/date for codes */ 895 self::$translator->translate('%H:%i:%s'); 896 } 897 898 /** 899 * Translate a string, and then substitute placeholders 900 * 901 * echo I18N::translate('Hello World!'); 902 * echo I18N::translate('The %s sat on the mat', 'cat'); 903 * 904 * @return string 905 */ 906 public static function translate(/* var_args */) 907 { 908 $args = func_get_args(); 909 $args[0] = self::$translator->translate($args[0]); 910 911 return self::substitutePlaceholders($args); 912 } 913 914 /** 915 * Context sensitive version of translate. 916 * 917 * echo I18N::translateContext('NOMINATIVE', 'January'); 918 * echo I18N::translateContext('GENITIVE', 'January'); 919 * 920 * @return string 921 */ 922 public static function translateContext(/* var_args */) 923 { 924 $args = func_get_args(); 925 $args[0] = self::$translator->translateContext($args[0], $args[1]); 926 unset($args[1]); 927 928 return self::substitutePlaceholders($args); 929 } 930 931 /** 932 * What is the last day of the weekend. 933 * 934 * @return int Sunday=0, Monday=1, etc. 935 */ 936 public static function weekendEnd() 937 { 938 return self::$locale->territory()->weekendEnd(); 939 } 940 941 /** 942 * What is the first day of the weekend. 943 * 944 * @return int Sunday=0, Monday=1, etc. 945 */ 946 public static function weekendStart() 947 { 948 return self::$locale->territory()->weekendStart(); 949 } 950 951 /** 952 * Which calendar prefered in this locale? 953 * 954 * @return CalendarInterface 955 */ 956 public static function defaultCalendar() 957 { 958 switch (self::$locale->languageTag()) { 959 case 'ar': 960 return new ArabicCalendar; 961 case 'fa': 962 return new PersianCalendar; 963 case 'he': 964 case 'yi': 965 return new JewishCalendar; 966 default: 967 return new GregorianCalendar; 968 } 969 } 970} 971